返回 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/capability"
15 "reasonix/internal/event"
16 "reasonix/internal/evidence"
17 "reasonix/internal/hook"
18 "reasonix/internal/provider"
19 "reasonix/internal/skill"
20 "reasonix/internal/tool"
21 )
22
23 type plannerMetadataRunner struct {
24 meta plannerTurnMetadata
25 input string
26 }
27
28 type goalReplacingRunner struct {
29 c *Controller
30 executor *agent.Agent
31 calls int
32 }
33
34 func (r *goalReplacingRunner) Run(context.Context, string) error {
35 r.calls++
36 if r.calls == 1 {
37 r.c.SetGoal("replacement goal")
38 r.executor.ReplaceTodoState(nil)
39 r.executor.Session().Add(provider.Message{
40 Role: provider.RoleAssistant,
41 Content: "Old Goal turn finished.\n\n[goal:complete]",
42 })
43 }
44 return nil
45 }
46
47 func (r *plannerMetadataRunner) Run(ctx context.Context, input string) error {
48 r.meta, _ = plannerTurnMetadataFromContext(ctx)
49 r.input = input
50 return nil
51 }
52
53 func TestTurnOrchestratorAttachesTrustedPlannerMetadata(t *testing.T) {
54 sess := agent.NewSession("sys")
55 sess.Add(provider.Message{Role: provider.RoleUser, Content: "explain the bug"})
56 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "the bug is in parser.go"})
57 exec := agent.New(nil, tool.NewRegistry(), sess, agent.Options{}, event.Discard)
58 runner := &plannerMetadataRunner{}
59 c := New(Options{
60 Runner: runner,
61 Executor: exec,
62 RuntimeProfile: capability.ProfileDelivery,
63 })
64 c.SetGoal("migrate authentication across the backend")
65
66 const raw = "fix typo in README"
67 const expanded = "Referenced context:\n\nprivate injected details\n\nfix typo in README"
68 if err := newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), expanded, raw, ""); err != nil {
69 t.Fatal(err)
70 }
71
72 if runner.meta.UserText != raw {
73 t.Fatalf("planner metadata user text = %q, want pristine %q", runner.meta.UserText, raw)
74 }
75 if runner.meta.ExplicitPlanMode || !runner.meta.GoalActive || !runner.meta.DeliveryProfile {
76 t.Fatalf("planner metadata missing trusted host state: %+v", runner.meta)
77 }
78 if !runner.meta.HasConversationContext {
79 t.Fatalf("planner metadata lost executor conversation ownership: %+v", runner.meta)
80 }
81 if !strings.Contains(runner.input, expanded) {
82 t.Fatalf("model input lost expanded context: %q", runner.input)
83 }
84 }
85
86 func TestTurnOrchestratorRunsForegroundUnit(t *testing.T) {
87 runner := &fakeTurnRunner{}
88 c := New(Options{Runner: runner})
89 c.SetPlanMode(true)
90
91 o := newTurnOrchestrator(c)
92 if err := o.runTurnWithRawDisplay(context.Background(), "draft the plan", "draft the plan", ""); err != nil {
93 t.Fatal(err)
94 }
95
96 if len(runner.inputs) != 1 {
97 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
98 }
99 if !strings.HasPrefix(runner.inputs[0], PlanModeMarker) {
100 t.Fatalf("orchestrator should compose plan marker before running, got %q", runner.inputs[0])
101 }
102 }
103
104 func TestNonGoalTurnDoesNotInvokeGoalEvaluator(t *testing.T) {
105 tests := []struct {
106 name string
107 run func(*turnOrchestrator) error
108 }{
109 {
110 name: "ordinary",
111 run: func(o *turnOrchestrator) error {
112 return o.runGoalLoopWithRawDisplay(context.Background(), "answer", "answer", "")
113 },
114 },
115 {
116 name: "edited",
117 run: func(o *turnOrchestrator) error {
118 return o.runEditedGoalLoopWithRawDisplay(context.Background(), "answer", "answer", "", "old answer")
119 },
120 },
121 }
122 for _, tt := range tests {
123 t.Run(tt.name, func(t *testing.T) {
124 runner := &fakeTurnRunner{}
125 evaluator := &fakeGoalEvaluator{}
126 c := New(Options{Runner: runner, GoalEvaluator: evaluator})
127
128 if err := tt.run(newTurnOrchestrator(c)); err != nil {
129 t.Fatal(err)
130 }
131 if len(runner.inputs) != 1 {
132 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
133 }
134 if evaluator.calls != 0 {
135 t.Fatalf("goal evaluator calls = %d, want 0 outside Goal mode", evaluator.calls)
136 }
137 })
138 }
139 }
140
141 func TestTurnOrchestratorTypedSyntheticTurnDoesNotDependOnPrefix(t *testing.T) {
142 runner := &fakeTurnRunner{}
143 c := New(Options{Runner: runner})
144 o := newTurnOrchestrator(c)
145
146 turn := "Controller-created follow-up with a brand-new synthetic wording:\n- inspect\n- edit\n- verify"
147 if IsSyntheticUserMessage(turn) {
148 t.Fatalf("test setup: %q unexpectedly matched the legacy synthetic prefix list", turn)
149 }
150 if err := o.runSyntheticTurnWithRawDisplay(context.Background(), turn, turn, ""); err != nil {
151 t.Fatal(err)
152 }
153
154 if len(runner.inputs) != 1 {
155 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
156 }
157 if strings.HasPrefix(runner.inputs[0], PlanModeMarker) {
158 t.Fatalf("typed synthetic turn should remain a plain turn, got %q", runner.inputs[0])
159 }
160 }
161
162 func TestGoalTurnOutputCannotAdvanceReplacementGoal(t *testing.T) {
163 executor := agent.New(nil, tool.NewRegistry(), agent.NewSession("system"), agent.Options{}, event.Discard)
164 runner := &goalReplacingRunner{executor: executor}
165 evaluator := &fakeGoalEvaluator{}
166 c := New(Options{
167 Runner: runner,
168 Executor: executor,
169 GoalEvaluator: evaluator,
170 SessionDir: t.TempDir(),
171 })
172 runner.c = c
173 c.SetGoal("old goal")
174
175 if err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(
176 context.Background(),
177 "work on the old goal",
178 "work on the old goal",
179 "",
180 ); err != nil {
181 t.Fatal(err)
182 }
183 if runner.calls != 1 {
184 t.Fatalf("runner calls = %d, want 1 old-Goal turn", runner.calls)
185 }
186 if got := c.Goal(); got != "replacement goal" {
187 t.Fatalf("Goal() = %q, want replacement Goal to remain active", got)
188 }
189 if got := c.GoalStatus(); got != GoalStatusRunning {
190 t.Fatalf("GoalStatus() = %q, want replacement Goal to remain running", got)
191 }
192 if evaluator.calls != 0 {
193 t.Fatalf("stale Goal evaluator calls = %d, want 0", evaluator.calls)
194 }
195 }
196
197 func TestGoalContinuationNoticeCannotMoveOldInterceptIntoReplacementGoal(t *testing.T) {
198 runner := &fakeTurnRunner{}
199 session := agent.NewSession("")
200 session.Add(provider.Message{
201 Role: provider.RoleAssistant,
202 Content: "All done.",
203 })
204 executor := agent.New(nil, tool.NewRegistry(), session, agent.Options{}, event.Discard)
205 executor.SeedTodoState([]evidence.TodoItem{{
206 Content: "unfinished work from old goal",
207 Status: "in_progress",
208 }})
209
210 var c *Controller
211 replaced := false
212 c = New(Options{
213 Runner: runner,
214 Executor: executor,
215 Sink: event.FuncSink(func(e event.Event) {
216 if replaced ||
217 e.Kind != event.Notice ||
218 !strings.Contains(e.Text, "Goal is not ready to complete yet") {
219 return
220 }
221 replaced = true
222 c.SetGoal("replacement goal")
223 }),
224 })
225 c.SetGoal("old goal")
226 scopeID, _, _ := c.goals.deliveryScope()
227 rec := c.goals.newTurnRecorder(scopeID, c.goals.continuationToken())
228 if _, err := rec.RecordGoalReport(tool.GoalReport{Status: GoalStatusComplete, Reason: ""}); err != nil {
229 t.Fatal(err)
230 }
231 c.goalUsageTee.setActiveRecorder(rec)
232
233 if err := newTurnOrchestrator(c).continueGoal(
234 context.Background(),
235 c.goals.continuationToken(),
236 nil,
237 ); err != nil {
238 t.Fatal(err)
239 }
240 if !replaced {
241 t.Fatal("test setup: Notice callback did not replace the active Goal")
242 }
243 if got := c.Goal(); got != "replacement goal" {
244 t.Fatalf("Goal() = %q, want replacement goal", got)
245 }
246 if len(runner.inputs) != 0 {
247 t.Fatalf("stale continuation reached runner with input %q", runner.inputs[0])
248 }
249 }
250
251 func TestGoalContinuationOutputCannotAdvanceReplacementGoal(t *testing.T) {
252 session := agent.NewSession("system")
253 session.Add(provider.Message{
254 Role: provider.RoleAssistant,
255 Content: "All done.",
256 })
257 executor := agent.New(nil, tool.NewRegistry(), session, agent.Options{}, event.Discard)
258 executor.SeedTodoState([]evidence.TodoItem{{
259 Content: "unfinished work from old goal",
260 Status: "in_progress",
261 }})
262 runner := &goalReplacingRunner{executor: executor}
263 c := New(Options{
264 Runner: runner,
265 Executor: executor,
266 SessionDir: t.TempDir(),
267 })
268 runner.c = c
269 c.SetGoal("old goal")
270 scopeID, _, _ := c.goals.deliveryScope()
271 rec := c.goals.newTurnRecorder(scopeID, c.goals.continuationToken())
272 if _, err := rec.RecordGoalReport(tool.GoalReport{Status: GoalStatusComplete, Reason: ""}); err != nil {
273 t.Fatal(err)
274 }
275 c.goalUsageTee.setActiveRecorder(rec)
276
277 if err := newTurnOrchestrator(c).continueGoal(
278 context.Background(),
279 c.goals.continuationToken(),
280 nil,
281 ); err != nil {
282 t.Fatal(err)
283 }
284 if runner.calls != 1 {
285 t.Fatalf("runner calls = %d, want 1 old-Goal continuation", runner.calls)
286 }
287 if got := c.Goal(); got != "replacement goal" {
288 t.Fatalf("Goal() = %q, want replacement Goal to remain active", got)
289 }
290 if got := c.GoalStatus(); got != GoalStatusRunning {
291 t.Fatalf("GoalStatus() = %q, want replacement Goal to remain running", got)
292 }
293 }
294
295 func TestTurnOrchestratorStopHookIgnoresCanceledTurnContext(t *testing.T) {
296 runCtx, cancel := context.WithCancel(context.Background())
297 var stopCalls int
298 var stopErr error
299 hooks := hook.NewRunner([]hook.ResolvedHook{{
300 HookConfig: hook.HookConfig{Command: "record-stop"},
301 Event: hook.Stop,
302 Scope: hook.ScopeProject,
303 }}, "", func(ctx context.Context, in hook.SpawnInput) hook.SpawnResult {
304 stopCalls++
305 stopErr = ctx.Err()
306 return hook.SpawnResult{ExitCode: 0}
307 }, nil)
308 c := New(Options{
309 Runner: cancelingRunner{cancel: cancel},
310 Hooks: hooks,
311 })
312
313 o := newTurnOrchestrator(c)
314 if err := o.runTurnWithRawDisplay(runCtx, "hello", "hello", ""); err != nil {
315 t.Fatal(err)
316 }
317
318 if runCtx.Err() != context.Canceled {
319 t.Fatalf("turn context err = %v, want %v", runCtx.Err(), context.Canceled)
320 }
321 if stopCalls != 1 {
322 t.Fatalf("Stop hook calls = %d, want 1", stopCalls)
323 }
324 if stopErr != nil {
325 t.Fatalf("Stop hook context err = %v, want nil", stopErr)
326 }
327 }
328
329 type recordingSessionRunner struct {
330 session *agent.Session
331 inputs []string
332 raw []string
333 }
334
335 type deliveryScopeErrorRunner struct {
336 scopes []agent.DeliveryExecutionScope
337 }
338
339 func (r *deliveryScopeErrorRunner) Run(ctx context.Context, _ string) error {
340 if scope, ok := agent.DeliveryExecutionScopeFromContext(ctx); ok {
341 r.scopes = append(r.scopes, scope)
342 }
343 return &agent.FinalReadinessError{Attempts: 1, Reason: "missing verification", Missing: []string{"verification"}}
344 }
345
346 func TestGoalReadinessFailureContinuesThenPausesOnNoProgress(t *testing.T) {
347 runner := &deliveryScopeErrorRunner{}
348 executor := agent.New(nil, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
349 c := New(Options{Runner: runner, Executor: executor})
350 c.SetGoal("ship the integration")
351
352 err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "")
353 if err != nil {
354 t.Fatalf("run err = %v, want the loop to absorb FinalReadinessError and pause on no-progress", err)
355 }
356 // The FSM absorbs the readiness failure and continues with the missing
357 // requirements; with no host-verifiable progress across turns the
358 // no-progress gate pauses the goal instead of looping forever.
359 if got := c.GoalStatus(); got != GoalStatusBlocked {
360 t.Fatalf("GoalStatus = %q, want blocked (no-progress pause)", got)
361 }
362 if rt := c.GoalRuntime(); rt.StopCause != stopCauseNoProgress {
363 t.Fatalf("stop cause = %q, want %q", rt.StopCause, stopCauseNoProgress)
364 }
365 if len(runner.scopes) < 2 || runner.scopes[0].ID == "" || runner.scopes[0].TaskText != "ship the integration" {
366 t.Fatalf("delivery scopes = %+v, want scoped continuation turns", runner.scopes)
367 }
368 if !c.ResumeGoal() || c.GoalStatus() != GoalStatusRunning {
369 t.Fatal("paused Goal should resume with its existing scope")
370 }
371 if id, task, ok := c.goals.deliveryScope(); !ok || id != runner.scopes[0].ID || task != "ship the integration" {
372 t.Fatalf("resumed scope = (%q, %q, %v), want preserved id/task", id, task, ok)
373 }
374 }
375
376 type recoveryPauseRunner struct {
377 scopes []agent.DeliveryExecutionScope
378 calls int
379 }
380
381 func (r *recoveryPauseRunner) Run(ctx context.Context, _ string) error {
382 r.calls++
383 if scope, ok := agent.DeliveryExecutionScopeFromContext(ctx); ok {
384 r.scopes = append(r.scopes, scope)
385 }
386 return &agent.RecoveryPauseError{
387 Message: "Automatic retries paused. Reasonix stopped repeated attempts and kept completed work. Send \"continue\" to start a fresh attempt, or add instructions to change direction.",
388 StopReason: "episode_failures",
389 }
390 }
391
392 func TestRecoveryPauseKeepsGoalRunningAndDeliveryScope(t *testing.T) {
393 runner := &recoveryPauseRunner{}
394 c := New(Options{Runner: runner})
395 c.SetGoal("ship the integration")
396 if id, task, ok := c.goals.deliveryScope(); !ok || task != "ship the integration" {
397 t.Fatalf("initial scope = (%q, %q, %v)", id, task, ok)
398 }
399 scopeID, _, _ := c.goals.deliveryScope()
400
401 err := newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "start", "start", "")
402 var pause *agent.RecoveryPauseError
403 if !errors.As(err, &pause) {
404 t.Fatalf("run err = %v, want RecoveryPauseError", err)
405 }
406 // Recovery pause ends auto-continue only; Goal must stay running so the next
407 // ordinary "continue" keeps the same Goal prompt and delivery scope.
408 if got := c.GoalStatus(); got != GoalStatusRunning {
409 t.Fatalf("GoalStatus = %q, want running after recovery pause", got)
410 }
411 if id, task, ok := c.goals.deliveryScope(); !ok || id != scopeID || task != "ship the integration" {
412 t.Fatalf("scope after pause = (%q, %q, %v), want preserved running Goal", id, task, ok)
413 }
414 if len(runner.scopes) != 1 || runner.scopes[0].ID != scopeID {
415 t.Fatalf("delivery scopes = %+v, want one call with scope %q", runner.scopes, scopeID)
416 }
417
418 // A follow-up ordinary Goal turn reuses the same delivery scope without ResumeGoal.
419 err = newTurnOrchestrator(c).runGoalLoopWithRawDisplay(context.Background(), "continue", "continue", "")
420 if !errors.As(err, &pause) {
421 t.Fatalf("follow-up err = %v, want RecoveryPauseError again", err)
422 }
423 if got := c.GoalStatus(); got != GoalStatusRunning {
424 t.Fatalf("GoalStatus after continue = %q, want running", got)
425 }
426 if id, task, ok := c.goals.deliveryScope(); !ok || id != scopeID || task != "ship the integration" {
427 t.Fatalf("scope after continue = (%q, %q, %v), want same Goal", id, task, ok)
428 }
429 if runner.calls != 2 || len(runner.scopes) != 2 || runner.scopes[1].ID != scopeID {
430 t.Fatalf("follow-up scopes = %+v calls=%d, want same delivery scope reused", runner.scopes, runner.calls)
431 }
432 }
433
434 func (r *recordingSessionRunner) Run(ctx context.Context, input string) error {
435 r.inputs = append(r.inputs, input)
436 r.raw = append(r.raw, agent.RawUserInput(ctx, input))
437 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
438 return nil
439 }
440
441 func TestTurnOrchestratorGoalContinuationRunsStopPerUnit(t *testing.T) {
442 prov := &scriptedTurns{turns: flattenTurns(
443 goalToolTurn(GoalStatusRunning, "started", "next"),
444 goalToolTurn(GoalStatusComplete, "", ""),
445 )}
446 ag := agent.New(prov, goalRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
447 var stopEvents int
448 hooks := hook.NewRunner([]hook.ResolvedHook{{
449 HookConfig: hook.HookConfig{Command: "record-stop"},
450 Event: hook.Stop,
451 Scope: hook.ScopeProject,
452 }}, "", func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
453 var p hook.Payload
454 if err := json.Unmarshal([]byte(in.Stdin), &p); err != nil {
455 t.Fatalf("hook payload: %v", err)
456 }
457 if p.Event == hook.Stop {
458 stopEvents++
459 }
460 return hook.SpawnResult{ExitCode: 0}
461 }, nil)
462 c := New(Options{Runner: ag, Executor: ag, Hooks: hooks})
463 c.SetGoal("ship the refactor")
464
465 o := newTurnOrchestrator(c)
466 if err := o.runGoalLoopWithRawDisplay(context.Background(), "Start pursuing the active goal now.", "ship the refactor", ""); err != nil {
467 t.Fatal(err)
468 }
469
470 if prov.call != 4 {
471 t.Fatalf("provider calls = %d, want initial + continuation (report + final answer each)", prov.call)
472 }
473 if stopEvents != 2 {
474 t.Fatalf("Stop hook events = %d, want one per goal-loop turn unit", stopEvents)
475 }
476 }
477
478 func TestTurnOrchestratorApprovedPlanSharesOneStopHook(t *testing.T) {
479 prov := &scriptedTurns{turns: [][]provider.Chunk{
480 textTurn("Plan:\n1. Make the change\n2. Verify it"),
481 textTurn("Done."),
482 }}
483 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
484 approvalID := make(chan string, 1)
485 var promptSubmitEvents, stopEvents int
486 hooks := hook.NewRunner([]hook.ResolvedHook{
487 {
488 HookConfig: hook.HookConfig{Command: "record-submit"},
489 Event: hook.UserPromptSubmit,
490 Scope: hook.ScopeProject,
491 },
492 {
493 HookConfig: hook.HookConfig{Command: "record-stop"},
494 Event: hook.Stop,
495 Scope: hook.ScopeProject,
496 },
497 }, "", func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
498 var p hook.Payload
499 if err := json.Unmarshal([]byte(in.Stdin), &p); err != nil {
500 t.Fatalf("hook payload: %v", err)
501 }
502 switch p.Event {
503 case hook.UserPromptSubmit:
504 promptSubmitEvents++
505 case hook.Stop:
506 stopEvents++
507 }
508 return hook.SpawnResult{ExitCode: 0}
509 }, nil)
510 c := New(Options{
511 Runner: ag,
512 Executor: ag,
513 Hooks: hooks,
514 Sink: event.FuncSink(func(e event.Event) {
515 if e.Kind == event.ApprovalRequest {
516 approvalID <- e.Approval.ID
517 }
518 }),
519 })
520 c.SetPlanMode(true)
521 go func() { c.Approve(<-approvalID, true, false, false) }()
522
523 o := newTurnOrchestrator(c)
524 if err := o.runTurnWithRawDisplay(context.Background(), "plan this change", "plan this change", ""); err != nil {
525 t.Fatal(err)
526 }
527
528 if prov.call != 2 {
529 t.Fatalf("provider calls = %d, want plan + approved execution", prov.call)
530 }
531 if promptSubmitEvents != 1 {
532 t.Fatalf("UserPromptSubmit events = %d, want one for plan + approved execution unit", promptSubmitEvents)
533 }
534 if stopEvents != 1 {
535 t.Fatalf("Stop hook events = %d, want one for plan + approved execution unit", stopEvents)
536 }
537 }
538
539 func TestTurnOrchestratorRefTurnRecordsVisibleDisplay(t *testing.T) {
540 root := t.TempDir()
541 if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("referenced evidence"), 0o644); err != nil {
542 t.Fatal(err)
543 }
544 sess := agent.NewSession("sys")
545 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
546 runner := &recordingSessionRunner{session: sess}
547 events := make(chan event.Event, 4)
548 c := New(Options{
549 WorkspaceRoot: root,
550 Runner: runner,
551 Executor: exec,
552 Sink: event.FuncSink(func(e event.Event) {
553 events <- e
554 }),
555 })
556 var gotContent, gotDisplay string
557 c.SetDisplayRecorder(func(content, display string) {
558 gotContent = content
559 gotDisplay = display
560 })
561
562 const visible = "explain @notes.txt"
563 c.runRefTurn(visible, visible)
564 waitForTurnDone(t, events)
565
566 if len(runner.inputs) != 1 {
567 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
568 }
569 if !strings.Contains(runner.inputs[0], "Referenced context:") || !strings.Contains(runner.inputs[0], "referenced evidence") {
570 t.Fatalf("model input should include resolved reference context, got %q", runner.inputs[0])
571 }
572 if gotDisplay != visible {
573 t.Fatalf("display recorder display = %q, want visible prompt %q", gotDisplay, visible)
574 }
575 if gotContent != runner.inputs[0] {
576 t.Fatalf("display recorder content = %q, want persisted model input %q", gotContent, runner.inputs[0])
577 }
578 }
579
580 func TestTurnOrchestratorRefTurnPreservesExpandedPasteForRouting(t *testing.T) {
581 const label = "[Pasted text #1 · 2 lines]"
582 const display = "inspect\n\n" + label
583 const expanded = display + "\n\n--- Begin " + label + " ---\nroute-expanded-paste\nfunc main() {}\n--- End " + label + " ---"
584
585 sess := agent.NewSession("sys")
586 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
587 runner := &recordingSessionRunner{session: sess}
588 reg := tool.NewRegistry()
589 reg.Add(capabilityTestTool{name: "run_skill"})
590 c := New(Options{
591 Runner: runner,
592 Executor: exec,
593 Registry: reg,
594 Skills: []skill.Skill{{
595 Name: "paste-review",
596 Description: "review code",
597 Triggers: []string{"route-expanded-paste"},
598 Scope: skill.ScopeBuiltin,
599 }},
600 })
601 resolve := func(context.Context, string) (string, []string) {
602 return "<file path=\"notes.txt\">\nreference\n</file>", nil
603 }
604
605 if err := c.runRefTurnWithResolverSync(context.Background(), expanded, expanded, display, "", resolve); err != nil {
606 t.Fatal(err)
607 }
608 if len(runner.inputs) != 1 || !strings.Contains(runner.inputs[0], "Referenced context:") || !strings.Contains(runner.inputs[0], expanded) {
609 t.Fatalf("provider input = %+v, want resolved context and expanded paste", runner.inputs)
610 }
611 if !strings.Contains(runner.inputs[0], "skill:paste-review prefer") {
612 t.Fatalf("expanded pasted text did not drive capability routing:\n%s", runner.inputs[0])
613 }
614 if len(runner.raw) != 1 || runner.raw[0] != expanded {
615 t.Fatalf("persisted raw input = %+v, want complete user input %q", runner.raw, expanded)
616 }
617 }
618
619 func TestTurnOrchestratorAutoReasoningLanguageUsesRawPromptForRefTurns(t *testing.T) {
620 root := t.TempDir()
621 if err := os.WriteFile(filepath.Join(root, "auth.go"), []byte("package main\nfunc AuthHandler() error { return errors.New(\"not authorized\") }\n"), 0o644); err != nil {
622 t.Fatal(err)
623 }
624 runner := &fakeTurnRunner{}
625 events := make(chan event.Event, 4)
626 c := New(Options{
627 WorkspaceRoot: root,
628 Runner: runner,
629 Sink: event.FuncSink(func(e event.Event) {
630 events <- e
631 }),
632 })
633
634 const visible = "解释 @auth.go 的报错"
635 c.runRefTurn(visible, visible)
636 waitForTurnDone(t, events)
637
638 if len(runner.inputs) != 1 {
639 t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
640 }
641 got := runner.inputs[0]
642 if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") {
643 t.Fatalf("auto reasoning language should anchor Chinese before referenced context, got %q", got)
644 }
645 if !strings.Contains(got, "Referenced context:") || !strings.Contains(got, "AuthHandler") {
646 t.Fatalf("ref context missing from model input: %q", got)
647 }
648 if strings.Contains(got, "use English") {
649 t.Fatalf("English referenced file content should not win over raw Chinese prompt:\n%s", got)
650 }
651 }
652
653 func TestTurnOrchestratorCheckpointBoundaryPrecedesUserMessage(t *testing.T) {
654 dir := t.TempDir()
655 path := filepath.Join(dir, "session.jsonl")
656 sess := agent.NewSession("sys")
657 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
658 runner := &recordingSessionRunner{session: sess}
659 c := New(Options{
660 Runner: runner,
661 Executor: exec,
662 SessionDir: dir,
663 SessionPath: path,
664 Label: "test",
665 })
666
667 o := newTurnOrchestrator(c)
668 if err := o.runTurnWithRawDisplay(context.Background(), "write the test", "write the test", ""); err != nil {
669 t.Fatal(err)
670 }
671
672 if !c.CheckpointHasBoundary(0) {
673 t.Fatal("checkpoint boundary should be available for the orchestrated turn")
674 }
675 if len(sess.Messages) != 2 || sess.Messages[1].Content != "write the test" {
676 t.Fatalf("session messages after turn = %+v, want system + user", sess.Messages)
677 }
678 loaded, err := agent.LoadSession(path)
679 if err != nil {
680 t.Fatal(err)
681 }
682 if len(loaded.Messages) != 2 {
683 t.Fatalf("saved messages = %d, want system + user", len(loaded.Messages))
684 }
685 meta, ok, err := agent.LoadBranchMeta(path)
686 if err != nil || !ok {
687 t.Fatalf("load branch meta ok=%v err=%v", ok, err)
688 }
689 if meta.UpdatedAt.IsZero() {
690 t.Fatal("activity meta should be marked after transcript changes")
691 }
692 if err := c.Rewind(0, RewindConversation); err != nil {
693 t.Fatal(err)
694 }
695 if len(sess.Messages) != 1 {
696 t.Fatalf("session messages after rewind = %d, want boundary before user message", len(sess.Messages))
697 }
698 }
699
700 // TestTurnOrchestratorCheckpointPromptIsRawUserInput verifies the rewind picker
701 // label records the user's own text, not the composed provider input. compose()
702 // prefixes the turn with transient blocks (<response-language>,
703 // <reasoning-language>, plan marker, memory, hook context, …); storing that
704 // string as checkpoint.Prompt made the Esc-Esc picker show a wall of prefab
705 // prompt text instead of the user's messages.
706 func TestTurnOrchestratorCheckpointPromptIsRawUserInput(t *testing.T) {
707 dir := t.TempDir()
708 path := filepath.Join(dir, "session.jsonl")
709 sess := agent.NewSession("sys")
710 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
711 runner := &recordingSessionRunner{session: sess}
712 c := New(Options{
713 Runner: runner,
714 Executor: exec,
715 SessionDir: dir,
716 SessionPath: path,
717 Label: "test",
718 ResponseLanguage: "zh",
719 ReasoningLanguage: "en",
720 })
721 o := newTurnOrchestrator(c)
722 const raw = "fix the parser"
723 if err := o.runTurnWithRawDisplay(context.Background(), raw, raw, ""); err != nil {
724 t.Fatal(err)
725 }
726 cps := c.Checkpoints()
727 if len(cps) != 1 {
728 t.Fatalf("checkpoints = %+v, want exactly one", cps)
729 }
730 if got := cps[0].Prompt; got != raw {
731 t.Fatalf("checkpoint prompt = %q, want raw user input %q (composed text leaked into the rewind picker)", got, raw)
732 }
733 for _, prefab := range []string{"<response-language>", "<reasoning-language>"} {
734 if strings.Contains(cps[0].Prompt, prefab) {
735 t.Fatalf("checkpoint prompt contains %q: %q", prefab, cps[0].Prompt)
736 }
737 }
738 }
739
740 func TestTurnOrchestratorSyntheticTurnDoesNotCreateCheckpoint(t *testing.T) {
741 dir := t.TempDir()
742 path := filepath.Join(dir, "session.jsonl")
743 sess := agent.NewSession("sys")
744 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
745 runner := &recordingSessionRunner{session: sess}
746 c := New(Options{
747 Runner: runner,
748 Executor: exec,
749 SessionDir: dir,
750 SessionPath: path,
751 Label: "test",
752 })
753 o := newTurnOrchestrator(c)
754 if err := o.runTurnWithRawDisplay(context.Background(), "real prompt", "real prompt", ""); err != nil {
755 t.Fatal(err)
756 }
757 if err := o.runSyntheticTurnWithRawDisplay(context.Background(), "hidden follow-up", "hidden follow-up", ""); err != nil {
758 t.Fatal(err)
759 }
760
761 cps := c.Checkpoints()
762 if len(cps) != 1 {
763 t.Fatalf("checkpoints = %+v, want exactly the visible user turn", cps)
764 }
765 if cps[0].Turn != 0 || cps[0].Prompt != "real prompt" {
766 t.Fatalf("checkpoint = %+v, want turn 0 real prompt", cps[0])
767 }
768 turns := c.CheckpointTurnsByMessageIndex()
769 if len(turns) != 1 || turns[1] != 0 {
770 t.Fatalf("checkpoint turns by message index = %v, want {1:0}", turns)
771 }
772 }
773
774 func TestTurnOrchestratorStopFailureHookCancelledContext(t *testing.T) {
775 prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("done")}}
776 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
777 var stopCalls int
778 hooks := hook.NewRunner([]hook.ResolvedHook{{
779 HookConfig: hook.HookConfig{Command: "stop"},
780 Event: hook.StopFailure,
781 Scope: hook.ScopeProject,
782 }}, "", func(ctx context.Context, in hook.SpawnInput) hook.SpawnResult {
783 if ctx.Err() != nil {
784 t.Errorf("Stop hook spawner ctx.Err()=%v; want nil", ctx.Err())
785 }
786 var p hook.Payload
787 json.Unmarshal([]byte(in.Stdin), &p)
788 if p.Event == hook.StopFailure {
789 if p.Error == "" || !p.IsInterrupt {
790 t.Errorf("failure payload = %+v", p)
791 }
792 stopCalls++
793 }
794 return hook.SpawnResult{ExitCode: 0}
795 }, nil)
796 c := New(Options{Runner: ag, Executor: ag, Hooks: hooks})
797 ctx, cancel := context.WithCancel(context.Background())
798 cancel()
799 o := newTurnOrchestrator(c)
800 if err := o.runTurnWithRawDisplay(ctx, "test", "test", ""); err != nil && err != context.Canceled {
801 t.Fatal(err)
802 }
803 if stopCalls != 1 {
804 t.Fatalf("StopFailure hooks called = %d; want 1", stopCalls)
805 }
806 }
807
808 // TestTurnOrchestratorCancelPreservesVisibleUserPrompt verifies that when the
809 // user explicitly cancels a visible turn (Ctrl+C), the real user prompt and
810 // fully paired tool work remain in the session while unsafe fragments become
811 // provider-excluded display history.
812 func TestTurnOrchestratorCancelPreservesVisibleUserPrompt(t *testing.T) {
813 sess := agent.NewSession("you are a helpful agent")
814 // Pre-populate with a few messages from an earlier turn.
815 sess.Add(provider.Message{Role: provider.RoleUser, Content: "previous work"})
816 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
817 preCount := len(sess.Messages)
818
819 // runner that simulates a cancelled turn: it adds the user message plus
820 // some tool-call garbage the real agent would leave behind, then returns
821 // context.Canceled.
822 runner := &cancelStrippingRunner{
823 session: sess,
824 add: []provider.Message{
825 {Role: provider.RoleAssistant, Content: "let me do that", ToolCalls: []provider.ToolCall{
826 {ID: "c1", Name: "todo_write", Arguments: `{"todos":[{"content":"add abc","status":"in_progress"}]}`},
827 }},
828 {Role: provider.RoleTool, Content: "Todos updated: 1 total — 0 completed, 1 in_progress, 0 pending.", ToolCallID: "c1", Name: "todo_write"},
829 },
830 err: context.Canceled,
831 }
832
833 ex := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
834 c := New(Options{Runner: runner, Executor: ex})
835 c.SetPlanMode(true)
836 // Simulate a user-initiated cancel: set the cancelling flag.
837 c.mu.Lock()
838 c.canceling = true
839 c.mu.Unlock()
840
841 // Pre-seed todoState as if a successful todo_write from the cancelled turn
842 // had already updated it — this is the state the runner leaves behind before
843 // returning context.Canceled, and what RebuildTodoState must clear.
844 ex.ReplaceTodoState([]evidence.TodoItem{{Content: "add abc", Status: "in_progress"}})
845
846 o := newTurnOrchestrator(c)
847 err := o.runTurnWithRawDisplay(context.Background(), "add config file abc", "add config file abc", "")
848 if !errors.Is(err, context.Canceled) {
849 t.Fatalf("expected context.Canceled, got %v", err)
850 }
851
852 // The visible user prompt and completed tool pair stay, followed by a durable
853 // provider-excluded recovery record.
854 msgs := sess.Messages
855 if len(msgs) != preCount+4 {
856 t.Fatalf("session messages after cancel = %d, want user + tool pair + recovery %d: %+v", len(msgs), preCount+4, msgs)
857 }
858 user := msgs[preCount]
859 if user.Role != provider.RoleUser || user.Content != "add config file abc" {
860 t.Fatalf("cancelled user message = %+v, want prefix-free prompt", user)
861 }
862 if msgs[preCount+1].Role != provider.RoleAssistant || msgs[preCount+2].Role != provider.RoleTool {
863 t.Fatalf("completed tool pair was not retained: %+v", msgs[preCount+1:])
864 }
865 last := msgs[len(msgs)-1]
866 if !last.LocalOnly || last.InterruptedTurn == nil || !last.InterruptedTurn.Pending || len(last.InterruptedTurn.CompletedTools) != 1 {
867 t.Fatalf("pending recovery metadata missing: %+v", last)
868 }
869
870 // The completed todo_write result is canonical, so its state remains visible
871 // and the next model turn can inspect rather than blindly repeat it.
872 if todos := c.Todos(); len(todos) != 1 || todos[0].Status != "in_progress" {
873 t.Fatalf("Todos() after cancel = %v, want retained completed todo_write state", todos)
874 }
875 }
876
877 func TestTurnOrchestratorProviderErrorPreservesCompletedPairAndLocalPartial(t *testing.T) {
878 sess := agent.NewSession("system")
879 apiErr := errors.New("provider connection reset")
880 runner := &cancelStrippingRunner{
881 session: sess,
882 add: []provider.Message{
883 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`, Added: 1}}},
884 {Role: provider.RoleTool, ToolCallID: "c1", Name: "write_file", Content: "wrote a.txt"},
885 {
886 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
887 LocalOnly: true, Content: "partial final answer", ReasoningContent: "partial reasoning",
888 InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true, DroppedPartialText: true, DroppedPartialReasoning: true},
889 },
890 },
891 err: apiErr,
892 }
893 ex := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
894 c := New(Options{Runner: runner, Executor: ex})
895
896 err := newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), "update a.txt", "update a.txt", "")
897 if !errors.Is(err, apiErr) {
898 t.Fatalf("run error = %v, want %v", err, apiErr)
899 }
900 msgs := sess.Snapshot()
901 if len(msgs) != 5 || msgs[2].Role != provider.RoleAssistant || msgs[3].Role != provider.RoleTool || !msgs[4].LocalOnly {
902 t.Fatalf("provider-error recovery transcript = %+v", msgs)
903 }
904 recovery := msgs[4].InterruptedTurn
905 if recovery == nil || !recovery.Pending || len(recovery.CompletedTools) != 1 || len(recovery.CompletedTools[0].Files) != 1 || recovery.CompletedTools[0].Files[0] != "a.txt" {
906 t.Fatalf("provider-error recovery metadata = %+v", recovery)
907 }
908 if msgs[4].Content != "partial final answer" || msgs[4].ReasoningContent != "partial reasoning" {
909 t.Fatalf("provider-error display output was not retained: %+v", msgs[4])
910 }
911 }
912
913 func TestTurnOrchestratorInterruptedAfterCompactionRelocatesVisibleTurn(t *testing.T) {
914 for _, tc := range []struct {
915 name string
916 err error
917 cancel bool
918 }{
919 {name: "cancel", err: context.Canceled, cancel: true},
920 {name: "provider error", err: errors.New("provider connection reset")},
921 } {
922 t.Run(tc.name, func(t *testing.T) {
923 sess := agent.NewSession("system")
924 for i := 0; i < 3; i++ {
925 sess.Add(provider.Message{Role: provider.RoleUser, Content: "old task"})
926 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "old answer"})
927 }
928 start := sess.Len()
929 runner := &compactingErrorRunner{session: sess, err: tc.err}
930 c := New(Options{Runner: runner, Executor: agent.New(nil, nil, sess, agent.Options{}, event.Discard)})
931 if tc.cancel {
932 c.mu.Lock()
933 c.canceling = true
934 c.mu.Unlock()
935 }
936
937 err := newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), "update a.txt", "update a.txt", "")
938 if !errors.Is(err, tc.err) {
939 t.Fatalf("run error = %v, want %v", err, tc.err)
940 }
941 msgs := sess.Snapshot()
942 if start <= len(msgs) {
943 t.Fatalf("test setup did not shrink transcript below stale boundary: start=%d len=%d", start, len(msgs))
944 }
945 userCount := 0
946 for _, m := range msgs {
947 if m.Role == provider.RoleUser && StripComposePrefixes(m.Content) == "update a.txt" {
948 userCount++
949 }
950 }
951 if userCount != 1 {
952 t.Fatalf("current user occurrences = %d, want 1: %+v", userCount, msgs)
953 }
954 if len(msgs) != 6 || !agent.IsCompactionSummary(msgs[1]) || msgs[3].Role != provider.RoleAssistant || msgs[4].Role != provider.RoleTool || !msgs[5].LocalOnly {
955 t.Fatalf("recovered compacted transcript = %+v", msgs)
956 }
957 recovery := msgs[5].InterruptedTurn
958 if recovery == nil || !recovery.Pending || len(recovery.CompletedTools) != 1 || recovery.CompletedTools[0].Name != "write_file" {
959 t.Fatalf("recovery metadata = %+v", recovery)
960 }
961 })
962 }
963 }
964
965 func TestTurnOrchestratorCancelClassifiesCancelledToolResultAsInterrupted(t *testing.T) {
966 sess := agent.NewSession("system")
967 runner := &cancelStrippingRunner{
968 session: sess,
969 add: []provider.Message{
970 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "c1", Name: "bash", Arguments: `{"command":"go test ./..."}`}}},
971 {Role: provider.RoleTool, ToolCallID: "c1", Name: "bash", Content: "error: context canceled"},
972 },
973 err: context.Canceled,
974 }
975 c := New(Options{Runner: runner, Executor: agent.New(nil, nil, sess, agent.Options{}, event.Discard)})
976 c.mu.Lock()
977 c.canceling = true
978 c.mu.Unlock()
979
980 err := newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), "run tests", "run tests", "")
981 if !errors.Is(err, context.Canceled) {
982 t.Fatalf("run error = %v, want cancellation", err)
983 }
984 msgs := sess.Snapshot()
985 recovery := msgs[len(msgs)-1].InterruptedTurn
986 if recovery == nil || len(recovery.CompletedTools) != 0 || len(recovery.InterruptedTools) != 1 || recovery.InterruptedTools[0] != "bash" {
987 t.Fatalf("cancelled tool result was misclassified: %+v", recovery)
988 }
989 if msgs[len(msgs)-3].Role != provider.RoleAssistant || msgs[len(msgs)-2].Role != provider.RoleTool {
990 t.Fatalf("paired cancelled call/result should remain canonical: %+v", msgs)
991 }
992 }
993
994 func TestTurnOrchestratorCancelBeforeRunnerAddsUserPreservesVisiblePrompt(t *testing.T) {
995 workspace := t.TempDir()
996 writeVisionTestConfig(t, workspace)
997 imagePath := filepath.Join(workspace, "diagram.png")
998 if err := os.WriteFile(imagePath, mustBase64(t, tinyPNG), 0o644); err != nil {
999 t.Fatal(err)
1000 }
1001 sess := agent.NewSession("system")
1002 ex := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
1003 c := New(Options{
1004 Runner: cancelBeforeUserRunner{},
1005 Executor: ex,
1006 WorkspaceRoot: workspace,
1007 ModelRef: "custom/vision-pro",
1008 })
1009 c.SetPlanMode(true)
1010 c.mu.Lock()
1011 c.canceling = true
1012 c.mu.Unlock()
1013
1014 err := newTurnOrchestrator(c).runTurnWithRawDisplay(context.Background(), "inspect @diagram.png", "inspect @diagram.png", "")
1015 if !errors.Is(err, context.Canceled) {
1016 t.Fatalf("expected context.Canceled, got %v", err)
1017 }
1018 msgs := sess.Snapshot()
1019 if len(msgs) != 3 || msgs[1].Role != provider.RoleUser || msgs[1].Content != "inspect @diagram.png" || !msgs[2].LocalOnly {
1020 t.Fatalf("session after pre-executor cancel = %+v, want user plus recovery marker", msgs)
1021 }
1022 if len(msgs[1].Images) != 1 || !strings.HasPrefix(msgs[1].Images[0], "data:image/png;base64,") {
1023 t.Fatalf("session after pre-executor cancel lost user image: %+v", msgs[1].Images)
1024 }
1025 }
1026
1027 // TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk verifies that after a
1028 // user-cancel strip the cleaned transcript is written to disk, so a restart or
1029 // session resume does not reload the partial turn from a stale mid-turn
1030 // autosave. See #5286.
1031 func TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk(t *testing.T) {
1032 sess := agent.NewSession("system")
1033 sess.Add(provider.Message{Role: provider.RoleUser, Content: "earlier turn"})
1034 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
1035 // Count only non-system messages; the system prompt is not written to the
1036 // .jsonl by Session.Save (it is reconstructed from the session options).
1037 wantNonSystem := 0
1038 for _, m := range sess.Messages {
1039 if m.Role != provider.RoleSystem {
1040 wantNonSystem++
1041 }
1042 }
1043 wantNonSystem += 4 // visible user + complete assistant/tool pair + recovery
1044
1045 runner := &cancelStrippingRunner{
1046 session: sess,
1047 add: []provider.Message{
1048 {Role: provider.RoleAssistant, Content: "working…", ToolCalls: []provider.ToolCall{
1049 {ID: "d1", Name: "todo_write", Arguments: `{"todos":[{"content":"task","status":"in_progress"}]}`},
1050 }},
1051 {Role: provider.RoleTool, Content: "Todos updated.", ToolCallID: "d1", Name: "todo_write"},
1052 },
1053 err: context.Canceled,
1054 }
1055
1056 sessionPath := agent.NewSessionPath(t.TempDir(), "test-model")
1057 c := New(Options{
1058 Runner: runner,
1059 Executor: agent.New(nil, nil, sess, agent.Options{}, event.Discard),
1060 SessionPath: sessionPath,
1061 })
1062 c.SetPlanMode(true)
1063 c.mu.Lock()
1064 c.canceling = true
1065 c.mu.Unlock()
1066
1067 o := newTurnOrchestrator(c)
1068 if err := o.runTurnWithRawDisplay(context.Background(), "do something", "do something", ""); !errors.Is(err, context.Canceled) {
1069 t.Fatalf("expected context.Canceled, got %v", err)
1070 }
1071
1072 // Load the session file written after cleanup and verify the complete pair and
1073 // provider-excluded recovery marker survive restart.
1074 loaded, err := agent.LoadSession(sessionPath)
1075 if err != nil {
1076 t.Fatalf("LoadSession: %v", err)
1077 }
1078 nonSystem := 0
1079 var last provider.Message
1080 for _, m := range loaded.Messages {
1081 if m.Role != provider.RoleSystem {
1082 nonSystem++
1083 last = m
1084 }
1085 }
1086 if nonSystem != wantNonSystem {
1087 t.Fatalf("on-disk message count (non-system) = %d, want %d — stale partial turn still on disk", nonSystem, wantNonSystem)
1088 }
1089 if !last.LocalOnly || last.InterruptedTurn == nil || !last.InterruptedTurn.Pending {
1090 t.Fatalf("last on-disk message = %+v, want pending local recovery", last)
1091 }
1092 }
1093
1094 func TestResumeRecoversStaleVisibleInFlightTurn(t *testing.T) {
1095 dir := t.TempDir()
1096 path := filepath.Join(dir, "stale-visible.jsonl")
1097 sess := agent.NewSession("system")
1098 sess.Add(provider.Message{Role: provider.RoleUser, Content: "previous work"})
1099 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
1100 start := len(sess.Messages)
1101 sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue work"})
1102 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "working", ToolCalls: []provider.ToolCall{
1103 {ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"continue work","status":"in_progress"}]}`},
1104 }})
1105 sess.Add(provider.Message{Role: provider.RoleTool, Content: "Todos updated.", ToolCallID: "todo-1", Name: "todo_write"})
1106 if err := sess.Save(path); err != nil {
1107 t.Fatal(err)
1108 }
1109 if err := agent.MarkSessionInFlightTurn(path, start, true); err != nil {
1110 t.Fatal(err)
1111 }
1112
1113 loaded, err := agent.LoadSession(path)
1114 if err != nil {
1115 t.Fatal(err)
1116 }
1117 exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
1118 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path})
1119 c.Resume(loaded, path)
1120
1121 msgs := exec.Session().Snapshot()
1122 if len(msgs) != start+4 {
1123 t.Fatalf("resumed messages = %d, want user + completed pair + recovery %d: %+v", len(msgs), start+4, msgs)
1124 }
1125 last := msgs[len(msgs)-1]
1126 if !last.LocalOnly || last.InterruptedTurn == nil || !last.InterruptedTurn.Pending {
1127 t.Fatalf("last resumed message = %+v, want provider-excluded recovery", last)
1128 }
1129 if todos := c.Todos(); len(todos) != 1 || todos[0].Status != "in_progress" {
1130 t.Fatalf("Todos() after stale in-flight recovery = %+v, want retained completed todo_write", todos)
1131 }
1132 reloaded, err := agent.LoadSession(path)
1133 if err != nil {
1134 t.Fatal(err)
1135 }
1136 if len(reloaded.Messages) != start+4 {
1137 t.Fatalf("persisted messages = %d, want recovered count %d: %+v", len(reloaded.Messages), start+4, reloaded.Messages)
1138 }
1139 meta, ok, err := agent.LoadBranchMeta(path)
1140 if err != nil || !ok {
1141 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
1142 }
1143 if meta.InFlightTurn != nil {
1144 t.Fatalf("stale in-flight marker survived resume: %+v", meta.InFlightTurn)
1145 }
1146 }
1147
1148 func TestResumeClearsStaleSyntheticInFlightTurn(t *testing.T) {
1149 dir := t.TempDir()
1150 path := filepath.Join(dir, "stale-synthetic.jsonl")
1151 sess := agent.NewSession("system")
1152 sess.Add(provider.Message{Role: provider.RoleUser, Content: "ship it"})
1153 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "Started.\n\n[goal:continue]"})
1154 start := len(sess.Messages)
1155 sess.Add(provider.Message{Role: provider.RoleUser, Content: goalContinueTurn})
1156 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "hidden continuation partial"})
1157 if err := sess.Save(path); err != nil {
1158 t.Fatal(err)
1159 }
1160 if err := agent.MarkSessionInFlightTurn(path, start, false); err != nil {
1161 t.Fatal(err)
1162 }
1163
1164 loaded, err := agent.LoadSession(path)
1165 if err != nil {
1166 t.Fatal(err)
1167 }
1168 exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
1169 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path})
1170 c.Resume(loaded, path)
1171
1172 msgs := exec.Session().Snapshot()
1173 if len(msgs) != start {
1174 t.Fatalf("resumed messages = %d, want synthetic turn stripped to %d: %+v", len(msgs), start, msgs)
1175 }
1176 if last := msgs[len(msgs)-1]; last.Role != provider.RoleAssistant || !strings.Contains(last.Content, "[goal:continue]") {
1177 t.Fatalf("last resumed message = %+v, want completed visible turn preserved", last)
1178 }
1179 meta, ok, err := agent.LoadBranchMeta(path)
1180 if err != nil || !ok {
1181 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
1182 }
1183 if meta.InFlightTurn != nil {
1184 t.Fatalf("stale in-flight marker survived resume: %+v", meta.InFlightTurn)
1185 }
1186 }
1187
1188 // cancelStrippingRunner adds messages to a session then returns a fixed error,
1189 // simulating an agent that was interrupted mid-turn.
1190 type cancelStrippingRunner struct {
1191 session *agent.Session
1192 add []provider.Message
1193 err error
1194 }
1195
1196 type compactingErrorRunner struct {
1197 session *agent.Session
1198 err error
1199 }
1200
1201 type cancelBeforeUserRunner struct{}
1202
1203 func (cancelBeforeUserRunner) Run(context.Context, string) error {
1204 return context.Canceled
1205 }
1206
1207 func (r *cancelStrippingRunner) Run(ctx context.Context, input string) error {
1208 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
1209 for _, m := range r.add {
1210 r.session.Add(m)
1211 }
1212 return r.err
1213 }
1214
1215 func (r *compactingErrorRunner) Run(_ context.Context, input string) error {
1216 r.session.Replace([]provider.Message{
1217 {Role: provider.RoleSystem, Content: "system"},
1218 {Role: provider.RoleUser, Content: "<compaction-summary>\nold work\n</compaction-summary>"},
1219 {Role: provider.RoleUser, Content: input, CreatedAt: time.Now().UnixMilli()},
1220 {Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "write-1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`}}},
1221 {Role: provider.RoleTool, ToolCallID: "write-1", Name: "write_file", Content: "wrote a.txt"},
1222 {
1223 Role: provider.RoleTool, ToolCallID: provider.LocalOnlyToolID, Name: provider.LocalOnlyToolName,
1224 LocalOnly: true, Content: "partial final answer", ReasoningContent: "private partial reasoning",
1225 InterruptedTurn: &provider.InterruptedTurnRecovery{Pending: true},
1226 },
1227 })
1228 return r.err
1229 }
1230
1230 lines GO