返回 DeepSeek-Reasonix
fork_targets_test.go
根目录 / internal / control / fork_targets_test.go
1 package control
2
3 import (
4 "context"
5 "errors"
6 "os"
7 "path/filepath"
8 "slices"
9 "strings"
10 "testing"
11
12 "reasonix/internal/agent"
13 "reasonix/internal/agent/testutil"
14 "reasonix/internal/event"
15 "reasonix/internal/session"
16 "reasonix/internal/tool"
17 )
18
19 // newForkTargetsHarness builds an exclusive v3 controller over a filesystem
20 // service, the harness the other v3 session tests use.
21 func newForkTargetsHarness(t *testing.T, sessionID string, script ...testutil.Turn) (*session.Service, *session.FilesystemPersistence, *Controller) {
22 t.Helper()
23 root := t.TempDir()
24 persistence := session.NewFilesystemPersistence(filepath.Join(root, "sessions-v4"))
25 service, err := session.NewService("desktop", persistence)
26 if err != nil {
27 t.Fatal(err)
28 }
29 t.Cleanup(func() { _ = service.CloseAll(context.Background()) })
30 runtime, err := service.Create(t.Context(), session.CreateOptions{SessionID: sessionID})
31 if err != nil {
32 t.Fatal(err)
33 }
34 exec := agent.New(testutil.NewMock(sessionID, script...), tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
35 c := newOwnedTestController(t, Options{
36 Runner: exec, Executor: exec, Sink: event.Discard,
37 SessionDir: filepath.Join(root, "legacy"),
38 SessionService: service, SessionRuntime: runtime, ExclusiveSession: true,
39 })
40 return service, persistence, c
41 }
42
43 // sessionDirNames lists the session identities a filesystem service holds. The
44 // query cache is a read artifact, not a session, so it is excluded.
45 func sessionDirNames(t *testing.T, root string) []string {
46 t.Helper()
47 entries, err := os.ReadDir(root)
48 if err != nil {
49 if os.IsNotExist(err) {
50 return nil
51 }
52 t.Fatal(err)
53 }
54 names := make([]string, 0, len(entries))
55 for _, entry := range entries {
56 if strings.HasPrefix(entry.Name(), ".") {
57 continue
58 }
59 names = append(names, entry.Name())
60 }
61 return names
62 }
63
64 func TestForkTargetsReportsCompletedTurnAvailable(t *testing.T) {
65 _, _, c := newForkTargetsHarness(t, "targets-parent", testutil.Turn{Text: "answer one"}, testutil.Turn{Text: "answer two"})
66 if err := c.RunTurn(t.Context(), "one"); err != nil {
67 t.Fatal(err)
68 }
69 if err := c.RunTurn(t.Context(), "two"); err != nil {
70 t.Fatal(err)
71 }
72
73 targets, err := c.ForkTargets()
74 if err != nil {
75 t.Fatal(err)
76 }
77 if !targets.Verifiable {
78 t.Fatal("a session with committed turns must be verifiable")
79 }
80 if len(targets.Targets) != 2 {
81 t.Fatalf("targets = %+v, want both completed turns", targets.Targets)
82 }
83 for index, target := range targets.Targets {
84 if !target.Available || target.Reason != "" {
85 t.Fatalf("target %d = %+v, want an available turn", index, target)
86 }
87 if target.TurnID == "" || target.MessageID == "" {
88 t.Fatalf("target %d lost its identity: %+v", index, target)
89 }
90 if target.TurnNumber != index+1 || target.Status != event.TurnCompleted {
91 t.Fatalf("target %d = %+v, want completed turn %d", index, target, index+1)
92 }
93 }
94 }
95
96 func TestCreateForkSessionCreatesChildWithoutSwitchingController(t *testing.T) {
97 service, _, c := newForkTargetsHarness(t, "fork-session-parent", testutil.Turn{Text: "answer one"}, testutil.Turn{Text: "answer two"})
98 if err := c.RunTurn(t.Context(), "one"); err != nil {
99 t.Fatal(err)
100 }
101 if err := c.RunTurn(t.Context(), "two"); err != nil {
102 t.Fatal(err)
103 }
104 targets, err := c.ForkTargets()
105 if err != nil {
106 t.Fatal(err)
107 }
108 parentRef, ok := c.SessionRef()
109 if !ok {
110 t.Fatal("controller has no v3 session")
111 }
112 parentPath := c.SessionPath()
113
114 childID, err := c.CreateForkSession(session.ForkRequest{Source: targets.Source, TurnID: targets.Targets[0].TurnID,
115 BoundarySequence: targets.Targets[0].BoundarySequence, OperationID: "op-create-child"}, "first turn")
116 if err != nil {
117 t.Fatal(err)
118 }
119 if childID == "" || childID == parentRef.SessionID {
120 t.Fatalf("child id = %q, parent = %q", childID, parentRef.SessionID)
121 }
122 if ref, ok := c.SessionRef(); !ok || ref != parentRef {
123 t.Fatalf("controller session = %+v, want %+v", ref, parentRef)
124 }
125 if got := c.SessionPath(); got != parentPath {
126 t.Fatalf("controller session path = %q, want %q", got, parentPath)
127 }
128
129 childRef := session.SessionRef{HostID: service.HostID(), SessionID: childID}
130 if _, opened := service.Runtime(childRef); opened {
131 t.Fatal("CreateForkSession published a child runtime")
132 }
133 childTargets, err := service.ForkTargetSetFor(t.Context(), childRef)
134 if err != nil {
135 t.Fatal(err)
136 }
137 if !childTargets.Verifiable || len(childTargets.Targets) != 1 ||
138 !childTargets.Targets[0].Available || childTargets.Targets[0].TurnID != targets.Targets[0].TurnID {
139 t.Fatalf("child targets = %+v, want the one inherited turn", childTargets)
140 }
141 childSnapshot, err := service.Query().Snapshot(t.Context(), childRef)
142 if err != nil {
143 t.Fatal(err)
144 }
145 if got := childSnapshot.Projection.Title; got != "first turn" {
146 t.Fatalf("child title = %q, want %q", got, "first turn")
147 }
148 if got := childSnapshot.Projection.Messages; len(got) != 3 || got[1].Content != "one" || got[2].Content != "answer one" {
149 t.Fatalf("child history = %#v, want the parent prefix through turn one", got)
150 }
151 }
152
153 func TestCreateForkSessionRejectsAnchorAfterControllerRebind(t *testing.T) {
154 service, persistence, c := newForkTargetsHarness(t, "anchor-parent", testutil.Turn{Text: "answer"})
155 if err := c.RunTurn(t.Context(), "one"); err != nil {
156 t.Fatal(err)
157 }
158 targets, err := c.ForkTargets()
159 if err != nil {
160 t.Fatal(err)
161 }
162 target := targets.Targets[0]
163 // Seed a second session from the same boundary so it inherits the same turn
164 // and message identities. Only the source SessionRef distinguishes it.
165 second, err := service.CreateFork(t.Context(), session.ForkRequest{Source: targets.Source, TurnID: target.TurnID,
166 BoundarySequence: target.BoundarySequence, OperationID: "seed-second-session"})
167 if err != nil {
168 t.Fatal(err)
169 }
170 if _, err := c.OpenSession(t.Context(), second.Child); err != nil {
171 t.Fatal(err)
172 }
173 before := sessionDirNames(t, persistence.Root)
174 _, err = c.CreateForkSession(session.ForkRequest{Source: targets.Source, TurnID: target.TurnID,
175 BoundarySequence: target.BoundarySequence, OperationID: "stale-source-create"}, "")
176 var unavailable *session.ForkUnavailableError
177 if !errors.As(err, &unavailable) || unavailable.Reason != session.ForkStaleSource {
178 t.Fatalf("rebound create error = %v, want stale_source", err)
179 }
180 if after := sessionDirNames(t, persistence.Root); !slices.Equal(before, after) {
181 t.Fatalf("stale source created a child: %v then %v", before, after)
182 }
183 }
184
185 func TestCreateForkSessionUnknownTurnCreatesNoChild(t *testing.T) {
186 _, persistence, c := newForkTargetsHarness(t, "unknown-turn-parent", testutil.Turn{Text: "answer"})
187 if err := c.RunTurn(t.Context(), "one"); err != nil {
188 t.Fatal(err)
189 }
190 parentRef, ok := c.SessionRef()
191 if !ok {
192 t.Fatal("controller has no v3 session")
193 }
194 before := sessionDirNames(t, persistence.Root)
195
196 childID, err := c.CreateForkSession(session.ForkRequest{Source: parentRef, TurnID: "no-such-turn",
197 BoundarySequence: 1, OperationID: "op-unknown-turn"}, "")
198 if childID != "" || err == nil {
199 t.Fatalf("CreateForkSession(unknown turn) = %q, %v", childID, err)
200 }
201 var unavailable *session.ForkUnavailableError
202 if !errors.As(err, &unavailable) || unavailable.Reason != session.ForkHistoryUnverifiable {
203 t.Fatalf("error = %v, want an unverifiable-history refusal", err)
204 }
205 if after := sessionDirNames(t, persistence.Root); !slices.Equal(before, after) {
206 t.Fatalf("refused fork changed session identities: %v then %v", before, after)
207 }
208 if ref, ok := c.SessionRef(); !ok || ref != parentRef {
209 t.Fatalf("controller session = %+v, want %+v", ref, parentRef)
210 }
211 }
212
213 func TestCreateForkSessionWorksWhileTurnRunning(t *testing.T) {
214 _, _, c := newForkTargetsHarness(t, "running-parent", testutil.Turn{Text: "answer"})
215 if err := c.RunTurn(t.Context(), "one"); err != nil {
216 t.Fatal(err)
217 }
218 parentRef, ok := c.SessionRef()
219 if !ok {
220 t.Fatal("controller has no v3 session")
221 }
222 parentPath := c.SessionPath()
223
224 // The harness expresses a live foreground turn the way the rotation tests
225 // do; no body runs, so the completed turn below stays committed.
226 c.mu.Lock()
227 c.turns.phase = session.RuntimeRunning
228 c.mu.Unlock()
229 defer func() {
230 c.mu.Lock()
231 c.turns.phase = session.RuntimeIdle
232 c.mu.Unlock()
233 }()
234
235 // The gate this test is about: the switching entry point refuses while the
236 // turn runs, so the call below is not passing for a trivial reason.
237 if _, err := c.ForkSession(1, ""); err == nil || !strings.Contains(err.Error(), "cannot fork while a turn is running") {
238 t.Fatalf("ForkSession while a turn runs = %v, want a running-turn refusal", err)
239 }
240 targets, err := c.ForkTargets()
241 if err != nil {
242 t.Fatal(err)
243 }
244 if len(targets.Targets) != 1 || !targets.Targets[0].Available {
245 t.Fatalf("targets while a turn runs = %+v", targets)
246 }
247
248 childID, err := c.CreateForkSession(session.ForkRequest{Source: targets.Source, TurnID: targets.Targets[0].TurnID,
249 BoundarySequence: targets.Targets[0].BoundarySequence, OperationID: "op-running-turn"}, "from a running turn")
250 if err != nil {
251 t.Fatal(err)
252 }
253 if childID == "" || childID == parentRef.SessionID {
254 t.Fatalf("child id = %q, parent = %q", childID, parentRef.SessionID)
255 }
256 if ref, ok := c.SessionRef(); !ok || ref != parentRef {
257 t.Fatalf("controller session = %+v, want %+v", ref, parentRef)
258 }
259 if got := c.SessionPath(); got != parentPath {
260 t.Fatalf("controller session path = %q, want %q", got, parentPath)
261 }
262 }
263
264 func TestForkTargetsLegacyEngineReportsUnverifiable(t *testing.T) {
265 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
266 c := newOwnedTestController(t, Options{Executor: exec, Sink: event.Discard})
267
268 targets, err := c.ForkTargets()
269 if err != nil {
270 t.Fatal(err)
271 }
272 if targets.Verifiable || len(targets.Targets) != 0 {
273 t.Fatalf("legacy targets = %+v, want an empty unverifiable set", targets)
274 }
275 // The checkpoint engine has no v3 identity, so a cut cannot be requested
276 // from it at all.
277 if _, err := c.CreateForkSession(session.ForkRequest{TurnID: "turn", BoundarySequence: 1, OperationID: "op-legacy"}, "name"); !errors.Is(err, session.ErrSessionNotRunning) {
278 t.Fatalf("legacy CreateForkSession = %v, want ErrSessionNotRunning", err)
279 }
280 }
281
281 lines GO