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