返回 DeepSeek-Reasonix
turn_orchestrator_test.go
根目录 / internal / control / turn_orchestrator_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "os"
8 "path/filepath"
9 "strings"
10 "testing"
11 "time"
12
13 "reasonix/internal/agent"
14 "reasonix/internal/event"
15 "reasonix/internal/evidence"
16 "reasonix/internal/hook"
17 "reasonix/internal/provider"
18 "reasonix/internal/skill"
19 "reasonix/internal/tool"
20 )
21
22 type plannerMetadataRunner struct {
23 meta plannerTurnMetadata
24 input string
25 }
26
27 type goalReplacingRunner struct {
28 c *Controller
29 executor *agent.Agent
30 calls int
31 }
32
33 func (r *goalReplacingRunner) Run(context.Context, string) error {
34 r.calls++
35 if r.calls == 1 {
36 r.c.SetGoal("replacement goal")
37 r.executor.ReplaceTodoState(nil)
38 r.executor.Session().Add(provider.Message{
39 Role: provider.RoleAssistant,
40 Content: "Old Goal turn finished.\n\n[goal:complete]",
41 })
42 }
43 return nil
44 }
45
46 func (r *plannerMetadataRunner) Run(ctx context.Context, input string) error {
47 r.meta, _ = plannerTurnMetadataFromContext(ctx)
48 r.input = input
49 return nil
50 }
51
52 func TestTurnOrchestratorAttachesTrustedPlannerMetadata(t *testing.T) {
53 sess := agent.NewSession("sys")
54 sess.Add(provider.Message{Role: provider.RoleUser, Content: "explain the bug"})
55 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "the bug is in parser.go"})
56 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
57 runner := &plannerMetadataRunner{}
58 c := newOwnedTestController(t, Options{
59 Runner: runner,
60 Executor: exec,
61 })
62 c.SetGoal("migrate authentication across the backend")
63
64 const raw = "fix typo in README"
65 const expanded = "Referenced context:\n\nprivate injected details\n\nfix typo in README"
66 if err := newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), expanded, raw, ""); err != nil {
67 t.Fatal(err)
68 }
69
70 if runner.meta.UserText != raw {
71 t.Fatalf("planner metadata user text = %q, want pristine %q", runner.meta.UserText, raw)
72 }
73 if runner.meta.ExplicitPlanMode {
74 t.Fatalf("planner metadata should not force plan mode: %+v", runner.meta)
75 }
76 if !runner.meta.HasConversationContext {
77 t.Fatalf("planner metadata lost executor conversation ownership: %+v", runner.meta)
78 }
79 if !strings.Contains(runner.input, expanded) {
80 t.Fatalf("model input lost expanded context: %q", runner.input)
81 }
82 }
83
84 func TestTurnOrchestratorRunsForegroundUnit(t *testing.T) {
85 runner := &fakeTurnRunner{}
86 c := newOwnedTestController(t, Options{Runner: runner})
87 c.SetPlanMode(true)
88
89 o := newTurnOrchestrator(c)
90 if err := o.runTurnWithRawDisplay(context.Background(), "draft the plan", "draft the plan", ""); err != nil {
91 t.Fatal(err)
92 }
93
94 if len(runner.inputs) != 1 {
95 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
96 }
97 if !strings.HasPrefix(runner.inputs[0], PlanModeMarker) {
98 t.Fatalf("orchestrator should compose plan marker before running, got %q", runner.inputs[0])
99 }
100 }
101
102 func TestNonGoalTurnDoesNotInvokeGoalEvaluator(t *testing.T) {
103 tests := []struct {
104 name string
105 run func(*turnOrchestrator) error
106 }{
107 {
108 name: "ordinary",
109 run: func(o *turnOrchestrator) error {
110 return o.runGoalLoopWithRawDisplay(context.Background(), "answer", "answer", "")
111 },
112 },
113 {
114 name: "edited",
115 run: func(o *turnOrchestrator) error {
116 return o.runEditedGoalLoopWithRawDisplay(context.Background(), "answer", "answer", "", "old answer")
117 },
118 },
119 }
120 for _, tt := range tests {
121 t.Run(tt.name, func(t *testing.T) {
122 runner := &fakeTurnRunner{}
123 evaluator := &fakeGoalEvaluator{}
124 c := newOwnedTestController(t, Options{Runner: runner, GoalEvaluator: evaluator})
125
126 if err := tt.run(newTurnOrchestrator(c)); err != nil {
127 t.Fatal(err)
128 }
129 if len(runner.inputs) != 1 {
130 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
131 }
132 if evaluator.calls != 0 {
133 t.Fatalf("goal evaluator calls = %d, want 0 outside Goal mode", evaluator.calls)
134 }
135 })
136 }
137 }
138
139 func TestTurnOrchestratorTypedSyntheticTurnDoesNotDependOnPrefix(t *testing.T) {
140 runner := &fakeTurnRunner{}
141 c := newOwnedTestController(t, Options{Runner: runner})
142 o := newTurnOrchestrator(c)
143
144 turn := "Controller-created follow-up with a brand-new synthetic wording:\n- inspect\n- edit\n- verify"
145 if IsSyntheticUserMessage(turn) {
146 t.Fatalf("test setup: %q unexpectedly matched the legacy synthetic prefix list", turn)
147 }
148 if err := o.runSyntheticTurnWithRawDisplay(context.Background(), turn, turn, ""); err != nil {
149 t.Fatal(err)
150 }
151
152 if len(runner.inputs) != 1 {
153 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
154 }
155 if strings.HasPrefix(runner.inputs[0], PlanModeMarker) {
156 t.Fatalf("typed synthetic turn should remain a plain turn, got %q", runner.inputs[0])
157 }
158 }
159
160 func TestGoalTurnOutputCannotAdvanceReplacementGoal(t *testing.T) {
161 executor := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
162 runner := &goalReplacingRunner{executor: executor}
163 evaluator := &fakeGoalEvaluator{}
164 c := newOwnedTestController(t, Options{
165 Runner: runner,
166 Executor: executor,
167 GoalEvaluator: evaluator,
168 SessionDir: t.TempDir(),
169 })
170 runner.c = c
171 c.SetGoal("old goal")
172
173 if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(
174 context.Background(),
175 "work on the old goal",
176 "work on the old goal",
177 "",
178 ); err != nil {
179 t.Fatal(err)
180 }
181 if runner.calls != 1 {
182 t.Fatalf("runner calls = %d, want 1 old-Goal turn", runner.calls)
183 }
184 if got := c.Goal(); got != "replacement goal" {
185 t.Fatalf("Goal() = %q, want replacement Goal to remain active", got)
186 }
187 if got := c.GoalStatus(); got != GoalStatusRunning {
188 t.Fatalf("GoalStatus() = %q, want replacement Goal to remain running", got)
189 }
190 if evaluator.calls != 0 {
191 t.Fatalf("stale Goal evaluator calls = %d, want 0", evaluator.calls)
192 }
193 }
194
195 func TestTurnOrchestratorStopHookIgnoresCanceledTurnContext(t *testing.T) {
196 runCtx, cancel := context.WithCancel(context.Background())
197 var stopCalls int
198 var stopErr error
199 hooks := hook.NewRunner([]hook.ResolvedHook{{
200 HookConfig: hook.HookConfig{Command: "record-stop"},
201 Event: hook.Stop,
202 Scope: hook.ScopeProject,
203 }}, "", func(ctx context.Context, in hook.SpawnInput) hook.SpawnResult {
204 stopCalls++
205 stopErr = ctx.Err()
206 return hook.SpawnResult{ExitCode: 0}
207 }, nil)
208 c := newOwnedTestController(t, Options{
209 Runner: cancelingRunner{cancel: cancel},
210 Hooks: hooks,
211 })
212
213 o := newTurnOrchestrator(c)
214 if err := o.runTurnWithRawDisplay(runCtx, "hello", "hello", ""); err != nil {
215 t.Fatal(err)
216 }
217
218 if runCtx.Err() != context.Canceled {
219 t.Fatalf("turn context err = %v, want %v", runCtx.Err(), context.Canceled)
220 }
221 if stopCalls != 1 {
222 t.Fatalf("Stop hook calls = %d, want 1", stopCalls)
223 }
224 if stopErr != nil {
225 t.Fatalf("Stop hook context err = %v, want nil", stopErr)
226 }
227 }
228
229 type recordingSessionRunner struct {
230 session *agent.Session
231 inputs []string
232 raw []string
233 }
234
235 func (r *recordingSessionRunner) Run(ctx context.Context, input string) error {
236 r.inputs = append(r.inputs, input)
237 r.raw = append(r.raw, agent.RawUserInput(ctx, input))
238 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
239 return nil
240 }
241
242 func TestTurnOrchestratorRunsOneGoalTurnPerAdmission(t *testing.T) {
243 prov := &scriptedTurns{turns: flattenTurns(
244 goalToolTurn(GoalStatusRunning, "started", "next"),
245 goalToolTurn(GoalStatusComplete, "", ""),
246 )}
247 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
248 var stopEvents int
249 hooks := hook.NewRunner([]hook.ResolvedHook{{
250 HookConfig: hook.HookConfig{Command: "record-stop"},
251 Event: hook.Stop,
252 Scope: hook.ScopeProject,
253 }}, "", func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
254 var p hook.Payload
255 if err := json.Unmarshal([]byte(in.Stdin), &p); err != nil {
256 t.Fatalf("hook payload: %v", err)
257 }
258 if p.Event == hook.Stop {
259 stopEvents++
260 }
261 return hook.SpawnResult{ExitCode: 0}
262 }, nil)
263 c := newOwnedTestController(t, Options{Runner: ag, Executor: ag, Hooks: hooks})
264 c.SetGoal("ship the refactor")
265
266 o := newTurnOrchestrator(c)
267 if err := o.runGoalLoopWithRawDisplay(context.Background(), "Start pursuing the active goal now.", "ship the refactor", ""); err != nil {
268 t.Fatal(err)
269 }
270
271 if prov.call != 2 {
272 t.Fatalf("provider calls = %d, want one admitted turn (tool call + final answer)", prov.call)
273 }
274 if stopEvents != 1 {
275 t.Fatalf("Stop hook events = %d, want one per admitted turn", stopEvents)
276 }
277 }
278
279 func TestTurnOrchestratorApprovedPlanSharesOneStopHook(t *testing.T) {
280 prov := &scriptedTurns{turns: planThenExecuteTurns(
281 "Plan:\n1. Make the change\n2. Verify it",
282 "Done.",
283 )}
284 ag := newPlanTestAgent(prov)
285 approvalID := make(chan string, 1)
286 var promptSubmitEvents, stopEvents int
287 hooks := hook.NewRunner([]hook.ResolvedHook{
288 {
289 HookConfig: hook.HookConfig{Command: "record-submit"},
290 Event: hook.UserPromptSubmit,
291 Scope: hook.ScopeProject,
292 },
293 {
294 HookConfig: hook.HookConfig{Command: "record-stop"},
295 Event: hook.Stop,
296 Scope: hook.ScopeProject,
297 },
298 }, "", func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
299 var p hook.Payload
300 if err := json.Unmarshal([]byte(in.Stdin), &p); err != nil {
301 t.Fatalf("hook payload: %v", err)
302 }
303 switch p.Event {
304 case hook.UserPromptSubmit:
305 promptSubmitEvents++
306 case hook.Stop:
307 stopEvents++
308 }
309 return hook.SpawnResult{ExitCode: 0}
310 }, nil)
311 c := newOwnedTestController(t, Options{
312 Runner: ag,
313 Executor: ag,
314 Hooks: hooks,
315 Sink: event.FuncSink(func(e event.Event) {
316 if e.Kind == event.ApprovalRequest {
317 approvalID <- e.Approval.ID
318 }
319 }),
320 })
321 c.SetPlanMode(true)
322 go func() { c.Approve(<-approvalID, true, false, false) }()
323
324 o := newTurnOrchestrator(c)
325 if err := o.runTurnWithRawDisplay(context.Background(), "plan this change", "plan this change", ""); err != nil {
326 t.Fatal(err)
327 }
328
329 if prov.call != 3 {
330 t.Fatalf("provider calls = %d, want plan + read + answer", prov.call)
331 }
332 if promptSubmitEvents != 1 {
333 t.Fatalf("UserPromptSubmit events = %d, want one for plan + approved execution unit", promptSubmitEvents)
334 }
335 if stopEvents != 1 {
336 t.Fatalf("Stop hook events = %d, want one for plan + approved execution unit", stopEvents)
337 }
338 }
339
340 func TestTurnOrchestratorRefTurnRecordsVisibleDisplay(t *testing.T) {
341 root := t.TempDir()
342 if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("referenced evidence"), 0o644); err != nil {
343 t.Fatal(err)
344 }
345 sess := agent.NewSession("sys")
346 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
347 runner := &recordingSessionRunner{session: sess}
348 events := make(chan event.Event, 4)
349 c := newOwnedTestController(t, Options{
350 WorkspaceRoot: root,
351 Runner: runner,
352 Executor: exec,
353 Sink: event.FuncSink(func(e event.Event) {
354 events <- e
355 }),
356 })
357 var gotContent, gotDisplay string
358 c.SetDisplayRecorder(func(content, display string) {
359 gotContent = content
360 gotDisplay = display
361 })
362
363 const visible = "explain @notes.txt"
364 c.runRefTurn(visible, visible)
365 waitForTurnDone(t, events)
366
367 if len(runner.inputs) != 1 {
368 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
369 }
370 if !strings.Contains(runner.inputs[0], "Referenced context:") || !strings.Contains(runner.inputs[0], "referenced evidence") {
371 t.Fatalf("model input should include resolved reference context, got %q", runner.inputs[0])
372 }
373 if gotDisplay != visible {
374 t.Fatalf("display recorder display = %q, want visible prompt %q", gotDisplay, visible)
375 }
376 if gotContent != runner.inputs[0] {
377 t.Fatalf("display recorder content = %q, want persisted model input %q", gotContent, runner.inputs[0])
378 }
379 }
380
381 func TestTurnOrchestratorRefTurnPreservesExpandedPasteForRouting(t *testing.T) {
382 const label = "[Pasted text #1 · 2 lines]"
383 const display = "inspect\n\n" + label
384 const expanded = display + "\n\n--- Begin " + label + " ---\nroute-expanded-paste\nfunc main() {}\n--- End " + label + " ---"
385
386 sess := agent.NewSession("sys")
387 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
388 runner := &recordingSessionRunner{session: sess}
389 reg := tool.NewRegistry()
390 reg.Add(capabilityTestTool{name: "run_skill"})
391 c := newOwnedTestController(t, Options{
392 Runner: runner,
393 Executor: exec,
394 Registry: reg,
395 Skills: []skill.Skill{{
396 Name: "paste-review",
397 Description: "review code",
398 Triggers: []string{"route-expanded-paste"},
399 Scope: skill.ScopeBuiltin,
400 }},
401 })
402 resolve := func(context.Context, string) resolvedReferences {
403 return resolvedReferences{block: "<file path=\"notes.txt\">\nreference\n</file>"}
404 }
405
406 if err := c.runRefTurnWithResolverSync(context.Background(), expanded, expanded, display, "", resolve); err != nil {
407 t.Fatal(err)
408 }
409 if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "Referenced context:") || !strings.Contains(runner.inputs[0], expanded) {
410 t.Fatalf("provider input = %+v, want resolved context and expanded paste", runner.inputs)
411 }
412 if !strings.Contains(runner.inputs[0], "skill:paste-review prefer") {
413 t.Fatalf("expanded pasted text did not drive capability routing:\n%s", runner.inputs[0])
414 }
415 if len(runner.raw) != 1 || runner.raw[0] != expanded {
416 t.Fatalf("persisted raw input = %+v, want complete user input %q", runner.raw, expanded)
417 }
418 }
419
420 func TestTurnOrchestratorAutoReasoningLanguageUsesRawPromptForRefTurns(t *testing.T) {
421 root := t.TempDir()
422 if err := os.WriteFile(filepath.Join(root, "auth.go"), []byte("package main\nfunc AuthHandler() error { return errors.New(\"not authorized\") }\n"), 0o644); err != nil {
423 t.Fatal(err)
424 }
425 runner := &fakeTurnRunner{}
426 events := make(chan event.Event, 4)
427 c := newOwnedTestController(t, Options{
428 WorkspaceRoot: root,
429 Runner: runner,
430 Sink: event.FuncSink(func(e event.Event) {
431 events <- e
432 }),
433 })
434
435 const visible = "解释 @auth.go 的报错"
436 c.runRefTurn(visible, visible)
437 waitForTurnDone(t, events)
438
439 if len(runner.inputs) != 1 {
440 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
441 }
442 got := runner.inputs[0]
443 if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") {
444 t.Fatalf("auto reasoning language should anchor Chinese before referenced context, got %q", got)
445 }
446 if !strings.Contains(got, "Referenced context:") || !strings.Contains(got, "AuthHandler") {
447 t.Fatalf("ref context missing from model input: %q", got)
448 }
449 if strings.Contains(got, "use English") {
450 t.Fatalf("English referenced file content should not win over raw Chinese prompt:\n%s", got)
451 }
452 }
453
454 // TestTurnOrchestratorCheckpointPromptIsRawUserInput verifies the rewind picker
455 // label records the user's own text, not the composed provider input. compose()
456 // prefixes the turn with transient blocks (<response-language>,
457 // <reasoning-language>, plan marker, memory, hook context, …); storing that
458 // string as checkpoint.Prompt made the Esc-Esc picker show a wall of prefab
459 // prompt text instead of the user's messages.
460 func TestTurnOrchestratorCheckpointPromptIsRawUserInput(t *testing.T) {
461 dir := t.TempDir()
462 path := filepath.Join(dir, "session.jsonl")
463 sess := agent.NewSession("sys")
464 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
465 runner := &recordingSessionRunner{session: sess}
466 c := newOwnedTestController(t, Options{
467 Runner: runner,
468 Executor: exec,
469 SessionDir: dir,
470 SessionPath: path,
471 Label: "test",
472 ResponseLanguage: "zh",
473 ReasoningLanguage: "en",
474 })
475 o := newTurnOrchestrator(c)
476 const raw = "fix the parser"
477 if err := o.runTurnWithRawDisplay(context.Background(), raw, raw, ""); err != nil {
478 t.Fatal(err)
479 }
480 cps := c.Checkpoints()
481 if len(cps) != 1 {
482 t.Fatalf("checkpoints = %+v, want exactly one", cps)
483 }
484 if got := cps[0].Prompt; got != raw {
485 t.Fatalf("checkpoint prompt = %q, want raw user input %q (composed text leaked into the rewind picker)", got, raw)
486 }
487 for _, prefab := range []string{"<response-language>", "<reasoning-language>"} {
488 if strings.Contains(cps[0].Prompt, prefab) {
489 t.Fatalf("checkpoint prompt contains %q: %q", prefab, cps[0].Prompt)
490 }
491 }
492 }
493
494 func TestTurnOrchestratorSyntheticTurnDoesNotCreateCheckpoint(t *testing.T) {
495 dir := t.TempDir()
496 path := filepath.Join(dir, "session.jsonl")
497 sess := agent.NewSession("sys")
498 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
499 runner := &recordingSessionRunner{session: sess}
500 c := newOwnedTestController(t, Options{
501 Runner: runner,
502 Executor: exec,
503 SessionDir: dir,
504 SessionPath: path,
505 Label: "test",
506 })
507 o := newTurnOrchestrator(c)
508 if err := o.runTurnWithRawDisplay(context.Background(), "real prompt", "real prompt", ""); err != nil {
509 t.Fatal(err)
510 }
511 if err := o.runSyntheticTurnWithRawDisplay(context.Background(), "hidden follow-up", "hidden follow-up", ""); err != nil {
512 t.Fatal(err)
513 }
514
515 cps := c.Checkpoints()
516 if len(cps) != 1 {
517 t.Fatalf("checkpoints = %+v, want exactly the visible user turn", cps)
518 }
519 if cps[0].Turn != 0 || cps[0].Prompt != "real prompt" {
520 t.Fatalf("checkpoint = %+v, want turn 0 real prompt", cps[0])
521 }
522 turns := c.CheckpointTurnsByMessageIndex()
523 if len(turns) != 1 || turns[1] != 0 {
524 t.Fatalf("checkpoint turns by message index = %v, want {1:0}", turns)
525 }
526 }
527
528 func TestTurnOrchestratorStopFailureHookCancelledContext(t *testing.T) {
529 prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("done")}}
530 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
531 var stopCalls int
532 hooks := hook.NewRunner([]hook.ResolvedHook{{
533 HookConfig: hook.HookConfig{Command: "stop"},
534 Event: hook.StopFailure,
535 Scope: hook.ScopeProject,
536 }}, "", func(ctx context.Context, in hook.SpawnInput) hook.SpawnResult {
537 if ctx.Err() != nil {
538 t.Errorf("Stop hook spawner ctx.Err()=%v; want nil", ctx.Err())
539 }
540 var p hook.Payload
541 json.Unmarshal([]byte(in.Stdin), &p)
542 if p.Event == hook.StopFailure {
543 if p.Error == "" || !p.IsInterrupt {
544 t.Errorf("failure payload = %+v", p)
545 }
546 stopCalls++
547 }
548 return hook.SpawnResult{ExitCode: 0}
549 }, nil)
550 c := newOwnedTestController(t, Options{Runner: ag, Executor: ag, Hooks: hooks})
551 ctx, cancel := context.WithCancel(context.Background())
552 cancel()
553 o := newTurnOrchestrator(c)
554 if err := o.runTurnWithRawDisplay(ctx, "test", "test", ""); err != nil && !errors.Is(err, context.Canceled) {
555 t.Fatal(err)
556 }
557 if stopCalls != 1 {
558 t.Fatalf("StopFailure hooks called = %d; want 1", stopCalls)
559 }
560 }
561
562 // TestTurnOrchestratorCancelPreservesVisibleUserPrompt verifies that when the
563 // user explicitly cancels a visible turn (Ctrl+C), the real user prompt and
564 // fully paired tool work remain in the session while unsafe fragments become
565 // provider-excluded display history.
566 func TestTurnOrchestratorCancelPreservesVisibleUserPrompt(t *testing.T) {
567 sess := agent.NewSession("you are a helpful agent")
568 // Pre-populate with a few messages from an earlier turn.
569 sess.Add(provider.Message{Role: provider.RoleUser, Content: "previous work"})
570 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
571 preCount := len(sess.Messages)
572
573 // runner that simulates a cancelled turn: it adds the user message plus
574 // some tool-call garbage the real agent would leave behind, then returns
575 // context.Canceled.
576 runner := &cancelStrippingRunner{
577 session: sess,
578 add: []provider.Message{
579 {Role: provider.RoleAssistant, Content: "let me do that", ToolCalls: []provider.ToolCall{
580 {ID: "c1", Name: "todo_write", Arguments: `{"todos":[{"content":"add abc","status":"in_progress"}]}`},
581 }},
582 {Role: provider.RoleTool, Content: "Todos updated: 1 total — 0 completed, 1 in_progress, 0 pending.", ToolCallID: "c1", Name: "todo_write"},
583 },
584 err: context.Canceled,
585 }
586
587 ex := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
588 c := newOwnedTestController(t, Options{Runner: runner, Executor: ex})
589 c.SetPlanMode(true)
590 // Simulate a user-initiated cancel: set the cancelling flag.
591 c.mu.Lock()
592 c.turns.cancelRequested = true
593 c.mu.Unlock()
594
595 // Pre-seed only the executor's legacy mutable copy. Without a committed
596 // semantic ToolResult event it must not become the host todo projection.
597 ex.ReplaceTodoState([]evidence.TodoItem{{Content: "add abc", Status: "in_progress"}})
598
599 o := newTurnOrchestrator(c)
600 err := o.runTurnWithRawDisplay(context.Background(), "add config file abc", "add config file abc", "")
601 if !errors.Is(err, context.Canceled) {
602 t.Fatalf("expected context.Canceled, got %v", err)
603 }
604
605 // The visible user prompt and completed tool pair stay, followed by a durable
606 // provider-excluded recovery record.
607 msgs := sess.Messages
608 if len(msgs) != preCount+4 {
609 t.Fatalf("session messages after cancel = %d, want user + tool pair + recovery %d: %+v", len(msgs), preCount+4, msgs)
610 }
611 user := msgs[preCount]
612 if user.Role != provider.RoleUser || user.Content != "add config file abc" {
613 t.Fatalf("cancelled user message = %+v, want prefix-free prompt", user)
614 }
615 if msgs[preCount+1].Role != provider.RoleAssistant || msgs[preCount+2].Role != provider.RoleTool {
616 t.Fatalf("completed tool pair was not retained: %+v", msgs[preCount+1:])
617 }
618 last := msgs[len(msgs)-1]
619 if !last.LocalOnly || last.InterruptedTurn == nil || !last.InterruptedTurn.Pending || len(last.InterruptedTurn.CompletedTools) != 1 {
620 t.Fatalf("pending recovery metadata missing: %+v", last)
621 }
622
623 // Transcript prose and the executor copy are archival/convenience data. The
624 // host projection changes only from a committed semantic ToolResult event.
625 if todos := c.Todos(); len(todos) != 0 {
626 t.Fatalf("Todos() after cancel = %v, want no uncommitted todo projection", todos)
627 }
628 }
629
630 func TestTurnOrchestratorProviderErrorPreservesCompletedPairAndLocalPartial(t *testing.T) {
631 sess := agent.NewSession("system")
632 apiErr := errors.New("provider connection reset")
633 runner := &cancelStrippingRunner{
634 session: sess,
635 add: []provider.Message{
636 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`, Added: 1}}},
637 {Role: provider.RoleTool, ToolCallID: "c1", Name: "write_file", Content: "wrote a.txt"},
638 {
639 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
640 LocalOnly: true, Content: "partial final answer", ReasoningContent: "partial reasoning",
641 InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true, DroppedPartialText: true, DroppedPartialReasoning: true},
642 },
643 },
644 err: apiErr,
645 }
646 ex := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
647 c := newOwnedTestController(t, Options{Runner: runner, Executor: ex})
648
649 err := newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), "update a.txt", "update a.txt", "")
650 if !errors.Is(err, apiErr) {
651 t.Fatalf("run error = %v, want %v", err, apiErr)
652 }
653 msgs := sess.Snapshot()
654 if len(msgs) != 5 || msgs[2].Role != provider.RoleAssistant || msgs[3].Role != provider.RoleTool || !msgs[4].LocalOnly {
655 t.Fatalf("provider-error recovery transcript = %+v", msgs)
656 }
657 recovery := msgs[4].InterruptedTurn
658 if recovery == nil || !recovery.Pending || len(recovery.CompletedTools) != 1 || len(recovery.CompletedTools[0].Files) != 1 || recovery.CompletedTools[0].Files[0] != "a.txt" {
659 t.Fatalf("provider-error recovery metadata = %+v", recovery)
660 }
661 if msgs[4].Content != "partial final answer" || msgs[4].ReasoningContent != "partial reasoning" {
662 t.Fatalf("provider-error display output was not retained: %+v", msgs[4])
663 }
664 }
665
666 func TestTurnOrchestratorInterruptedAfterCompactionRelocatesVisibleTurn(t *testing.T) {
667 for _, tc := range []struct {
668 name string
669 err error
670 cancel bool
671 }{
672 {name: "cancel", err: context.Canceled, cancel: true},
673 {name: "provider error", err: errors.New("provider connection reset")},
674 } {
675 t.Run(tc.name, func(t *testing.T) {
676 sess := agent.NewSession("system")
677 for range 3 {
678 sess.Add(provider.Message{Role: provider.RoleUser, Content: "old task"})
679 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "old answer"})
680 }
681 start := sess.Len()
682 runner := &compactingErrorRunner{session: sess, err: tc.err}
683 c := newOwnedTestController(t, Options{Runner: runner, Executor: agent.New(nil, nil, sess, agent.Options{}, event.Discard)})
684 if tc.cancel {
685 c.mu.Lock()
686 c.turns.cancelRequested = true
687 c.mu.Unlock()
688 }
689
690 err := newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), "update a.txt", "update a.txt", "")
691 if !errors.Is(err, tc.err) {
692 t.Fatalf("run error = %v, want %v", err, tc.err)
693 }
694 msgs := sess.Snapshot()
695 if start <= len(msgs) {
696 t.Fatalf("test setup did not shrink transcript below stale boundary: start=%d len=%d", start, len(msgs))
697 }
698 userCount := 0
699 for _, m := range msgs {
700 if m.Role == provider.RoleUser && StripComposePrefixes(m.Content) == "update a.txt" {
701 userCount++
702 }
703 }
704 if userCount != 1 {
705 t.Fatalf("current user occurrences = %d, want 1: %+v", userCount, msgs)
706 }
707 if len(msgs) != 6 || !agent.IsCompactionSummary(msgs[1]) || msgs[3].Role != provider.RoleAssistant || msgs[4].Role != provider.RoleTool || !msgs[5].LocalOnly {
708 t.Fatalf("recovered compacted transcript = %+v", msgs)
709 }
710 recovery := msgs[5].InterruptedTurn
711 if recovery == nil || !recovery.Pending || len(recovery.CompletedTools) != 1 || recovery.CompletedTools[0].Name != "write_file" {
712 t.Fatalf("recovery metadata = %+v", recovery)
713 }
714 })
715 }
716 }
717
718 func TestTurnOrchestratorCancelClassifiesCancelledToolResultAsInterrupted(t *testing.T) {
719 sess := agent.NewSession("system")
720 runner := &cancelStrippingRunner{
721 session: sess,
722 add: []provider.Message{
723 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: `{"command":"go test ./..."}`}}},
724 {Role: provider.RoleTool, ToolCallID: "c1", Name: "bash", Content: "error: context canceled"},
725 },
726 err: context.Canceled,
727 }
728 c := newOwnedTestController(t, Options{Runner: runner, Executor: agent.New(nil, nil, sess, agent.Options{}, event.Discard)})
729 c.mu.Lock()
730 c.turns.cancelRequested = true
731 c.mu.Unlock()
732
733 err := newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), "run tests", "run tests", "")
734 if !errors.Is(err, context.Canceled) {
735 t.Fatalf("run error = %v, want cancellation", err)
736 }
737 msgs := sess.Snapshot()
738 recovery := msgs[len(msgs)-1].InterruptedTurn
739 if recovery == nil || len(recovery.CompletedTools) != 0 || len(recovery.InterruptedTools) != 1 || recovery.InterruptedTools[0] != "bash" {
740 t.Fatalf("cancelled tool result was misclassified: %+v", recovery)
741 }
742 if msgs[len(msgs)-3].Role != provider.RoleAssistant || msgs[len(msgs)-2].Role != provider.RoleTool {
743 t.Fatalf("paired cancelled call/result should remain canonical: %+v", msgs)
744 }
745 }
746
747 func TestTurnOrchestratorCancelBeforeRunnerAddsUserPreservesVisiblePrompt(t *testing.T) {
748 workspace := t.TempDir()
749 writeVisionTestConfig(t, workspace)
750 imagePath := filepath.Join(workspace, "diagram.png")
751 if err := os.WriteFile(imagePath, mustBase64(t, tinyPNG), 0o644); err != nil {
752 t.Fatal(err)
753 }
754 sess := agent.NewSession("system")
755 ex := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
756 c := newOwnedTestController(t, Options{
757 Runner: cancelBeforeUserRunner{},
758 Executor: ex,
759 WorkspaceRoot: workspace,
760 ModelRef: "custom/vision-pro",
761 })
762 c.SetPlanMode(true)
763 c.mu.Lock()
764 c.turns.cancelRequested = true
765 c.mu.Unlock()
766
767 err := newTurnOrchestrator(c).runTurnWithImageRefsRawDisplay(context.Background(), "Referenced context:\n\n<image path=\"diagram.png\">\n@diagram.png\n</image>\n\ninspect the diagnostic", "inspect the diagnostic", "@diagram.png", "")
768 if !errors.Is(err, context.Canceled) {
769 t.Fatalf("expected context.Canceled, got %v", err)
770 }
771 msgs := sess.Snapshot()
772 if len(msgs) != 3 || msgs[1].Role != provider.RoleUser || !strings.Contains(msgs[1].Content, "inspect the diagnostic") || !msgs[2].LocalOnly {
773 t.Fatalf("session after pre-executor cancel = %+v, want user plus recovery marker", msgs)
774 }
775 if len(msgs[1].Images) != 1 || !strings.HasPrefix(msgs[1].Images[0], "data:image/png;base64,") {
776 t.Fatalf("session after pre-executor cancel lost user image: %+v", msgs[1].Images)
777 }
778 }
779
780 // TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk verifies that after a
781 // user-cancel strip the cleaned transcript is written to disk, so a restart or
782 // session resume does not reload the partial turn from a stale mid-turn
783 // autosave. See #5286.
784 func TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk(t *testing.T) {
785 sess := agent.NewSession("system")
786 sess.Add(provider.Message{Role: provider.RoleUser, Content: "earlier turn"})
787 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
788 // Count only non-system messages; the system prompt is not written to the
789 // .jsonl by Session.Save (it is reconstructed from the session options).
790 wantNonSystem := 0
791 for _, m := range sess.Messages {
792 if m.Role != provider.RoleSystem {
793 wantNonSystem++
794 }
795 }
796 wantNonSystem += 4 // visible user + complete assistant/tool pair + recovery
797
798 runner := &cancelStrippingRunner{
799 session: sess,
800 add: []provider.Message{
801 {Role: provider.RoleAssistant, Content: "working…", ToolCalls: []provider.ToolCall{
802 {ID: "d1", Name: "todo_write", Arguments: `{"todos":[{"content":"task","status":"in_progress"}]}`},
803 }},
804 {Role: provider.RoleTool, Content: "Todos updated.", ToolCallID: "d1", Name: "todo_write"},
805 },
806 err: context.Canceled,
807 }
808
809 sessionPath := agent.NewSessionPath(t.TempDir(), "test-model")
810 c := newOwnedTestController(t, Options{
811 Runner: runner,
812 Executor: agent.New(nil, nil, sess, agent.Options{}, event.Discard),
813 SessionPath: sessionPath,
814 })
815 c.SetPlanMode(true)
816 c.mu.Lock()
817 c.turns.cancelRequested = true
818 c.mu.Unlock()
819
820 o := newTurnOrchestrator(c)
821 if err := o.runTurnWithRawDisplay(context.Background(), "do something", "do something", ""); !errors.Is(err, context.Canceled) {
822 t.Fatalf("expected context.Canceled, got %v", err)
823 }
824
825 // Load the session file written after cleanup and verify the complete pair and
826 // provider-excluded recovery marker survive restart.
827 loaded, err := agent.LoadSession(sessionPath)
828 if err != nil {
829 t.Fatalf("LoadSession: %v", err)
830 }
831 nonSystem := 0
832 var last provider.Message
833 for _, m := range loaded.Messages {
834 if m.Role != provider.RoleSystem {
835 nonSystem++
836 last = m
837 }
838 }
839 if nonSystem != wantNonSystem {
840 t.Fatalf("on-disk message count (non-system) = %d, want %d — stale partial turn still on disk", nonSystem, wantNonSystem)
841 }
842 if !last.LocalOnly || last.InterruptedTurn == nil || !last.InterruptedTurn.Pending {
843 t.Fatalf("last on-disk message = %+v, want pending local recovery", last)
844 }
845 }
846
847 func TestResumeRecoversStaleVisibleInFlightTurn(t *testing.T) {
848 dir := t.TempDir()
849 path := filepath.Join(dir, "stale-visible.jsonl")
850 sess := agent.NewSession("system")
851 sess.Add(provider.Message{Role: provider.RoleUser, Content: "previous work"})
852 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
853 start := len(sess.Messages)
854 sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue work"})
855 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "working", ToolCalls: []provider.ToolCall{
856 {ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"continue work","status":"in_progress"}]}`},
857 }})
858 sess.Add(provider.Message{Role: provider.RoleTool, Content: "Todos updated.", ToolCallID: "todo-1", Name: "todo_write"})
859 if err := sess.Save(path); err != nil {
860 t.Fatal(err)
861 }
862 if err := agent.MarkSessionInFlightTurn(path, start, true); err != nil {
863 t.Fatal(err)
864 }
865
866 loaded, err := agent.LoadSession(path)
867 if err != nil {
868 t.Fatal(err)
869 }
870 exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
871 c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path})
872 c.Resume(loaded, path)
873
874 msgs := exec.Session().Snapshot()
875 if len(msgs) != start+4 {
876 t.Fatalf("resumed messages = %d, want user + completed pair + recovery %d: %+v", len(msgs), start+4, msgs)
877 }
878 last := msgs[len(msgs)-1]
879 if !last.LocalOnly || last.InterruptedTurn == nil || !last.InterruptedTurn.Pending {
880 t.Fatalf("last resumed message = %+v, want provider-excluded recovery", last)
881 }
882 if todos := c.Todos(); len(todos) != 0 {
883 t.Fatalf("Todos() after legacy stale in-flight recovery = %+v, want archival todo inactive", todos)
884 }
885 reloaded, err := agent.LoadSession(path)
886 if err != nil {
887 t.Fatal(err)
888 }
889 if len(reloaded.Messages) != start+4 {
890 t.Fatalf("persisted messages = %d, want recovered count %d: %+v", len(reloaded.Messages), start+4, reloaded.Messages)
891 }
892 meta, ok, err := agent.LoadBranchMeta(path)
893 if err != nil || !ok {
894 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
895 }
896 if meta.InFlightTurn != nil {
897 t.Fatalf("stale in-flight marker survived resume: %+v", meta.InFlightTurn)
898 }
899 }
900
901 func TestResumeClearsStaleSyntheticInFlightTurn(t *testing.T) {
902 dir := t.TempDir()
903 path := filepath.Join(dir, "stale-synthetic.jsonl")
904 sess := agent.NewSession("system")
905 sess.Add(provider.Message{Role: provider.RoleUser, Content: "ship it"})
906 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "Started.\n\n[goal:continue]"})
907 start := len(sess.Messages)
908 // Historical synthetic continuation prompt: retained only as imported test
909 // data so resume can remove an abandoned pre-driver turn.
910 sess.Add(provider.Message{Role: provider.RoleUser, Content: "Continue pursuing the active goal. Do the next useful work and report your judgment with update_goal: continue (give the next concrete step), complete (you judge the goal finished), or blocked (explain why you cannot continue). Keep execution results and any verification limitations accurate."})
911 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "hidden continuation partial"})
912 if err := sess.Save(path); err != nil {
913 t.Fatal(err)
914 }
915 if err := agent.MarkSessionInFlightTurn(path, start, false); err != nil {
916 t.Fatal(err)
917 }
918
919 loaded, err := agent.LoadSession(path)
920 if err != nil {
921 t.Fatal(err)
922 }
923 exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
924 c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path})
925 c.Resume(loaded, path)
926
927 msgs := exec.Session().Snapshot()
928 if len(msgs) != start {
929 t.Fatalf("resumed messages = %d, want synthetic turn stripped to %d: %+v", len(msgs), start, msgs)
930 }
931 if last := msgs[len(msgs)-1]; last.Role != provider.RoleAssistant || !strings.Contains(last.Content, "[goal:continue]") {
932 t.Fatalf("last resumed message = %+v, want completed visible turn preserved", last)
933 }
934 meta, ok, err := agent.LoadBranchMeta(path)
935 if err != nil || !ok {
936 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
937 }
938 if meta.InFlightTurn != nil {
939 t.Fatalf("stale in-flight marker survived resume: %+v", meta.InFlightTurn)
940 }
941 }
942
943 // cancelStrippingRunner adds messages to a session then returns a fixed error,
944 // simulating an agent that was interrupted mid-turn.
945 type cancelStrippingRunner struct {
946 session *agent.Session
947 add []provider.Message
948 err error
949 }
950
951 type compactingErrorRunner struct {
952 session *agent.Session
953 err error
954 }
955
956 type cancelBeforeUserRunner struct{}
957
958 func (cancelBeforeUserRunner) Run(context.Context, string) error {
959 return context.Canceled
960 }
961
962 func (r *cancelStrippingRunner) Run(ctx context.Context, input string) error {
963 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
964 for _, m := range r.add {
965 r.session.Add(m)
966 }
967 return r.err
968 }
969
970 func (r *compactingErrorRunner) Run(_ context.Context, input string) error {
971 r.session.Replace([]provider.Message{
972 {Role: provider.RoleSystem, Content: "system"},
973 {Role: provider.RoleUser, Content: "<compaction-summary>\nold work\n</compaction-summary>"},
974 {Role: provider.RoleUser, Content: input, CreatedAt: time.Now().UnixMilli()},
975 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "write-1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`}}},
976 {Role: provider.RoleTool, ToolCallID: "write-1", Name: "write_file", Content: "wrote a.txt"},
977 {
978 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
979 LocalOnly: true, Content: "partial final answer", ReasoningContent: "private partial reasoning",
980 InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true},
981 },
982 })
983 return r.err
984 }
985
985 lines GO