返回 DeepSeek-Reasonix
branches_test.go
根目录 / internal / control / branches_test.go
1 package control
2
3 import (
4 "context"
5 "os"
6 "path/filepath"
7 "strings"
8 "testing"
9
10 "reasonix/internal/agent"
11 "reasonix/internal/event"
12 "reasonix/internal/provider"
13 "reasonix/internal/session"
14 "reasonix/internal/store"
15 "reasonix/internal/tool"
16 )
17
18 func TestBranchAndSwitch(t *testing.T) {
19 dir := schemaOneTempDir(t)
20 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
21 exec.Session().Add(provider.Message{Role: provider.RoleUser, Content: "root prompt"})
22 c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test"})
23 c.SetSessionPath(agent.NewSessionPath(dir, "test"))
24 if err := c.Snapshot(); err != nil {
25 t.Fatal(err)
26 }
27 rootPath := c.SessionPath()
28 rootID := agent.BranchID(rootPath)
29
30 if _, err := c.Branch("try something"); err != nil {
31 t.Fatal(err)
32 }
33 childPath := c.SessionPath()
34 if childPath == rootPath {
35 t.Fatal("branch should switch to a new session path")
36 }
37 meta, ok, err := agent.LoadBranchMeta(childPath)
38 if err != nil || !ok {
39 t.Fatalf("load child meta ok=%v err=%v", ok, err)
40 }
41 if meta.ParentID != rootID || meta.Name != "try something" {
42 t.Fatalf("child meta = %+v, want parent %q and name", meta, rootID)
43 }
44 // Branch must seed the listing-only sidecar fields at creation, so the
45 // sidebar never has to decode the new .jsonl to show its turn count/preview.
46 if meta.Turns != 1 || meta.Preview != "root prompt" {
47 t.Fatalf("child meta should carry turns/preview from creation: turns=%d preview=%q", meta.Turns, meta.Preview)
48 }
49
50 if _, err := c.SwitchBranch(rootID); err != nil {
51 t.Fatal(err)
52 }
53 if c.SessionPath() != rootPath {
54 t.Fatalf("session path = %q, want %q", c.SessionPath(), rootPath)
55 }
56
57 tree := c.BranchTreeText()
58 if !strings.Contains(tree, shortBranchID(rootID)) || !strings.Contains(tree, "try something") {
59 t.Fatalf("tree missing expected branches:\n%s", tree)
60 }
61 }
62
63 func TestSnapshotExternalRemovalMovesOnceToStableRecovery(t *testing.T) {
64 t.Setenv(agent.SessionLogSchemaEnv, "v1")
65 dir := t.TempDir()
66 path := filepath.Join(dir, "root.jsonl")
67 session := agent.NewSession("sys")
68 session.Add(provider.Message{Role: provider.RoleUser, Content: "keep me"})
69 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
70 c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
71 if err := c.Snapshot(); err != nil {
72 t.Fatal(err)
73 }
74 for _, artifact := range append([]string{path}, store.SessionSidecarFiles(path)...) {
75 if artifact != "" {
76 _ = os.Remove(artifact)
77 }
78 }
79 session.Add(provider.Message{Role: provider.RoleAssistant, Content: "still here"})
80 if err := c.Snapshot(); err != nil {
81 t.Fatalf("snapshot after external removal: %v", err)
82 }
83 recovered := c.SessionPath()
84 if recovered == path || recovered == "" {
85 t.Fatalf("session path = %q, want recovery path", recovered)
86 }
87 if _, err := os.Stat(path); !os.IsNotExist(err) {
88 t.Fatalf("deleted original was recreated: %v", err)
89 }
90 if err := c.Snapshot(); err != nil {
91 t.Fatalf("second recovery snapshot: %v", err)
92 }
93 if got := c.SessionPath(); got != recovered {
94 t.Fatalf("recovery fork storm: %q -> %q", recovered, got)
95 }
96 }
97
98 func TestSwitchBranchRejectsCleanupPending(t *testing.T) {
99 dir := schemaOneTempDir(t)
100 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
101 exec.Session().Add(provider.Message{Role: provider.RoleUser, Content: "root prompt"})
102 c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test"})
103 c.SetSessionPath(filepath.Join(dir, "root.jsonl"))
104 if err := c.Snapshot(); err != nil {
105 t.Fatal(err)
106 }
107 rootPath := c.SessionPath()
108 rootID := agent.BranchID(rootPath)
109
110 if _, err := c.Branch("pending experiment"); err != nil {
111 t.Fatal(err)
112 }
113 pendingPath := c.SessionPath()
114 pendingID := agent.BranchID(pendingPath)
115 if _, err := c.SwitchBranch(rootID); err != nil {
116 t.Fatal(err)
117 }
118 if err := agent.MarkCleanupPending(pendingPath, "delete"); err != nil {
119 t.Fatal(err)
120 }
121
122 tree := c.BranchTreeText()
123 if strings.Contains(tree, "pending experiment") || strings.Contains(tree, shortBranchID(pendingID)) {
124 t.Fatalf("tree leaked cleanup-pending branch:\n%s", tree)
125 }
126 if _, err := c.SwitchBranch(pendingID); err == nil {
127 t.Fatal("SwitchBranch cleanup-pending id error = nil, want not found")
128 }
129 if c.SessionPath() != rootPath {
130 t.Fatalf("session path changed to %q, want %q", c.SessionPath(), rootPath)
131 }
132 if _, err := c.SwitchBranch(pendingPath); err == nil {
133 t.Fatal("SwitchBranch cleanup-pending path error = nil, want not found")
134 }
135 if c.SessionPath() != rootPath {
136 t.Fatalf("session path changed to %q, want %q", c.SessionPath(), rootPath)
137 }
138 }
139
140 func TestBranchResetsTwoModelPlannerContext(t *testing.T) {
141 dir := t.TempDir()
142 planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
143 planTurn("OLD PLAN: inspect alpha.go"),
144 planTurn("BRANCH PLAN: inspect beta.go"),
145 }}
146 execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
147 textTurn("old done"),
148 textTurn("branch done"),
149 }}
150 exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
151 coord := agent.NewCoordinator(planner, agent.NewSession("planner sys"), nil, agent.PlannerToolRegistry(tool.NewRegistry()), agent.Options{}, exec, 0, event.Discard, nil)
152 c := newOwnedTestController(t, Options{Runner: coord, Executor: exec, SystemPrompt: "exec sys", SessionDir: dir, SessionPath: filepath.Join(dir, "root.jsonl"), Label: "test"})
153
154 if err := c.Run(context.Background(), "old task alpha"); err != nil {
155 t.Fatal(err)
156 }
157 if _, err := c.Branch("child"); err != nil {
158 t.Fatal(err)
159 }
160 if err := c.Run(context.Background(), "branch task beta"); err != nil {
161 t.Fatal(err)
162 }
163
164 if len(planner.requests) != 2 {
165 t.Fatalf("planner requests = %d, want 2", len(planner.requests))
166 }
167 second := requestMessagesText(planner.requests[1].Messages)
168 if strings.Contains(second, "old task alpha") || strings.Contains(second, "OLD PLAN") {
169 t.Fatalf("branch planner request leaked previous session context:\n%s", second)
170 }
171 if !strings.Contains(second, "branch task beta") {
172 t.Fatalf("branch planner request missing current task:\n%s", second)
173 }
174 }
175
176 func TestSwitchBranchResetsTwoModelPlannerContext(t *testing.T) {
177 dir := t.TempDir()
178 planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
179 planTurn("ROOT PLAN: inspect alpha.go"),
180 planTurn("CHILD PLAN: inspect beta.go"),
181 planTurn("ROOT AGAIN PLAN: inspect gamma.go"),
182 }}
183 execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
184 textTurn("root done"),
185 textTurn("child done"),
186 textTurn("root again done"),
187 }}
188 exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
189 coord := agent.NewCoordinator(planner, agent.NewSession("planner sys"), nil, agent.PlannerToolRegistry(tool.NewRegistry()), agent.Options{}, exec, 0, event.Discard, nil)
190 rootPath := filepath.Join(dir, "root.jsonl")
191 c := newOwnedTestController(t, Options{Runner: coord, Executor: exec, SystemPrompt: "exec sys", SessionDir: dir, SessionPath: rootPath, Label: "test"})
192 t.Cleanup(c.Close)
193
194 if err := c.Run(context.Background(), "root task alpha"); err != nil {
195 t.Fatal(err)
196 }
197 rootID := agent.BranchID(c.SessionPath())
198 if _, err := c.Branch("child"); err != nil {
199 t.Fatal(err)
200 }
201 if err := c.Run(context.Background(), "child task beta"); err != nil {
202 t.Fatal(err)
203 }
204 if _, err := c.SwitchBranch(rootID); err != nil {
205 t.Fatal(err)
206 }
207 if err := c.Run(context.Background(), "root task gamma"); err != nil {
208 t.Fatal(err)
209 }
210
211 if len(planner.requests) != 3 {
212 t.Fatalf("planner requests = %d, want 3", len(planner.requests))
213 }
214 third := requestMessagesText(planner.requests[2].Messages)
215 if strings.Contains(third, "child task beta") || strings.Contains(third, "CHILD PLAN") {
216 t.Fatalf("switched planner request leaked previous branch context:\n%s", third)
217 }
218 if !strings.Contains(third, "root task gamma") {
219 t.Fatalf("switched planner request missing current task:\n%s", third)
220 }
221 }
222
223 func TestSubmitBranchHonorsNumericTurnTarget(t *testing.T) {
224 dir := schemaOneTempDir(t)
225 sess := agent.NewSession("sys")
226 sess.Add(provider.Message{Role: provider.RoleUser, Content: "first prompt"})
227 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "first answer"})
228 sess.Add(provider.Message{Role: provider.RoleUser, Content: "second prompt"})
229 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
230 c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test"})
231 c.SetSessionPath(agent.NewSessionPath(dir, "test"))
232 if err := c.Snapshot(); err != nil {
233 t.Fatal(err)
234 }
235 rootPath := c.SessionPath()
236
237 c.checkpoints.mu.Lock()
238 c.checkpoints.bound[1] = 3 // displayed turn 2 starts before "second prompt"
239 c.checkpoints.mu.Unlock()
240
241 c.Submit("/branch 2 experiment")
242 if c.SessionPath() == rootPath {
243 t.Fatal("Submit /branch <turn> should switch to a forked session")
244 }
245 meta, ok, err := agent.LoadBranchMeta(c.SessionPath())
246 if err != nil || !ok {
247 t.Fatalf("load branch meta ok=%v err=%v", ok, err)
248 }
249 if meta.ForkTurn != 1 || meta.ForkMessageIndex != 3 || meta.Name != "experiment" {
250 t.Fatalf("meta = %+v, want turn 1, msg index 3, name experiment", meta)
251 }
252 if got := len(c.History()); got != 3 {
253 t.Fatalf("forked history length = %d, want 3", got)
254 }
255 }
256
257 func TestParseBranchTarget(t *testing.T) {
258 turn, name, fromTurn, err := ParseBranchTarget("3 experiment")
259 if err != nil || !fromTurn || turn != 3 || name != "experiment" {
260 t.Fatalf("ParseBranchTarget numeric = (%d,%q,%v,%v)", turn, name, fromTurn, err)
261 }
262 turn, name, fromTurn, err = ParseBranchTarget("experiment")
263 if err != nil || fromTurn || turn != 0 || name != "experiment" {
264 t.Fatalf("ParseBranchTarget name = (%d,%q,%v,%v)", turn, name, fromTurn, err)
265 }
266 if _, _, _, err = ParseBranchTarget("0 bad"); err == nil {
267 t.Fatal("ParseBranchTarget should reject non-positive turns")
268 }
269 }
270
271 func TestSubmitSwitchEmitsErrorNotice(t *testing.T) {
272 var notices []string
273 sess := agent.NewSession("sys")
274 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
275 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
276 c := newOwnedTestController(t, Options{
277 Executor: exec,
278 Sink: event.FuncSink(func(e event.Event) {
279 if e.Kind == event.Notice {
280 notices = append(notices, e.Text)
281 }
282 }),
283 })
284
285 c.Submit("/switch")
286 if len(notices) == 0 {
287 t.Fatal("/switch with empty ref should emit an error notice")
288 }
289 if !strings.Contains(notices[len(notices)-1], "usage") {
290 t.Fatalf("notice = %q, want usage hint", notices[len(notices)-1])
291 }
292
293 notices = notices[:0]
294 c.Submit("/switch nonexistent")
295 if len(notices) == 0 {
296 t.Fatal("/switch with unknown ref should emit an error notice")
297 }
298 }
299
300 func TestSubmitBranchEmitsErrorNoticeWhileRunning(t *testing.T) {
301 var notices []string
302 sess := agent.NewSession("sys")
303 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
304 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
305 c := newOwnedTestController(t, Options{
306 Executor: exec,
307 SessionDir: t.TempDir(),
308 Label: "test",
309 Sink: event.FuncSink(func(e event.Event) {
310 if e.Kind == event.Notice {
311 notices = append(notices, e.Text)
312 }
313 }),
314 })
315 c.SetSessionPath(agent.NewSessionPath(c.sessionDir, "test"))
316
317 c.mu.Lock()
318 c.turns.phase = session.RuntimeRunning
319 c.mu.Unlock()
320
321 c.Submit("/branch experiment")
322 if len(notices) == 0 {
323 t.Fatal("/branch while running should emit an error notice")
324 }
325 if !strings.Contains(notices[len(notices)-1], "cannot branch") {
326 t.Fatalf("notice = %q, want 'cannot branch' error", notices[len(notices)-1])
327 }
328 }
329
330 func TestFormatBranchTreeMarksCurrent(t *testing.T) {
331 branches := []agent.BranchInfo{
332 {BranchMeta: agent.BranchMeta{ID: "root"}, Preview: "root", Turns: 1},
333 {BranchMeta: agent.BranchMeta{ID: "child", ParentID: "root", Name: "child branch"}, Turns: 2},
334 }
335 got := FormatBranchTree(branches, "child")
336 if !strings.Contains(got, "child branch 2 turns current") {
337 t.Fatalf("tree should mark current branch:\n%s", got)
338 }
339 if strings.Contains(got, "*") {
340 t.Fatalf("tree should not use duplicate current markers:\n%s", got)
341 }
342 }
343
344 func TestFormatBranchTreeUsesCompactVisualRows(t *testing.T) {
345 branches := []agent.BranchInfo{
346 {
347 BranchMeta: agent.BranchMeta{ID: "20260601-033830.928433000-deepseek-v4-flash"},
348 Preview: "你是谁",
349 Turns: 3,
350 },
351 {
352 BranchMeta: agent.BranchMeta{
353 ID: "20260601-033937.165828000-deepseek-v4-flash",
354 ParentID: "20260601-033830.928433000-deepseek-v4-flash",
355 },
356 Preview: `{ "code": 0, "msg": "success", "data": { "rows": [] } }`,
357 Turns: 1,
358 },
359 }
360 got := FormatBranchTree(branches, "20260601-033937.165828000-deepseek-v4-flash")
361 checks := []string{
362 "└─",
363 "0601-033937.165",
364 "JSON response: success",
365 "1 turn",
366 "current",
367 }
368 for _, want := range checks {
369 if !strings.Contains(got, want) {
370 t.Fatalf("tree missing %q:\n%s", want, got)
371 }
372 }
373 if strings.Contains(got, "20260601-033937.165828000-deepseek-v4-flash") {
374 t.Fatalf("tree should use compact branch IDs:\n%s", got)
375 }
376 if strings.Contains(got, `"data"`) {
377 t.Fatalf("tree should summarize JSON-like previews:\n%s", got)
378 }
379 }
380
381 func TestResolveBranchAcceptsDisplayedShortID(t *testing.T) {
382 branches := []agent.BranchInfo{
383 {BranchMeta: agent.BranchMeta{ID: "20260601-033937.165828000-deepseek-v4-flash"}},
384 }
385 got, err := resolveBranch(branches, "0601-033937.165")
386 if err != nil {
387 t.Fatal(err)
388 }
389 if got.ID != branches[0].ID {
390 t.Fatalf("branch = %q, want %q", got.ID, branches[0].ID)
391 }
392 }
393
393 lines GO