返回 DeepSeek-Reasonix
controller_test.go
根目录 / internal / control / controller_test.go
1 package control
2
3 import (
4 "context"
5 "encoding/json"
6 "errors"
7 "fmt"
8 "io"
9 "net/http"
10 "net/http/httptest"
11 "os"
12 "path/filepath"
13 "reflect"
14 "strings"
15 "sync"
16 "sync/atomic"
17 "testing"
18 "time"
19
20 "reasonix/internal/agent"
21 "reasonix/internal/checkpoint"
22 "reasonix/internal/command"
23 "reasonix/internal/config"
24 "reasonix/internal/event"
25 "reasonix/internal/guardian"
26 "reasonix/internal/hook"
27 "reasonix/internal/i18n"
28 "reasonix/internal/jobs"
29 "reasonix/internal/memory"
30 "reasonix/internal/permission"
31 "reasonix/internal/plugin"
32 "reasonix/internal/pluginpkg"
33 "reasonix/internal/provider"
34 "reasonix/internal/skill"
35 "reasonix/internal/tool"
36 )
37
38 type typedNilControllerSink struct{}
39
40 func (*typedNilControllerSink) Emit(event.Event) {}
41
42 func TestResolvePlanDecisionRecordsDistinctOutcomes(t *testing.T) {
43 tests := []struct {
44 action PlanDecisionAction
45 allow bool
46 }{
47 {action: PlanDecisionStartExecution, allow: true},
48 {action: PlanDecisionRevisePlan, allow: false},
49 {action: PlanDecisionExitPlan, allow: false},
50 }
51 for _, tt := range tests {
52 t.Run(string(tt.action), func(t *testing.T) {
53 session := agent.NewSession("sys")
54 session.Add(provider.Message{Role: provider.RoleAssistant, Content: "proposed plan"})
55 exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
56 c := New(Options{Executor: exec})
57 id, reply := c.approval.registerDecisionKind(planApprovalTool, "", "", true, false, "plan", nil)
58
59 if err := c.ResolvePlanDecision(id, tt.action); err != nil {
60 t.Fatalf("ResolvePlanDecision: %v", err)
61 }
62 select {
63 case got := <-reply:
64 if got.allow != tt.allow {
65 t.Fatalf("reply allow = %v, want %v", got.allow, tt.allow)
66 }
67 default:
68 t.Fatal("plan decision did not unblock the approval waiter")
69 }
70
71 messages := session.Snapshot()
72 if len(messages) != 2 || len(messages[1].DecisionReceipts) != 1 {
73 t.Fatalf("persisted messages = %+v, want receipt attached to plan answer", messages)
74 }
75 receipt := messages[1].DecisionReceipts[0]
76 if receipt.Kind != "plan" || receipt.Outcome != string(tt.action) {
77 t.Fatalf("receipt = %+v, want plan/%s", receipt, tt.action)
78 }
79 })
80 }
81 }
82
83 func isolateControlConfigHome(t *testing.T) string {
84 t.Helper()
85 home := t.TempDir()
86 t.Setenv("HOME", home)
87 t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
88 t.Setenv("USERPROFILE", home)
89 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
90 t.Setenv("AppData", filepath.Join(home, "AppData"))
91 t.Chdir(t.TempDir())
92 return home
93 }
94
95 func controlTestPluginByName(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
96 for _, entry := range entries {
97 if entry.Name == name {
98 return entry, true
99 }
100 }
101 return config.PluginEntry{}, false
102 }
103
104 type appendingRunner struct {
105 session *agent.Session
106 }
107
108 func (r appendingRunner) Run(_ context.Context, input string) error {
109 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
110 return nil
111 }
112
113 type handoffRunner struct {
114 session *agent.Session
115 }
116
117 func (r handoffRunner) Run(_ context.Context, input string) error {
118 r.session.Add(provider.Message{Role: provider.RoleUser, Content: "handoff: " + input})
119 return nil
120 }
121
122 type sessionContextRunner struct {
123 parentSession string
124 jobSession string
125 }
126
127 func (r *sessionContextRunner) Run(ctx context.Context, input string) error {
128 r.parentSession = agent.ParentSession(ctx)
129 r.jobSession = jobs.SessionFromContext(ctx)
130 return nil
131 }
132
133 type cancelingRunner struct {
134 cancel context.CancelFunc
135 }
136
137 func (r cancelingRunner) Run(_ context.Context, _ string) error {
138 r.cancel()
139 return nil
140 }
141
142 func TestContextSnapshotIncludesCompletionTokens(t *testing.T) {
143 prov := &scriptedTurns{turns: [][]provider.Chunk{{
144 {Type: provider.ChunkText, Text: "ok"},
145 {Type: provider.ChunkUsage, Usage: &provider.Usage{
146 PromptTokens: 6840,
147 CompletionTokens: 48,
148 TotalTokens: 6888,
149 ReasoningTokens: 48,
150 }},
151 {Type: provider.ChunkDone},
152 }}}
153 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{ContextWindow: 1_000_000}, event.Discard)
154 c := New(Options{Runner: ag, Executor: ag})
155
156 if err := c.Run(context.Background(), "hello"); err != nil {
157 t.Fatal(err)
158 }
159
160 used, window := c.ContextSnapshot()
161 if used != 6888 || window != 1_000_000 {
162 t.Fatalf("ContextSnapshot() = (%d, %d), want (6888, 1000000)", used, window)
163 }
164 }
165
166 type fakeControlTool struct{ name string }
167
168 func (t fakeControlTool) Name() string { return t.name }
169 func (fakeControlTool) Description() string {
170 return "fake"
171 }
172 func (fakeControlTool) Schema() json.RawMessage {
173 return json.RawMessage(`{"type":"object"}`)
174 }
175 func (fakeControlTool) Execute(context.Context, json.RawMessage) (string, error) {
176 return "", nil
177 }
178 func (fakeControlTool) ReadOnly() bool { return true }
179
180 type startBackgroundJobTool struct {
181 started chan string
182 release chan struct{}
183 }
184
185 func TestCancelJobCannotCrossSessionBoundary(t *testing.T) {
186 manager := jobs.NewManager(event.Discard)
187 t.Cleanup(manager.Close)
188 pathA := filepath.Join(t.TempDir(), "session-a.jsonl")
189 pathB := filepath.Join(t.TempDir(), "session-b.jsonl")
190 controllerA := New(Options{Jobs: manager})
191 controllerB := New(Options{Jobs: manager})
192 controllerA.sessionPath = pathA
193 controllerB.sessionPath = pathB
194
195 jobA := manager.StartForSession(agent.BranchID(pathA), "bash", "a", func(ctx context.Context, _ io.Writer) (string, error) {
196 <-ctx.Done()
197 return "", ctx.Err()
198 })
199 jobB := manager.StartForSession(agent.BranchID(pathB), "bash", "b", func(ctx context.Context, _ io.Writer) (string, error) {
200 <-ctx.Done()
201 return "", ctx.Err()
202 })
203
204 if controllerA.CancelJob(jobB.ID) {
205 t.Fatal("controller A cancelled controller B's job")
206 }
207 if !controllerA.CancelJob(jobA.ID) {
208 t.Fatal("controller A did not cancel its own job")
209 }
210 if !controllerB.CancelJob(jobB.ID) {
211 t.Fatal("controller B did not retain ownership of its job")
212 }
213 }
214
215 func (t startBackgroundJobTool) Name() string { return "start_background_job" }
216 func (t startBackgroundJobTool) Description() string { return "start background job" }
217 func (t startBackgroundJobTool) Schema() json.RawMessage {
218 return json.RawMessage(`{"type":"object"}`)
219 }
220 func (t startBackgroundJobTool) ReadOnly() bool { return false }
221 func (t startBackgroundJobTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
222 jm, ok := jobs.FromContext(ctx)
223 if !ok {
224 return "", nil
225 }
226 j := jm.StartForSession(jobs.SessionFromContext(ctx), "bash", "controller", func(_ context.Context, out io.Writer) (string, error) {
227 _, _ = io.WriteString(out, "before\n")
228 <-t.release
229 _, _ = io.WriteString(out, "after\n")
230 return "", nil
231 })
232 t.started <- j.ID
233 return "started " + j.ID, nil
234 }
235
236 type recordingProvider struct {
237 name string
238 streams [][]provider.Chunk
239 requests []provider.Request
240 }
241
242 func (p *recordingProvider) Name() string {
243 if p.name != "" {
244 return p.name
245 }
246 return "recording"
247 }
248
249 func (p *recordingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
250 p.requests = append(p.requests, req)
251 i := len(p.requests) - 1
252 if i >= len(p.streams) {
253 i = len(p.streams) - 1
254 }
255 chunks := p.streams[i]
256 ch := make(chan provider.Chunk, len(chunks))
257 for _, c := range chunks {
258 ch <- c
259 }
260 close(ch)
261 return ch, nil
262 }
263
264 func requestMessagesText(messages []provider.Message) string {
265 var b strings.Builder
266 for _, m := range messages {
267 b.WriteString(string(m.Role))
268 b.WriteString(": ")
269 b.WriteString(m.Content)
270 b.WriteByte('\n')
271 }
272 return b.String()
273 }
274
275 func lastUserMessage(messages []provider.Message) string {
276 for i := len(messages) - 1; i >= 0; i-- {
277 if messages[i].Role == provider.RoleUser {
278 return messages[i].Content
279 }
280 }
281 return ""
282 }
283
284 func TestNewTreatsTypedNilSinkAsDiscard(t *testing.T) {
285 var sink *typedNilControllerSink
286 c := New(Options{Sink: sink})
287
288 c.notice("typed nil sink should not panic")
289 }
290
291 func TestClearSessionMarksCleanupPendingBeforeReturningForRunningJobs(t *testing.T) {
292 dir := t.TempDir()
293 oldPath := filepath.Join(dir, "old.jsonl")
294 if err := os.MkdirAll(dir, 0o755); err != nil {
295 t.Fatal(err)
296 }
297 if err := os.WriteFile(oldPath, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
298 t.Fatal(err)
299 }
300 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
301 jm := jobs.NewManager(event.Discard)
302 release := make(chan struct{})
303 started := make(chan struct{})
304 defer func() {
305 close(release)
306 jm.Close()
307 }()
308 jm.StartForSession(agent.BranchID(oldPath), "task", "stuck clear", func(ctx context.Context, _ io.Writer) (string, error) {
309 close(started)
310 <-ctx.Done()
311 <-release
312 return "", ctx.Err()
313 })
314 select {
315 case <-started:
316 case <-time.After(30 * time.Second):
317 t.Fatal("background job never started")
318 }
319
320 ctrl := New(Options{Executor: exec, SessionDir: dir, SessionPath: oldPath, Label: "test", Jobs: jm})
321 if err := ctrl.ClearSession(); err != nil {
322 t.Fatalf("ClearSession: %v", err)
323 }
324 if !agent.IsCleanupPending(oldPath) {
325 t.Fatalf("old session should be cleanup-pending before ClearSession returns")
326 }
327 if _, err := os.Stat(oldPath); err != nil {
328 t.Fatalf("old session file should remain until delayed cleanup: %v", err)
329 }
330 sessions, err := agent.ListSessions(dir)
331 if err != nil {
332 t.Fatal(err)
333 }
334 for _, session := range sessions {
335 if filepath.Clean(session.Path) == filepath.Clean(oldPath) {
336 t.Fatalf("cleanup-pending old session still listed: %+v", sessions)
337 }
338 }
339 }
340
341 func TestClearSessionQueuesSessionStartHookContext(t *testing.T) {
342 dir := t.TempDir()
343 oldPath := filepath.Join(dir, "old.jsonl")
344 if err := os.MkdirAll(dir, 0o755); err != nil {
345 t.Fatal(err)
346 }
347 if err := os.WriteFile(oldPath, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
348 t.Fatal(err)
349 }
350 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
351 hooks := hook.NewRunner([]hook.ResolvedHook{{
352 HookConfig: hook.HookConfig{Command: "session-start"},
353 Event: hook.SessionStart,
354 }}, dir, func(context.Context, hook.SpawnInput) hook.SpawnResult {
355 return hook.SpawnResult{ExitCode: 0, Stdout: "clear session context"}
356 }, nil)
357 c := New(Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: oldPath, Label: "test", Hooks: hooks})
358
359 if err := c.ClearSession(); err != nil {
360 t.Fatalf("ClearSession: %v", err)
361 }
362 got := c.Compose("next")
363 if !strings.Contains(got, `<hook-context event="SessionStart">`) || !strings.Contains(got, "clear session context") || !strings.HasSuffix(got, "next") {
364 t.Fatalf("clear session did not queue SessionStart hook context: %q", got)
365 }
366 }
367
368 func TestReconcileCleanupPendingRemovesOrphanedArtifacts(t *testing.T) {
369 dir := t.TempDir()
370 path := filepath.Join(dir, "orphan.jsonl")
371 if err := os.WriteFile(path, []byte(`{"role":"user","content":"orphan"}`+"\n"), 0o644); err != nil {
372 t.Fatal(err)
373 }
374 if err := agent.SaveBranchMeta(path, agent.BranchMeta{Name: "orphan"}); err != nil {
375 t.Fatal(err)
376 }
377 if err := os.MkdirAll(jobs.ArtifactDir(path), 0o755); err != nil {
378 t.Fatal(err)
379 }
380 if err := os.WriteFile(filepath.Join(jobs.ArtifactDir(path), "job.log"), []byte("job output"), 0o644); err != nil {
381 t.Fatal(err)
382 }
383 if err := os.MkdirAll(ckptDir(path), 0o755); err != nil {
384 t.Fatal(err)
385 }
386 if err := agent.MarkCleanupPending(path, "delete"); err != nil {
387 t.Fatal(err)
388 }
389
390 if err := ReconcileCleanupPending(dir); err != nil {
391 t.Fatalf("ReconcileCleanupPending: %v", err)
392 }
393 for _, p := range []string{path, agent.BranchMetaPath(path), jobs.ArtifactDir(path), ckptDir(path), agent.CleanupPendingPath(path)} {
394 if _, err := os.Stat(p); !os.IsNotExist(err) {
395 t.Fatalf("%s still exists after reconciliation (err=%v)", p, err)
396 }
397 }
398 }
399
400 func TestTurnOutcomeClassifiesFinalReadiness(t *testing.T) {
401 err := &agent.FinalReadinessError{Attempts: 3, Reason: "missing verification"}
402 if got := turnOutcome(err); got != event.TurnOutcomeFinalReadiness {
403 t.Fatalf("turnOutcome() = %q, want %q", got, event.TurnOutcomeFinalReadiness)
404 }
405 if got := turnOutcome(errors.New("provider failed")); got != "" {
406 t.Fatalf("ordinary turn outcome = %q, want empty", got)
407 }
408 }
409
410 func TestRunTurnSnapshotsActivityWhenTranscriptChanges(t *testing.T) {
411 dir := t.TempDir()
412 sess := agent.NewSession("sys")
413 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
414 path := filepath.Join(dir, "session.jsonl")
415 c := New(Options{Runner: appendingRunner{session: sess}, Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
416
417 if err := c.runTurn(context.Background(), "hello"); err != nil {
418 t.Fatal(err)
419 }
420
421 loaded, err := agent.LoadSession(path)
422 if err != nil {
423 t.Fatal(err)
424 }
425 if len(loaded.Messages) != 2 {
426 t.Fatalf("saved messages = %d, want system + user", len(loaded.Messages))
427 }
428 meta, ok, err := agent.LoadBranchMeta(path)
429 if err != nil || !ok {
430 t.Fatalf("load activity meta ok=%v err=%v", ok, err)
431 }
432 if meta.UpdatedAt.IsZero() {
433 t.Fatal("activity meta should be marked")
434 }
435 }
436
437 func TestRunInjectsParentSessionForJobs(t *testing.T) {
438 dir := t.TempDir()
439 path := filepath.Join(dir, "session.jsonl")
440 runner := &sessionContextRunner{}
441 sess := agent.NewSession("sys")
442 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
443 c := New(Options{Runner: runner, Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
444
445 if err := c.Run(context.Background(), "hello"); err != nil {
446 t.Fatal(err)
447 }
448 want := agent.BranchID(path)
449 if runner.parentSession != want {
450 t.Fatalf("ParentSession = %q, want %q", runner.parentSession, want)
451 }
452 if runner.jobSession != want {
453 t.Fatalf("jobs session = %q, want %q", runner.jobSession, want)
454 }
455 }
456
457 func TestRunStopHookIgnoresCanceledCallerContext(t *testing.T) {
458 runCtx, cancel := context.WithCancel(context.Background())
459 var stopCalls int
460 var stopErr error
461 hooks := hook.NewRunner([]hook.ResolvedHook{{
462 HookConfig: hook.HookConfig{Command: "record-stop"},
463 Event: hook.Stop,
464 Scope: hook.ScopeProject,
465 }}, "", func(ctx context.Context, in hook.SpawnInput) hook.SpawnResult {
466 stopCalls++
467 stopErr = ctx.Err()
468 return hook.SpawnResult{ExitCode: 0}
469 }, nil)
470 c := New(Options{
471 Runner: cancelingRunner{cancel: cancel},
472 Hooks: hooks,
473 })
474
475 if err := c.Run(runCtx, "hello"); err != nil {
476 t.Fatal(err)
477 }
478
479 if runCtx.Err() != context.Canceled {
480 t.Fatalf("caller context err = %v, want %v", runCtx.Err(), context.Canceled)
481 }
482 if stopCalls != 1 {
483 t.Fatalf("Stop hook calls = %d, want 1", stopCalls)
484 }
485 if stopErr != nil {
486 t.Fatalf("Stop hook context err = %v, want nil", stopErr)
487 }
488 }
489
490 func TestSetSessionPathAdoptsTemporaryBackgroundJobs(t *testing.T) {
491 dir := t.TempDir()
492 path := filepath.Join(dir, "session.jsonl")
493 started := make(chan string, 1)
494 release := make(chan struct{})
495 jm := jobs.NewManager(event.Discard)
496 reg := tool.NewRegistry()
497 reg.Add(startBackgroundJobTool{started: started, release: release})
498 prov := &scriptedTurns{turns: [][]provider.Chunk{
499 toolCallTurn("call-1", "start_background_job", `{}`),
500 textTurn("done"),
501 }}
502 ag := agent.New(prov, reg, agent.NewSession("sys"), agent.Options{Jobs: jm}, event.Discard)
503 c := New(Options{Runner: ag, Executor: ag, SessionDir: dir, Label: "test", Jobs: jm})
504 defer c.Close()
505
506 if err := c.Run(context.Background(), "start background job"); err != nil {
507 t.Fatal(err)
508 }
509 jobID := <-started
510 c.SetSessionPath(path)
511 close(release)
512
513 parentSession := agent.BranchID(path)
514 res := c.jobs.WaitForSession(context.Background(), parentSession, []string{jobID}, 1)
515 if len(res) != 1 || !strings.Contains(res[0].Output, "before\n") || !strings.Contains(res[0].Output, "after\n") {
516 t.Fatalf("adopted controller job = %+v, want before/after output", res)
517 }
518 if _, err := os.Stat(filepath.Join(jobs.ArtifactDir(path), jobID+".log")); err != nil {
519 t.Fatalf("controller job artifact should be under persistent sidecar: %v", err)
520 }
521 }
522
523 func TestGoalStatePersistsNextToSessionPath(t *testing.T) {
524 dir := t.TempDir()
525 path := filepath.Join(dir, "session.jsonl")
526 sess := agent.NewSession("sys")
527 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
528 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
529
530 c.SetGoalWithResearchMode("fix the typo", GoalResearchOn)
531 c.GoalStrict(true)
532
533 data, err := os.ReadFile(goalStatePath(path))
534 if err != nil {
535 t.Fatal(err)
536 }
537 var state goalState
538 if err := json.Unmarshal(data, &state); err != nil {
539 t.Fatal(err)
540 }
541 if state.Goal != "fix the typo" || state.Status != GoalStatusRunning || state.ResearchMode != GoalResearchOn || !state.Strict {
542 t.Fatalf("goal state = %+v, want running strict research goal", state)
543 }
544 }
545
546 func TestSetGoalDurableRestoresInMemoryStateWhenSidecarWriteFails(t *testing.T) {
547 dir := t.TempDir()
548 path := filepath.Join(dir, "session.jsonl")
549 sess := agent.NewSession("sys")
550 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
551 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
552
553 c.SetGoal("keep the old goal")
554 oldStatus := c.GoalStatus()
555 notDirectory := filepath.Join(dir, "not-a-directory")
556 if err := os.WriteFile(notDirectory, []byte("block nested writes"), 0o600); err != nil {
557 t.Fatal(err)
558 }
559 c.goals.setStatePath(filepath.Join(notDirectory, "goal.json"))
560
561 if err := c.SetGoalDurable("replace the goal", ""); err == nil {
562 t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent")
563 }
564 if got := c.Goal(); got != "keep the old goal" {
565 t.Fatalf("Goal() after failed durable write = %q, want old Goal", got)
566 }
567 if got := c.GoalStatus(); got != oldStatus {
568 t.Fatalf("GoalStatus() after failed durable write = %q, want %q", got, oldStatus)
569 }
570 }
571
572 func TestSetGoalDurableRollsBackAutoResearchTaskAndNotice(t *testing.T) {
573 root := t.TempDir()
574 path := filepath.Join(root, "session.jsonl")
575 sink := &noticeSink{}
576 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
577 c := New(Options{
578 Executor: exec,
579 SessionDir: root,
580 SessionPath: path,
581 WorkspaceRoot: root,
582 Sink: sink,
583 Label: "test",
584 })
585
586 c.SetGoal("keep the old goal")
587 notDirectory := filepath.Join(root, "not-a-directory")
588 if err := os.WriteFile(notDirectory, []byte("block nested writes"), 0o600); err != nil {
589 t.Fatal(err)
590 }
591 c.goals.setStatePath(filepath.Join(notDirectory, "goal.json"))
592
593 goal := "investigate the root cause and fix the performance regression, then verify with tests"
594 if err := c.SetGoalDurable(goal, ""); err == nil {
595 t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent")
596 }
597 entries, err := os.ReadDir(filepath.Join(root, ".reasonix", "autoresearch"))
598 if err != nil && !os.IsNotExist(err) {
599 t.Fatalf("read autoresearch dir: %v", err)
600 }
601 if len(entries) != 0 {
602 t.Fatalf("autoresearch task count after rollback = %d, want 0", len(entries))
603 }
604 for _, notice := range sink.notices() {
605 if strings.Contains(notice, "autoresearch task created") || strings.Contains(notice, "autoresearch task resumed") {
606 t.Fatalf("durable failure emitted success notice %q", notice)
607 }
608 }
609 }
610
611 func TestResumeRestoresTerminalGoalTodosFromSidecar(t *testing.T) {
612 dir := t.TempDir()
613 path := filepath.Join(dir, "session.jsonl")
614 loaded := agent.NewSession("sys")
615 loaded.Add(provider.Message{
616 Role: provider.RoleAssistant,
617 ToolCalls: []provider.ToolCall{{
618 ID: "todo-1",
619 Name: "todo_write",
620 Arguments: `{"todos":[{"content":"Step 1","status":"in_progress"}]}`,
621 }},
622 })
623 loaded.Add(provider.Message{
624 Role: provider.RoleTool,
625 ToolCallID: "todo-1",
626 Name: "todo_write",
627 Content: "ok",
628 })
629 if err := os.WriteFile(goalStatePath(path), []byte(`{"status":"complete","todos":[{"content":"Step 1","status":"completed"}]}`), 0o644); err != nil {
630 t.Fatal(err)
631 }
632
633 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
634 c := New(Options{Executor: exec, SessionDir: dir, Label: "test"})
635 c.Resume(loaded, path)
636
637 got := c.Todos()
638 if len(got) != 1 || got[0].Content != "Step 1" || got[0].Status != "completed" {
639 t.Fatalf("Todos() after resume = %+v, want completed todos from goal-state sidecar", got)
640 }
641 }
642
643 func TestResumeKeepsTranscriptTodosForRunningGoalSidecar(t *testing.T) {
644 dir := t.TempDir()
645 path := filepath.Join(dir, "session.jsonl")
646 loaded := agent.NewSession("sys")
647 loaded.Add(provider.Message{
648 Role: provider.RoleAssistant,
649 ToolCalls: []provider.ToolCall{{
650 ID: "todo-1",
651 Name: "todo_write",
652 Arguments: `{"todos":[{"content":"Step 1","status":"in_progress"}]}`,
653 }},
654 })
655 loaded.Add(provider.Message{
656 Role: provider.RoleTool,
657 ToolCallID: "todo-1",
658 Name: "todo_write",
659 Content: "ok",
660 })
661 if err := os.WriteFile(goalStatePath(path), []byte(`{"status":"running","todos":[{"content":"Step 1","status":"completed"}]}`), 0o644); err != nil {
662 t.Fatal(err)
663 }
664
665 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
666 c := New(Options{Executor: exec, SessionDir: dir, Label: "test"})
667 c.Resume(loaded, path)
668
669 got := c.Todos()
670 if len(got) != 1 || got[0].Content != "Step 1" || got[0].Status != "in_progress" {
671 t.Fatalf("Todos() after resume = %+v, want transcript todos while goal state is running", got)
672 }
673 }
674
675 func TestResumeRestoresRunningAutoResearchGoalFromSidecar(t *testing.T) {
676 root := t.TempDir()
677 if resolved, err := filepath.EvalSymlinks(root); err == nil {
678 root = resolved
679 }
680 path := filepath.Join(root, "session.jsonl")
681 taskID := "investigate-runtime-resume"
682 if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state"), 0o755); err != nil {
683 t.Fatal(err)
684 }
685 if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch", taskID, "logs"), 0o755); err != nil {
686 t.Fatal(err)
687 }
688 if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "task_spec.json"), []byte(`{"id":"investigate-runtime-resume","goal":"investigate runtime resume","status":"running","created_at":"2026-06-30T00:00:00Z","updated_at":"2026-06-30T00:00:00Z","success_criteria":[{"id":"criterion-1","description":"resume keeps AutoResearch active","required":true}]}`), 0o644); err != nil {
689 t.Fatal(err)
690 }
691 if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "progress.json"), []byte(`{"task_id":"investigate-runtime-resume","iteration":2,"current_direction":"verify resume","stale_count":1,"pivot_count":0,"updated_at":"2026-06-30T00:00:00Z"}`), 0o644); err != nil {
692 t.Fatal(err)
693 }
694 if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "directions_tried.json"), []byte(`{"task_id":"investigate-runtime-resume","directions":[]}`), 0o644); err != nil {
695 t.Fatal(err)
696 }
697 if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "findings.jsonl"), nil, 0o644); err != nil {
698 t.Fatal(err)
699 }
700 if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "logs", "heartbeat.jsonl"), nil, 0o644); err != nil {
701 t.Fatal(err)
702 }
703 if err := os.WriteFile(goalStatePath(path), []byte(`{"goal":"investigate runtime resume","status":"running","researchMode":1,"autoResearchTaskID":"investigate-runtime-resume"}`), 0o644); err != nil {
704 t.Fatal(err)
705 }
706
707 loaded := agent.NewSession("sys")
708 exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
709 c := New(Options{Executor: exec, WorkspaceRoot: root, SessionDir: root, Label: "test"})
710 c.Resume(loaded, path)
711
712 if got := c.Goal(); got != "investigate runtime resume" {
713 t.Fatalf("Goal() after resume = %q, want running goal from sidecar", got)
714 }
715 composed := c.Compose("continue")
716 if !strings.Contains(composed, "<autoresearch-runtime>") || !strings.Contains(composed, "task_id: "+taskID) {
717 t.Fatalf("Compose after resume missing AutoResearch runtime for %q:\n%s", taskID, composed)
718 }
719 }
720
721 func TestRunTurnRecordsDisplayForPersistedUserMessage(t *testing.T) {
722 sess := agent.NewSession("sys")
723 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
724 c := New(Options{Runner: handoffRunner{session: sess}, Executor: exec})
725 var gotContent, gotDisplay string
726 c.SetDisplayRecorder(func(content, display string) {
727 gotContent = content
728 gotDisplay = display
729 })
730
731 if err := c.runTurnWithRawDisplay(context.Background(), "expanded prompt", "raw prompt", "visible prompt"); err != nil {
732 t.Fatal(err)
733 }
734
735 if gotContent != "handoff: expanded prompt" {
736 t.Fatalf("display recorded against %q, want persisted user message", gotContent)
737 }
738 if gotDisplay != "visible prompt" {
739 t.Fatalf("display = %q, want visible prompt", gotDisplay)
740 }
741 }
742
743 func TestSnapshotDoesNotRefreshSessionActivity(t *testing.T) {
744 dir := t.TempDir()
745 sess := agent.NewSession("sys")
746 sess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
747 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
748 c := New(Options{Executor: exec, SessionDir: dir, Label: "test", ModelRef: "provider/model-a"})
749 c.SetSessionPath(filepath.Join(dir, "session.jsonl"))
750
751 if err := c.SnapshotActivity(); err != nil {
752 t.Fatal(err)
753 }
754 first, ok, err := agent.LoadBranchMeta(c.SessionPath())
755 if err != nil || !ok {
756 t.Fatalf("load initial meta ok=%v err=%v", ok, err)
757 }
758
759 time.Sleep(10 * time.Millisecond)
760 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "saved without activity"})
761 if err := c.Snapshot(); err != nil {
762 t.Fatal(err)
763 }
764 second, ok, err := agent.LoadBranchMeta(c.SessionPath())
765 if err != nil || !ok {
766 t.Fatalf("load second meta ok=%v err=%v", ok, err)
767 }
768 if !second.UpdatedAt.Equal(first.UpdatedAt) {
769 t.Fatalf("Snapshot refreshed activity: first=%s second=%s", first.UpdatedAt, second.UpdatedAt)
770 }
771 if second.Model != "provider/model-a" {
772 t.Fatalf("snapshot model = %q, want provider/model-a", second.Model)
773 }
774 }
775
776 func TestSnapshotAdoptsNewerDiskForPureStalePrefix(t *testing.T) {
777 dir := t.TempDir()
778 path := filepath.Join(dir, "session.jsonl")
779
780 staleSess := agent.NewSession("sys")
781 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
782 staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
783 staleExec := agent.New(nil, nil, staleSess, agent.Options{}, event.Discard)
784 sink := &noticeSink{}
785 stale := New(Options{Executor: staleExec, SessionDir: dir, SessionPath: path, Label: "test", Sink: sink})
786
787 currentSess := agent.NewSession("sys")
788 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
789 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
790 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
791 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "two"})
792 currentExec := agent.New(nil, nil, currentSess, agent.Options{}, event.Discard)
793 current := New(Options{Executor: currentExec, SessionDir: dir, SessionPath: path, Label: "test"})
794 if err := current.SnapshotActivity(); err != nil {
795 t.Fatalf("SnapshotActivity current: %v", err)
796 }
797
798 if err := stale.Snapshot(); err != nil {
799 t.Fatalf("Snapshot stale prefix: %v", err)
800 }
801
802 loaded, err := agent.LoadSession(path)
803 if err != nil {
804 t.Fatalf("LoadSession: %v", err)
805 }
806 if got := len(loaded.Messages); got != 5 {
807 t.Fatalf("message count after stale snapshot = %d, want 5", got)
808 }
809 if got := loaded.Messages[4].Content; got != "two" {
810 t.Fatalf("last message after stale snapshot = %q, want %q", got, "two")
811 }
812 if got := len(stale.executor.Session().Snapshot()); got != 5 {
813 t.Fatalf("stale controller adopted message count = %d, want 5", got)
814 }
815 notice, ok := sink.lastNotice()
816 if !ok || notice.Code != event.NoticeCodeSessionRecoveryAdopted || notice.Audience != event.NoticeAudienceOperator {
817 t.Fatalf("adoption notice = %+v, want typed operator recovery notice", notice)
818 }
819 }
820
821 func TestSnapshotRecoversDivergedControllerTranscript(t *testing.T) {
822 dir := t.TempDir()
823 path := filepath.Join(dir, "session.jsonl")
824
825 staleSess := agent.NewSession("sys")
826 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
827 staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
828 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
829 staleExec := agent.New(nil, nil, staleSess, agent.Options{}, event.Discard)
830 stale := New(Options{Executor: staleExec, SessionDir: dir, SessionPath: path, Label: "test"})
831
832 currentSess := agent.NewSession("sys")
833 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
834 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
835 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
836 currentExec := agent.New(nil, nil, currentSess, agent.Options{}, event.Discard)
837 current := New(Options{Executor: currentExec, SessionDir: dir, SessionPath: path, Label: "test"})
838 if err := current.SnapshotActivity(); err != nil {
839 t.Fatalf("SnapshotActivity current: %v", err)
840 }
841
842 if err := stale.Snapshot(); err != nil {
843 t.Fatalf("Snapshot stale diverged: %v", err)
844 }
845 recoveryPath := stale.SessionPath()
846 if recoveryPath == path || recoveryPath == "" {
847 t.Fatalf("stale session path after recovery = %q, want recovery path", recoveryPath)
848 }
849 recovered, err := agent.LoadSession(recoveryPath)
850 if err != nil {
851 t.Fatalf("LoadSession recovery: %v", err)
852 }
853 if got := recovered.Messages[len(recovered.Messages)-1].Content; got != "local second" {
854 t.Fatalf("recovery tail = %q, want local second", got)
855 }
856 loaded, err := agent.LoadSession(path)
857 if err != nil {
858 t.Fatalf("LoadSession original: %v", err)
859 }
860 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "disk second" {
861 t.Fatalf("original tail = %q, want disk second", got)
862 }
863 }
864
865 // TestSnapshotConflictRecoveryTransplantsInFlightTurnMarker: when a snapshot
866 // conflict forks the running turn onto a recovery branch, the in-flight-turn
867 // marker must move with it. Left on the original branch, the stale marker
868 // makes the next open of that branch strip messages from a turn that in fact
869 // kept running (and completed) on the recovery branch.
870 func TestSnapshotConflictRecoveryTransplantsInFlightTurnMarker(t *testing.T) {
871 dir := t.TempDir()
872 path := filepath.Join(dir, "session.jsonl")
873
874 staleSess := agent.NewSession("sys")
875 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
876 staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
877 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
878 staleExec := agent.New(nil, nil, staleSess, agent.Options{}, event.Discard)
879 stale := New(Options{Executor: staleExec, SessionDir: dir, SessionPath: path, Label: "test"})
880
881 currentSess := agent.NewSession("sys")
882 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
883 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
884 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
885 currentExec := agent.New(nil, nil, currentSess, agent.Options{}, event.Discard)
886 current := New(Options{Executor: currentExec, SessionDir: dir, SessionPath: path, Label: "test"})
887 if err := current.SnapshotActivity(); err != nil {
888 t.Fatalf("SnapshotActivity current: %v", err)
889 }
890
891 // The stale runtime had a foreground turn running when the conflict fired.
892 if err := agent.MarkSessionInFlightTurn(path, 2, true); err != nil {
893 t.Fatalf("MarkSessionInFlightTurn: %v", err)
894 }
895 markedMeta, ok, err := agent.LoadBranchMeta(path)
896 if err != nil || !ok || markedMeta.InFlightTurn == nil {
897 t.Fatalf("LoadBranchMeta marked ok=%v err=%v meta=%+v", ok, err, markedMeta)
898 }
899 markedAt := markedMeta.InFlightTurn.StartedAt
900
901 if err := stale.Snapshot(); err != nil {
902 t.Fatalf("Snapshot stale diverged: %v", err)
903 }
904 recoveryPath := stale.SessionPath()
905 if recoveryPath == path || recoveryPath == "" {
906 t.Fatalf("stale session path after recovery = %q, want recovery path", recoveryPath)
907 }
908
909 origMeta, ok, err := agent.LoadBranchMeta(path)
910 if err != nil || !ok {
911 t.Fatalf("LoadBranchMeta original ok=%v err=%v", ok, err)
912 }
913 if origMeta.InFlightTurn != nil {
914 t.Fatal("in-flight turn marker left on the forked-from branch; reopening it would strip the turn")
915 }
916 recMeta, ok, err := agent.LoadBranchMeta(recoveryPath)
917 if err != nil || !ok {
918 t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
919 }
920 if recMeta.InFlightTurn == nil {
921 t.Fatal("in-flight turn marker not transplanted to the recovery branch")
922 }
923 if recMeta.InFlightTurn.StartMessageIndex != 2 || !recMeta.InFlightTurn.PreserveUser {
924 t.Fatalf("transplanted marker = %+v, want start index 2 with preserve_user", recMeta.InFlightTurn)
925 }
926 if !recMeta.InFlightTurn.StartedAt.Equal(markedAt) {
927 t.Fatalf("transplanted marker time = %v, want original %v", recMeta.InFlightTurn.StartedAt, markedAt)
928 }
929 }
930
931 // TestRecoverInterruptedTurnSparesTurnContinuedOnRecoveryBranch covers the
932 // legacy leftovers of the marker transplant: runtimes predating it forked a
933 // recovery branch mid-turn and left the in-flight marker on the original
934 // branch. Opening the original must clear the stale marker without stripping
935 // a turn that in fact kept running on the recovery branch.
936 func TestRecoverInterruptedTurnSparesTurnContinuedOnRecoveryBranch(t *testing.T) {
937 dir := t.TempDir()
938 path := filepath.Join(dir, "session.jsonl")
939
940 orig := agent.NewSession("sys")
941 orig.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
942 orig.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial"})
943 if err := orig.Save(path); err != nil {
944 t.Fatalf("Save original: %v", err)
945 }
946 if err := agent.MarkSessionInFlightTurn(path, 1, true); err != nil {
947 t.Fatalf("MarkSessionInFlightTurn: %v", err)
948 }
949 // A legacy runtime forked the running turn onto a recovery branch and
950 // left the marker behind on the original.
951 forked := agent.NewSession("sys")
952 forked.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
953 forked.Add(provider.Message{Role: provider.RoleAssistant, Content: "continued elsewhere"})
954 if _, err := forked.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: path}); err != nil {
955 t.Fatalf("SaveRecoveryBranch: %v", err)
956 }
957
958 loaded, err := agent.LoadSession(path)
959 if err != nil {
960 t.Fatalf("LoadSession: %v", err)
961 }
962 exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
963 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
964 c.recoverInterruptedTurn(path)
965
966 if got := c.executor.Session().Len(); got != 3 {
967 t.Fatalf("message count after reopening forked-from branch = %d, want 3 (turn stripped despite recovery child)", got)
968 }
969 meta, ok, err := agent.LoadBranchMeta(path)
970 if err != nil || !ok {
971 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
972 }
973 if meta.InFlightTurn != nil {
974 t.Fatal("fork-orphaned in-flight marker not cleared")
975 }
976 }
977
978 // TestRecoverInterruptedTurnPreservesGenuineCrashDisplay pins the crash-recovery
979 // behavior the recovery-child guard must not swallow: with no recovery branch
980 // in sight, an in-flight marker means the runtime died mid-turn and the
981 // partial tail becomes provider-excluded display history.
982 func TestRecoverInterruptedTurnPreservesGenuineCrashDisplay(t *testing.T) {
983 dir := t.TempDir()
984 path := filepath.Join(dir, "session.jsonl")
985
986 orig := agent.NewSession("sys")
987 orig.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
988 orig.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial"})
989 if err := orig.Save(path); err != nil {
990 t.Fatalf("Save original: %v", err)
991 }
992 if err := agent.MarkSessionInFlightTurn(path, 1, true); err != nil {
993 t.Fatalf("MarkSessionInFlightTurn: %v", err)
994 }
995
996 loaded, err := agent.LoadSession(path)
997 if err != nil {
998 t.Fatalf("LoadSession: %v", err)
999 }
1000 exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
1001 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
1002 c.recoverInterruptedTurn(path)
1003
1004 if got := c.executor.Session().Len(); got != 3 {
1005 t.Fatalf("message count after crash recovery = %d, want system + user + local recovery", got)
1006 }
1007 recovery := c.executor.Session().Snapshot()[2]
1008 if !recovery.LocalOnly || recovery.Content != "partial" || recovery.InterruptedTurn == nil || !recovery.InterruptedTurn.Pending {
1009 t.Fatalf("crash display/recovery was not retained safely: %+v", recovery)
1010 }
1011 meta, ok, err := agent.LoadBranchMeta(path)
1012 if err != nil || !ok {
1013 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
1014 }
1015 if meta.InFlightTurn != nil {
1016 t.Fatal("in-flight marker not cleared after crash recovery")
1017 }
1018 }
1019
1020 func TestRecoverInterruptedTurnAfterCompactionRelocatesVisibleTurn(t *testing.T) {
1021 dir := t.TempDir()
1022 path := filepath.Join(dir, "compacted-crash.jsonl")
1023
1024 orig := agent.NewSession("sys")
1025 for i := 0; i < 3; i++ {
1026 orig.Add(provider.Message{Role: provider.RoleUser, Content: "old task"})
1027 orig.Add(provider.Message{Role: provider.RoleAssistant, Content: "old answer"})
1028 }
1029 staleStart := orig.Len()
1030 if err := orig.Save(path); err != nil {
1031 t.Fatalf("Save original: %v", err)
1032 }
1033 if err := agent.MarkSessionInFlightTurn(path, staleStart, true); err != nil {
1034 t.Fatalf("MarkSessionInFlightTurn: %v", err)
1035 }
1036 meta, ok, err := agent.LoadBranchMeta(path)
1037 if err != nil || !ok || meta.InFlightTurn == nil {
1038 t.Fatalf("LoadBranchMeta ok=%v err=%v meta=%+v", ok, err, meta)
1039 }
1040
1041 compacted := agent.NewSession("sys")
1042 compacted.Add(provider.Message{Role: provider.RoleUser, Content: "<compaction-summary>\nold work\n</compaction-summary>"})
1043 compacted.Add(provider.Message{Role: provider.RoleUser, Content: "update a.txt", CreatedAt: meta.InFlightTurn.StartedAt.UnixMilli() + 1})
1044 compacted.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
1045 ID: "write-1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`,
1046 }}})
1047 compacted.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "write-1", Name: "write_file", Content: "wrote a.txt"})
1048 compacted.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial final answer", ReasoningContent: "private partial reasoning"})
1049 if compacted.Len() >= staleStart {
1050 t.Fatalf("test setup did not stale boundary: compacted=%d start=%d", compacted.Len(), staleStart)
1051 }
1052 if err := compacted.Save(path); err != nil {
1053 t.Fatalf("Save compacted: %v", err)
1054 }
1055
1056 loaded, err := agent.LoadSession(path)
1057 if err != nil {
1058 t.Fatalf("LoadSession: %v", err)
1059 }
1060 exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
1061 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
1062 c.recoverInterruptedTurn(path)
1063
1064 msgs := exec.Session().Snapshot()
1065 userCount := 0
1066 for _, m := range msgs {
1067 if m.Role == provider.RoleUser && StripComposePrefixes(m.Content) == "update a.txt" {
1068 userCount++
1069 }
1070 }
1071 if userCount != 1 {
1072 t.Fatalf("current user occurrences = %d, want 1: %+v", userCount, msgs)
1073 }
1074 if len(msgs) != 6 || !agent.IsCompactionSummary(msgs[1]) || msgs[3].Role != provider.RoleAssistant || msgs[4].Role != provider.RoleTool || !msgs[5].LocalOnly {
1075 t.Fatalf("crash recovery transcript = %+v", msgs)
1076 }
1077 recovery := msgs[5].InterruptedTurn
1078 if recovery == nil || !recovery.Pending || len(recovery.CompletedTools) != 1 || recovery.CompletedTools[0].Name != "write_file" || !recovery.DroppedPartialText || !recovery.DroppedPartialReasoning {
1079 t.Fatalf("crash recovery metadata = %+v", recovery)
1080 }
1081 meta, ok, err = agent.LoadBranchMeta(path)
1082 if err != nil || !ok {
1083 t.Fatalf("LoadBranchMeta after recovery ok=%v err=%v", ok, err)
1084 }
1085 if meta.InFlightTurn != nil {
1086 t.Fatalf("in-flight marker survived recovery: %+v", meta.InFlightTurn)
1087 }
1088 }
1089
1090 func TestSnapshotRewriteRecoversStaleControllerTranscript(t *testing.T) {
1091 dir := t.TempDir()
1092 path := filepath.Join(dir, "session.jsonl")
1093
1094 staleSess := agent.NewSession("sys")
1095 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1096 staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1097 staleExec := agent.New(nil, nil, staleSess, agent.Options{}, event.Discard)
1098 stale := New(Options{Executor: staleExec, SessionDir: dir, SessionPath: path, Label: "test"})
1099
1100 currentSess := agent.NewSession("sys")
1101 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1102 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1103 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
1104 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "two"})
1105 currentExec := agent.New(nil, nil, currentSess, agent.Options{}, event.Discard)
1106 current := New(Options{Executor: currentExec, SessionDir: dir, SessionPath: path, Label: "test"})
1107 if err := current.SnapshotActivity(); err != nil {
1108 t.Fatalf("SnapshotActivity current: %v", err)
1109 }
1110
1111 staleSess.Replace([]provider.Message{
1112 {Role: provider.RoleSystem, Content: "sys"},
1113 {Role: provider.RoleUser, Content: "summarized first"},
1114 })
1115 if err := stale.SnapshotRewrite(); err != nil {
1116 t.Fatalf("SnapshotRewrite stale: %v", err)
1117 }
1118
1119 loaded, err := agent.LoadSession(path)
1120 if err != nil {
1121 t.Fatalf("LoadSession: %v", err)
1122 }
1123 if got := len(loaded.Messages); got != 5 {
1124 t.Fatalf("message count after stale rewrite = %d, want 5", got)
1125 }
1126 if got := loaded.Messages[4].Content; got != "two" {
1127 t.Fatalf("last message after stale rewrite = %q, want %q", got, "two")
1128 }
1129 recoveryPath := stale.SessionPath()
1130 if recoveryPath == path || recoveryPath == "" {
1131 t.Fatalf("stale session path after rewrite recovery = %q, want recovery path", recoveryPath)
1132 }
1133 recovered, err := agent.LoadSession(recoveryPath)
1134 if err != nil {
1135 t.Fatalf("LoadSession recovery: %v", err)
1136 }
1137 if got := recovered.Messages[1].Content; got != "summarized first" {
1138 t.Fatalf("recovery content = %q, want summarized first", got)
1139 }
1140 meta, ok, err := agent.LoadBranchMeta(recoveryPath)
1141 if err != nil || !ok {
1142 t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
1143 }
1144 if !meta.Recovered || meta.ParentID != agent.BranchID(path) {
1145 t.Fatalf("recovery meta = %+v, want recovered parent", meta)
1146 }
1147 }
1148
1149 func TestSnapshotActivityPersistsOwnedCompactionRewrite(t *testing.T) {
1150 dir := t.TempDir()
1151 path := filepath.Join(dir, "session.jsonl")
1152
1153 sess := agent.NewSession("sys")
1154 sess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1155 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1156 if err := sess.Save(path); err != nil {
1157 t.Fatalf("Save base: %v", err)
1158 }
1159
1160 loaded, err := agent.LoadSession(path)
1161 if err != nil {
1162 t.Fatalf("LoadSession: %v", err)
1163 }
1164 exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
1165 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
1166
1167 // A mid-turn autosave can persist the pre-compaction prefix. Auto-compaction
1168 // then rewrites older history inside the same turn; the final activity
1169 // snapshot must persist that owned rewrite in place instead of branching.
1170 loaded.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
1171 if err := c.Snapshot(); err != nil {
1172 t.Fatalf("Snapshot pre-compaction: %v", err)
1173 }
1174 loaded.Replace([]provider.Message{
1175 {Role: provider.RoleSystem, Content: "sys"},
1176 {Role: provider.RoleUser, Content: "<compaction-summary>\nSummary of earlier conversation: first -> one\n</compaction-summary>"},
1177 {Role: provider.RoleUser, Content: "second"},
1178 })
1179 loaded.IncrementRewrite()
1180
1181 if err := c.SnapshotActivity(); err != nil {
1182 t.Fatalf("SnapshotActivity after compaction: %v", err)
1183 }
1184 if got := c.SessionPath(); got != path {
1185 t.Fatalf("session path after owned compaction = %q, want original %q", got, path)
1186 }
1187 reloaded, err := agent.LoadSession(path)
1188 if err != nil {
1189 t.Fatalf("LoadSession rewritten: %v", err)
1190 }
1191 if got := len(reloaded.Messages); got != 3 {
1192 t.Fatalf("message count after compaction rewrite = %d, want 3: %+v", got, reloaded.Messages)
1193 }
1194 if got := reloaded.Messages[1].Content; !strings.Contains(got, "compaction-summary") {
1195 t.Fatalf("compaction summary was not persisted: %+v", reloaded.Messages)
1196 }
1197 if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1198 t.Fatalf("recovery branches after owned compaction rewrite = %v err=%v, want none", matches, err)
1199 }
1200 }
1201
1202 func TestEditedPromptMetadataAfterMidTurnSnapshotStaysOnOwnedSession(t *testing.T) {
1203 dir := t.TempDir()
1204 path := filepath.Join(dir, "edited-mid-turn.jsonl")
1205
1206 sess := agent.NewSession("sys")
1207 sess.Add(provider.Message{Role: provider.RoleUser, Content: "edited prompt"})
1208 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial"})
1209 if err := sess.Save(path); err != nil {
1210 t.Fatalf("Save mid-turn transcript: %v", err)
1211 }
1212 // The model finishes after the periodic snapshot. Turn teardown then adds
1213 // local inline-edit metadata to the already-persisted user message.
1214 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "final"})
1215 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
1216 ctrl := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
1217 ctrl.markEditedForNewUser(1, "original prompt")
1218
1219 if err := ctrl.SnapshotActivity(); err != nil {
1220 t.Fatalf("SnapshotActivity edited turn: %v", err)
1221 }
1222 if got := ctrl.SessionPath(); got != path {
1223 t.Fatalf("edited turn moved to recovery path %q, want owned path %q", got, path)
1224 }
1225 loaded, err := agent.LoadSession(path)
1226 if err != nil {
1227 t.Fatalf("LoadSession: %v", err)
1228 }
1229 if got := len(loaded.Messages); got != 4 {
1230 t.Fatalf("saved message count = %d, want 4: %+v", got, loaded.Messages)
1231 }
1232 user := loaded.Messages[1]
1233 if !user.Edited || user.Original != "original prompt" || user.Content != "edited prompt" {
1234 t.Fatalf("saved edited user metadata = %+v", user)
1235 }
1236 if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1237 t.Fatalf("spurious recovery branches = %v err=%v, want none", matches, err)
1238 }
1239 }
1240
1241 func TestRecoveryBranchPersistsLaterOwnedCompactionRewrite(t *testing.T) {
1242 dir := t.TempDir()
1243 path := filepath.Join(dir, "session.jsonl")
1244
1245 currentSess := agent.NewSession("sys")
1246 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1247 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
1248 if err := currentSess.Save(path); err != nil {
1249 t.Fatalf("Save current: %v", err)
1250 }
1251
1252 localSess := agent.NewSession("sys")
1253 localSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1254 localSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
1255 localExec := agent.New(nil, nil, localSess, agent.Options{}, event.Discard)
1256 sink := &noticeSink{}
1257 c := New(Options{Executor: localExec, SessionDir: dir, SessionPath: path, Label: "test", Sink: sink})
1258
1259 if err := c.Snapshot(); err != nil {
1260 t.Fatalf("Snapshot initial recovery: %v", err)
1261 }
1262 recoveryPath := c.SessionPath()
1263 if recoveryPath == "" || recoveryPath == path {
1264 t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
1265 }
1266 notices := sink.notices()
1267 if len(notices) == 0 {
1268 t.Fatal("initial recovery emitted no operator notice")
1269 }
1270 notice, ok := sink.lastNotice()
1271 if !ok || notice.Code != event.NoticeCodeSessionRecoveryForked || notice.Audience != event.NoticeAudienceOperator {
1272 t.Fatalf("fork recovery notice = %+v, want typed operator recovery notice", notice)
1273 }
1274 if got := notices[len(notices)-1]; strings.Contains(got, agent.BranchID(recoveryPath)) || strings.Contains(got, "recovery branch") {
1275 t.Fatalf("initial recovery notice exposed internal branch detail: %q", got)
1276 }
1277
1278 localSess.Add(provider.Message{Role: provider.RoleUser, Content: "continue"})
1279 if err := c.Snapshot(); err != nil {
1280 t.Fatalf("Snapshot recovery append: %v", err)
1281 }
1282 localSess.Replace([]provider.Message{
1283 {Role: provider.RoleSystem, Content: "sys"},
1284 {Role: provider.RoleUser, Content: "<compaction-summary>\nSummary of recovery branch work: first -> local\n</compaction-summary>"},
1285 {Role: provider.RoleUser, Content: "continue"},
1286 })
1287 localSess.IncrementRewrite()
1288
1289 if err := c.SnapshotActivity(); err != nil {
1290 t.Fatalf("SnapshotActivity recovery compaction: %v", err)
1291 }
1292 if got := c.SessionPath(); got != recoveryPath {
1293 t.Fatalf("session path after recovery compaction = %q, want recovery %q", got, recoveryPath)
1294 }
1295 reloaded, err := agent.LoadSession(recoveryPath)
1296 if err != nil {
1297 t.Fatalf("LoadSession recovery: %v", err)
1298 }
1299 if got := len(reloaded.Messages); got != 3 {
1300 t.Fatalf("message count after recovery compaction = %d, want 3: %+v", got, reloaded.Messages)
1301 }
1302 if got := reloaded.Messages[1].Content; !strings.Contains(got, "compaction-summary") {
1303 t.Fatalf("recovery compaction summary was not persisted: %+v", reloaded.Messages)
1304 }
1305 if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1306 t.Fatalf("nested recovery branches after owned recovery compaction = %v err=%v, want none", matches, err)
1307 }
1308 }
1309
1310 func TestConcurrentSnapshotsShareSingleRecoveryHandoff(t *testing.T) {
1311 dir := t.TempDir()
1312 path := filepath.Join(dir, "session.jsonl")
1313
1314 currentSess := agent.NewSession("sys")
1315 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1316 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
1317 if err := currentSess.Save(path); err != nil {
1318 t.Fatalf("Save current: %v", err)
1319 }
1320
1321 localSess := agent.NewSession("sys")
1322 localSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1323 localSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
1324 localExec := agent.New(nil, nil, localSess, agent.Options{}, event.Discard)
1325
1326 entered := make(chan controlRecoveryInfo, 16)
1327 release := make(chan struct{})
1328 c := New(Options{
1329 Executor: localExec,
1330 SessionDir: dir,
1331 SessionPath: path,
1332 Label: "test",
1333 OnSessionRecovered: func(info SessionRecoveryInfo) error {
1334 entered <- controlRecoveryInfo{originalPath: info.OriginalPath, recoveryPath: info.RecoveryPath}
1335 <-release
1336 return nil
1337 },
1338 })
1339
1340 firstDone := make(chan error, 1)
1341 go func() { firstDone <- c.Snapshot() }()
1342 first := <-entered
1343 if first.originalPath != path || first.recoveryPath == "" || first.recoveryPath == path {
1344 t.Fatalf("first recovery info = %+v, want distinct recovery from original", first)
1345 }
1346
1347 const racingSnapshots = 8
1348 var wg sync.WaitGroup
1349 errs := make(chan error, racingSnapshots)
1350 for i := 0; i < racingSnapshots; i++ {
1351 wg.Add(1)
1352 go func() {
1353 defer wg.Done()
1354 errs <- c.Snapshot()
1355 }()
1356 }
1357
1358 select {
1359 case extra := <-entered:
1360 t.Fatalf("concurrent snapshot entered recovery while first handoff was blocked: %+v", extra)
1361 case <-time.After(100 * time.Millisecond):
1362 }
1363
1364 close(release)
1365 if err := <-firstDone; err != nil {
1366 t.Fatalf("first Snapshot: %v", err)
1367 }
1368 wg.Wait()
1369 close(errs)
1370 for err := range errs {
1371 if err != nil {
1372 t.Fatalf("racing Snapshot: %v", err)
1373 }
1374 }
1375 select {
1376 case extra := <-entered:
1377 t.Fatalf("unexpected additional recovery after handoff completed: %+v", extra)
1378 default:
1379 }
1380
1381 if got := c.SessionPath(); got != first.recoveryPath {
1382 t.Fatalf("controller session path = %q, want recovery %q", got, first.recoveryPath)
1383 }
1384 matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
1385 if err != nil {
1386 t.Fatalf("glob recovery branches: %v", err)
1387 }
1388 recoveries := recoveryTranscriptPaths(matches)
1389 if len(recoveries) != 1 || recoveries[0] != first.recoveryPath {
1390 t.Fatalf("recovery branches = %v err=%v, want only %q", matches, err, first.recoveryPath)
1391 }
1392 }
1393
1394 func TestRecoverShutdownSnapshotPersistsAndReanchorsSession(t *testing.T) {
1395 dir := t.TempDir()
1396 path := filepath.Join(dir, "session.jsonl")
1397 base := agent.NewSession("sys")
1398 base.Add(provider.Message{Role: provider.RoleUser, Content: "persisted"})
1399 if err := base.SaveSnapshot(path); err != nil {
1400 t.Fatalf("seed session: %v", err)
1401 }
1402
1403 current, err := agent.LoadSession(path)
1404 if err != nil {
1405 t.Fatalf("LoadSession: %v", err)
1406 }
1407 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "shutdown tail"})
1408 exec := agent.New(nil, nil, current, agent.Options{}, event.Discard)
1409 var handoff SessionRecoveryInfo
1410 sink := &noticeSink{}
1411 c := New(Options{
1412 Executor: exec,
1413 SessionDir: dir,
1414 SessionPath: path,
1415 Label: "shutdown",
1416 Sink: sink,
1417 OnSessionRecovered: func(info SessionRecoveryInfo) error {
1418 handoff = info
1419 return nil
1420 },
1421 })
1422
1423 recoveryPath, err := c.recoverShutdownSnapshot(path, agent.ErrSessionFileLockHeld)
1424 if err != nil {
1425 t.Fatalf("recoverShutdownSnapshot: %v", err)
1426 }
1427 if recoveryPath == "" || recoveryPath == path {
1428 t.Fatalf("recovery path = %q, want a distinct session", recoveryPath)
1429 }
1430 if c.SessionPath() != recoveryPath {
1431 t.Fatalf("controller session path = %q, want %q", c.SessionPath(), recoveryPath)
1432 }
1433 if handoff.OriginalPath != path || handoff.RecoveryPath != recoveryPath || handoff.Reason != "shutdown session file lock timeout" {
1434 t.Fatalf("shutdown recovery handoff = %+v", handoff)
1435 }
1436 recovered, err := agent.LoadSession(recoveryPath)
1437 if err != nil {
1438 t.Fatalf("load shutdown recovery: %v", err)
1439 }
1440 if got := recovered.Snapshot(); len(got) != 3 || got[2].Content != "shutdown tail" {
1441 t.Fatalf("shutdown recovery transcript = %+v", got)
1442 }
1443 notice, ok := sink.lastNotice()
1444 if !ok || notice.Code != event.NoticeCodeSessionShutdownRecoveryForked || notice.Audience != event.NoticeAudienceOperator {
1445 t.Fatalf("shutdown recovery notice = %+v, want typed operator recovery notice", notice)
1446 }
1447 }
1448
1449 func recoveryTranscriptPaths(paths []string) []string {
1450 out := paths[:0]
1451 for _, path := range paths {
1452 if !strings.HasSuffix(path, ".events.jsonl") {
1453 out = append(out, path)
1454 }
1455 }
1456 return out
1457 }
1458
1459 type controlRecoveryInfo struct {
1460 originalPath string
1461 recoveryPath string
1462 }
1463
1464 // blockedRecoveryHandoff is a controller whose first Snapshot has entered the
1465 // recovery handoff and is parked inside OnSessionRecovered until release is
1466 // closed, so tests can race other controller operations against an in-flight
1467 // handoff.
1468 type blockedRecoveryHandoff struct {
1469 c *Controller
1470 dir string
1471 path string
1472 local *agent.Session
1473 first controlRecoveryInfo
1474 release chan struct{}
1475 firstDone chan error
1476 }
1477
1478 func startBlockedRecoveryHandoff(t *testing.T) *blockedRecoveryHandoff {
1479 t.Helper()
1480 dir := t.TempDir()
1481 path := filepath.Join(dir, "session.jsonl")
1482
1483 currentSess := agent.NewSession("sys")
1484 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1485 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
1486 if err := currentSess.Save(path); err != nil {
1487 t.Fatalf("Save current: %v", err)
1488 }
1489
1490 localSess := agent.NewSession("sys")
1491 localSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1492 localSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
1493 localExec := agent.New(nil, nil, localSess, agent.Options{}, event.Discard)
1494
1495 entered := make(chan controlRecoveryInfo, 1)
1496 release := make(chan struct{})
1497 c := New(Options{
1498 Executor: localExec,
1499 SessionDir: dir,
1500 SessionPath: path,
1501 Label: "test",
1502 OnSessionRecovered: func(info SessionRecoveryInfo) error {
1503 entered <- controlRecoveryInfo{originalPath: info.OriginalPath, recoveryPath: info.RecoveryPath}
1504 <-release
1505 return nil
1506 },
1507 })
1508 t.Cleanup(func() {
1509 select {
1510 case <-release:
1511 default:
1512 close(release)
1513 }
1514 })
1515
1516 firstDone := make(chan error, 1)
1517 go func() { firstDone <- c.Snapshot() }()
1518 first := <-entered
1519 if first.originalPath != path || first.recoveryPath == "" || first.recoveryPath == path {
1520 t.Fatalf("recovery info = %+v, want distinct recovery from original", first)
1521 }
1522 return &blockedRecoveryHandoff{
1523 c: c, dir: dir, path: path, local: localSess,
1524 first: first, release: release, firstDone: firstDone,
1525 }
1526 }
1527
1528 // TestSessionSwapWaitsForRecoveryHandoff guards the swap side of the snapshot
1529 // serialization: moves of controller-owned session state must wait for an
1530 // in-flight save/recovery handoff instead of interleaving with it. A swap that
1531 // lands mid-handoff pairs the old path with the new session, which either
1532 // writes one transcript's messages into another's file or manufactures another
1533 // bogus conflict on the next save.
1534 func TestSessionSwapWaitsForRecoveryHandoff(t *testing.T) {
1535 cases := []struct {
1536 name string
1537 run func(t *testing.T, h *blockedRecoveryHandoff) (done chan struct{}, wantPath string)
1538 // during runs while the handoff is still blocked; verify after the
1539 // racing operation completed.
1540 during func(t *testing.T, h *blockedRecoveryHandoff)
1541 verify func(t *testing.T, h *blockedRecoveryHandoff)
1542 }{
1543 {name: "SetSessionPath", run: func(t *testing.T, h *blockedRecoveryHandoff) (chan struct{}, string) {
1544 other := filepath.Join(h.dir, "other.jsonl")
1545 done := make(chan struct{})
1546 go func() { h.c.SetSessionPath(other); close(done) }()
1547 return done, other
1548 }},
1549 {name: "Resume", run: func(t *testing.T, h *blockedRecoveryHandoff) (chan struct{}, string) {
1550 other := filepath.Join(h.dir, "resumed.jsonl")
1551 sess := agent.NewSession("sys")
1552 sess.Add(provider.Message{Role: provider.RoleUser, Content: "resumed"})
1553 if err := sess.Save(other); err != nil {
1554 t.Fatalf("Save resumed: %v", err)
1555 }
1556 done := make(chan struct{})
1557 go func() { h.c.Resume(sess, other); close(done) }()
1558 return done, other
1559 }},
1560 {
1561 name: "CancelFlush",
1562 run: func(t *testing.T, h *blockedRecoveryHandoff) (chan struct{}, string) {
1563 // Truncate the cancelled turn: drop the assistant reply, the
1564 // same shape stripTurnMessagesAfter feeds this helper.
1565 truncated := []provider.Message{
1566 {Role: provider.RoleSystem, Content: "sys"},
1567 {Role: provider.RoleUser, Content: "first"},
1568 }
1569 done := make(chan struct{})
1570 go func() { h.c.replaceSessionAfterCancel(truncated); close(done) }()
1571 return done, h.first.recoveryPath
1572 },
1573 during: func(t *testing.T, h *blockedRecoveryHandoff) {
1574 // The in-memory truncation itself must wait for the handoff: an
1575 // early Replace would let the blocked save capture the shortened
1576 // transcript, read the longer on-disk partial as a stale-prefix
1577 // conflict, and adopt it back over the cancel cleanup.
1578 if got := len(h.local.Snapshot()); got != 3 {
1579 t.Fatalf("session truncated to %d messages while the handoff was still in flight, want 3", got)
1580 }
1581 },
1582 verify: func(t *testing.T, h *blockedRecoveryHandoff) {
1583 if got := len(h.local.Snapshot()); got != 2 {
1584 t.Fatalf("session = %d messages after cancel flush, want 2", got)
1585 }
1586 loaded, err := agent.LoadSession(h.first.recoveryPath)
1587 if err != nil {
1588 t.Fatalf("LoadSession recovery: %v", err)
1589 }
1590 if got := len(loaded.Messages); got != 2 {
1591 t.Fatalf("recovery transcript = %d messages after cancel flush, want 2", got)
1592 }
1593 },
1594 },
1595 }
1596 for _, tc := range cases {
1597 t.Run(tc.name, func(t *testing.T) {
1598 h := startBlockedRecoveryHandoff(t)
1599 done, wantPath := tc.run(t, h)
1600 select {
1601 case <-done:
1602 t.Fatal("session state moved while the recovery handoff was still in flight")
1603 case <-time.After(100 * time.Millisecond):
1604 }
1605 if tc.during != nil {
1606 tc.during(t, h)
1607 }
1608 close(h.release)
1609 if err := <-h.firstDone; err != nil {
1610 t.Fatalf("first Snapshot: %v", err)
1611 }
1612 select {
1613 case <-done:
1614 case <-time.After(10 * time.Second):
1615 t.Fatal("session state move did not finish after the handoff completed")
1616 }
1617 if got := h.c.SessionPath(); got != wantPath {
1618 t.Fatalf("controller session path = %q, want %q", got, wantPath)
1619 }
1620 matches, err := filepath.Glob(filepath.Join(h.dir, "*-recovery-*.jsonl"))
1621 if err != nil {
1622 t.Fatalf("glob recovery branches: %v", err)
1623 }
1624 recoveries := recoveryTranscriptPaths(matches)
1625 if len(recoveries) != 1 || recoveries[0] != h.first.recoveryPath {
1626 t.Fatalf("recovery branches = %v, want only %q", matches, h.first.recoveryPath)
1627 }
1628 if tc.verify != nil {
1629 tc.verify(t, h)
1630 }
1631 })
1632 }
1633 }
1634
1635 // TestSnapshotConflictAdoptionResetsRewriteBaseline guards the baseline
1636 // handoff on the adopt path: adopting a newer on-disk transcript installs a
1637 // freshly loaded session, and the replaced session's rewrite version must not
1638 // leak onto it. A leaked (higher) baseline would make the adopted session's
1639 // own compactions look already persisted, so the next autosave would take the
1640 // snapshot path, conflict, and fork a spurious recovery branch.
1641 func TestSnapshotConflictAdoptionResetsRewriteBaseline(t *testing.T) {
1642 dir := t.TempDir()
1643 path := filepath.Join(dir, "session.jsonl")
1644
1645 base := agent.NewSession("sys")
1646 base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1647 if err := base.Save(path); err != nil {
1648 t.Fatalf("Save base: %v", err)
1649 }
1650 stale, err := agent.LoadSession(path)
1651 if err != nil {
1652 t.Fatalf("LoadSession stale: %v", err)
1653 }
1654 other, err := agent.LoadSession(path)
1655 if err != nil {
1656 t.Fatalf("LoadSession other: %v", err)
1657 }
1658 other.Add(provider.Message{Role: provider.RoleAssistant, Content: "newer"})
1659 if err := other.SaveSnapshot(path); err != nil {
1660 t.Fatalf("SaveSnapshot other: %v", err)
1661 }
1662
1663 exec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
1664 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
1665 // Rewrites the stale controller never persisted before it noticed the
1666 // newer transcript; adoption must discard this counter with the session.
1667 for i := 0; i < 3; i++ {
1668 stale.IncrementRewrite()
1669 }
1670
1671 if err := c.Snapshot(); err != nil {
1672 t.Fatalf("Snapshot adopt: %v", err)
1673 }
1674 if got := c.SessionPath(); got != path {
1675 t.Fatalf("session path after adoption = %q, want original %q", got, path)
1676 }
1677 adopted := exec.Session()
1678 if adopted == stale {
1679 t.Fatal("expected adoption to replace the stale session")
1680 }
1681 if adopted.NeedsRewriteSave() {
1682 t.Fatal("adopted session should carry a persisted rewrite baseline; the stale session's counter must not leak onto it")
1683 }
1684
1685 // The adopted session's first compaction must persist in place.
1686 msgs := adopted.Snapshot()
1687 adopted.Replace([]provider.Message{
1688 msgs[0],
1689 {Role: provider.RoleUser, Content: "<compaction-summary>\nfirst -> newer\n</compaction-summary>"},
1690 })
1691 adopted.IncrementRewrite()
1692 if err := c.SnapshotActivity(); err != nil {
1693 t.Fatalf("SnapshotActivity after adopted compaction: %v", err)
1694 }
1695 if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1696 t.Fatalf("recovery branches after adopted compaction = %v err=%v, want none", matches, err)
1697 }
1698 reloaded, err := agent.LoadSession(path)
1699 if err != nil {
1700 t.Fatalf("LoadSession rewritten: %v", err)
1701 }
1702 if got := len(reloaded.Messages); got != 2 {
1703 t.Fatalf("message count after adopted compaction = %d, want 2: %+v", got, reloaded.Messages)
1704 }
1705 }
1706
1707 // TestConcurrentCompactionAndAutosaveNeverBranch drives the real shape of the
1708 // bug: a mid-turn autosave goroutine saving while the turn goroutine compacts.
1709 // Whatever the interleaving, an owned single-process session must never fork a
1710 // recovery branch — the decision/mark pipeline in snapshot() has to capture
1711 // the rewrite version it actually persisted, and retry as an owned rewrite
1712 // when a compaction slips in between.
1713 func TestConcurrentCompactionAndAutosaveNeverBranch(t *testing.T) {
1714 dir := t.TempDir()
1715 path := filepath.Join(dir, "session.jsonl")
1716 sess := agent.NewSession("sys")
1717 sess.Add(provider.Message{Role: provider.RoleUser, Content: "turn-0"})
1718 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
1719 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
1720 if err := c.Snapshot(); err != nil {
1721 t.Fatalf("initial snapshot: %v", err)
1722 }
1723
1724 stop := make(chan struct{})
1725 var wg sync.WaitGroup
1726 wg.Add(1)
1727 go func() {
1728 defer wg.Done()
1729 for {
1730 select {
1731 case <-stop:
1732 return
1733 default:
1734 // Errors surface as recovery branches, asserted below.
1735 _ = c.SnapshotActivity()
1736 }
1737 }
1738 }()
1739 for i := 1; i <= 40; i++ {
1740 sess.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn-%d", i)})
1741 if i%4 == 0 {
1742 msgs := sess.Snapshot()
1743 sess.Replace([]provider.Message{
1744 msgs[0],
1745 {Role: provider.RoleUser, Content: fmt.Sprintf("<compaction-summary>\nrounds through %d\n</compaction-summary>", i)},
1746 msgs[len(msgs)-1],
1747 })
1748 sess.IncrementRewrite()
1749 }
1750 }
1751 close(stop)
1752 wg.Wait()
1753 if err := c.SnapshotActivity(); err != nil {
1754 t.Fatalf("final snapshot: %v", err)
1755 }
1756
1757 if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1758 t.Fatalf("compaction racing autosave created recovery branches: %v err=%v", matches, err)
1759 }
1760 reloaded, err := agent.LoadSession(path)
1761 if err != nil {
1762 t.Fatalf("LoadSession final: %v", err)
1763 }
1764 if got, want := len(reloaded.Messages), sess.Len(); got != want {
1765 t.Fatalf("persisted %d messages, memory has %d", got, want)
1766 }
1767 }
1768
1769 func TestAdoptHistoryPreservesRewriteBaseline(t *testing.T) {
1770 dir := t.TempDir()
1771 path := filepath.Join(dir, "session.jsonl")
1772
1773 s := agent.NewSession("old sys")
1774 s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1775 s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
1776 s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
1777 s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
1778 if err := s.Save(path); err != nil {
1779 t.Fatalf("Save base: %v", err)
1780 }
1781
1782 loaded, err := agent.LoadSession(path)
1783 if err != nil {
1784 t.Fatalf("LoadSession: %v", err)
1785 }
1786 msgs := loaded.Snapshot()
1787 msgs[0].Content = "new sys"
1788
1789 exec := agent.New(nil, nil, agent.NewSession("new sys"), agent.Options{}, event.Discard)
1790 c := New(Options{Executor: exec, SessionDir: dir, Label: "test", DisableColdResumePrune: true})
1791 c.AdoptHistory(msgs, path)
1792 rewrite := exec.Session().Snapshot()
1793 rewrite[3].Content = "[elided tool result]"
1794 exec.Session().Replace(rewrite)
1795 if err := c.SnapshotRewrite(); err != nil {
1796 t.Fatalf("SnapshotRewrite adopted history: %v", err)
1797 }
1798
1799 if got := c.SessionPath(); got != path {
1800 t.Fatalf("SessionPath after adopted rewrite = %q, want %q", got, path)
1801 }
1802 reloaded, err := agent.LoadSession(path)
1803 if err != nil {
1804 t.Fatalf("LoadSession rewritten: %v", err)
1805 }
1806 if got := reloaded.Messages[0].Content; got != "new sys" {
1807 t.Fatalf("system prompt after rewrite = %q, want new sys", got)
1808 }
1809 if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
1810 t.Fatalf("tool result after rewrite = %q, want elided", got)
1811 }
1812 if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1813 t.Fatalf("recovery branches after adopted rewrite = %v err=%v, want none", matches, err)
1814 }
1815 }
1816
1817 func TestAdoptEmptyHistoryRestoresPersistedGoalState(t *testing.T) {
1818 dir := t.TempDir()
1819 path := filepath.Join(dir, "empty-session.jsonl")
1820 if err := agent.NewSession("").Save(path); err != nil {
1821 t.Fatalf("Save empty session: %v", err)
1822 }
1823
1824 oldExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
1825 old := New(Options{Executor: oldExec, SessionDir: dir, SessionPath: path, Label: "old"})
1826 old.SetGoal("preserve the zero-turn goal")
1827 old.stopGoal(GoalStatusBlocked)
1828
1829 newExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
1830 replacement := New(Options{Executor: newExec, SessionDir: dir, Label: "replacement", DisableColdResumePrune: true})
1831 replacement.AdoptHistory(nil, path)
1832
1833 if got := replacement.Goal(); got != "preserve the zero-turn goal" {
1834 t.Fatalf("Goal after empty-history adoption = %q", got)
1835 }
1836 if got := replacement.GoalStatus(); got != GoalStatusBlocked {
1837 t.Fatalf("GoalStatus after empty-history adoption = %q, want blocked", got)
1838 }
1839 }
1840
1841 func TestAdoptHistoryRejectsStaleCarriedHistoryBaseline(t *testing.T) {
1842 dir := t.TempDir()
1843 path := filepath.Join(dir, "session.jsonl")
1844
1845 current := agent.NewSession("sys")
1846 current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1847 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1848 current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
1849 current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk two"})
1850 if err := current.Save(path); err != nil {
1851 t.Fatalf("Save current: %v", err)
1852 }
1853
1854 stale := []provider.Message{
1855 {Role: provider.RoleSystem, Content: "sys"},
1856 {Role: provider.RoleUser, Content: "first"},
1857 {Role: provider.RoleAssistant, Content: "one"},
1858 }
1859 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
1860 c := New(Options{Executor: exec, SessionDir: dir, Label: "test", DisableColdResumePrune: true})
1861 c.AdoptHistory(stale, path)
1862 if err := c.SnapshotRewrite(); err != nil {
1863 t.Fatalf("SnapshotRewrite stale adopted history: %v", err)
1864 }
1865
1866 if got := c.SessionPath(); got != path {
1867 t.Fatalf("SessionPath after stale adopted rewrite = %q, want original path", got)
1868 }
1869 reloaded, err := agent.LoadSession(path)
1870 if err != nil {
1871 t.Fatalf("LoadSession original: %v", err)
1872 }
1873 if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "disk two" {
1874 t.Fatalf("original tail after stale adopted rewrite = %q, want disk two", got)
1875 }
1876 if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
1877 t.Fatalf("recovery branches after prefix stale adopted rewrite = %v err=%v, want none", matches, err)
1878 }
1879 }
1880
1881 func TestCancelFlushRejectsStaleControllerOverwrite(t *testing.T) {
1882 dir := t.TempDir()
1883 path := filepath.Join(dir, "session.jsonl")
1884
1885 staleSess := agent.NewSession("sys")
1886 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1887 staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1888 staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "partial"})
1889 staleExec := agent.New(nil, nil, staleSess, agent.Options{}, event.Discard)
1890 stale := New(Options{Executor: staleExec, SessionDir: dir, SessionPath: path, Label: "test"})
1891
1892 currentSess := agent.NewSession("sys")
1893 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1894 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
1895 currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
1896 currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "two"})
1897 currentExec := agent.New(nil, nil, currentSess, agent.Options{}, event.Discard)
1898 current := New(Options{Executor: currentExec, SessionDir: dir, SessionPath: path, Label: "test"})
1899 if err := current.SnapshotActivity(); err != nil {
1900 t.Fatalf("SnapshotActivity current: %v", err)
1901 }
1902
1903 stale.replaceSessionAfterCancel([]provider.Message{
1904 {Role: provider.RoleSystem, Content: "sys"},
1905 {Role: provider.RoleUser, Content: "first"},
1906 })
1907
1908 loaded, err := agent.LoadSession(path)
1909 if err != nil {
1910 t.Fatalf("LoadSession: %v", err)
1911 }
1912 if got := len(loaded.Messages); got != 5 {
1913 t.Fatalf("message count after stale cancel flush = %d, want 5", got)
1914 }
1915 if got := loaded.Messages[4].Content; got != "two" {
1916 t.Fatalf("last message after stale cancel flush = %q, want %q", got, "two")
1917 }
1918 }
1919
1920 func TestSnapshotActivityRefreshesSessionActivity(t *testing.T) {
1921 dir := t.TempDir()
1922 sess := agent.NewSession("sys")
1923 sess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
1924 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
1925 c := New(Options{Executor: exec, SessionDir: dir, Label: "test"})
1926 c.SetSessionPath(filepath.Join(dir, "session.jsonl"))
1927
1928 if err := c.SnapshotActivity(); err != nil {
1929 t.Fatal(err)
1930 }
1931 first, _, err := agent.LoadBranchMeta(c.SessionPath())
1932 if err != nil {
1933 t.Fatal(err)
1934 }
1935
1936 time.Sleep(10 * time.Millisecond)
1937 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "activity"})
1938 if err := c.SnapshotActivity(); err != nil {
1939 t.Fatal(err)
1940 }
1941 second, _, err := agent.LoadBranchMeta(c.SessionPath())
1942 if err != nil {
1943 t.Fatal(err)
1944 }
1945 if !second.UpdatedAt.After(first.UpdatedAt) {
1946 t.Fatalf("SnapshotActivity did not refresh activity: first=%s second=%s", first.UpdatedAt, second.UpdatedAt)
1947 }
1948 }
1949
1950 func TestSnapshotActivitySavesTranscriptBeforeModelMeta(t *testing.T) {
1951 dir := t.TempDir()
1952 path := filepath.Join(dir, "session.jsonl")
1953 sess := agent.NewSession("sys")
1954 sess.Add(provider.Message{Role: provider.RoleUser, Content: "must persist"})
1955 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
1956 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test", ModelRef: "provider/model-a"})
1957 if err := os.MkdirAll(dir, 0o755); err != nil {
1958 t.Fatal(err)
1959 }
1960 if err := os.WriteFile(agent.BranchMetaPath(path), []byte("{bad json"), 0o644); err != nil {
1961 t.Fatal(err)
1962 }
1963
1964 if err := c.SnapshotActivity(); err == nil {
1965 t.Fatal("SnapshotActivity should report malformed branch metadata")
1966 }
1967 loaded, err := agent.LoadSession(path)
1968 if err != nil {
1969 t.Fatalf("transcript was not saved before metadata error: %v", err)
1970 }
1971 if len(loaded.Messages) == 0 || loaded.Messages[len(loaded.Messages)-1].Content != "must persist" {
1972 t.Fatalf("saved transcript = %+v, want persisted user message", loaded.Messages)
1973 }
1974 }
1975
1976 func TestNewSessionStartsFreshContextAndSavesTranscript(t *testing.T) {
1977 dir := t.TempDir()
1978 sess := agent.NewSession("sys")
1979 sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"})
1980 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
1981 path := filepath.Join(dir, "session.jsonl")
1982 c := New(Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test"})
1983
1984 if err := c.NewSession(); err != nil {
1985 t.Fatal(err)
1986 }
1987 if c.SessionPath() == path {
1988 t.Fatal("/new did not rotate to a fresh session path")
1989 }
1990 loaded, err := agent.LoadSession(path)
1991 if err != nil {
1992 t.Fatal(err)
1993 }
1994 if len(loaded.Messages) != 2 || loaded.Messages[1].Content != "old context" {
1995 t.Fatalf("previous transcript was not saved: %+v", loaded.Messages)
1996 }
1997 current := exec.Session().Snapshot()
1998 if len(current) != 1 || current[0].Role != provider.RoleSystem || current[0].Content != "sys" {
1999 t.Fatalf("fresh context = %+v, want only system prompt", current)
2000 }
2001 }
2002
2003 func TestSnapshotConflictLogAttrsCarryRevisionLedger(t *testing.T) {
2004 conflict := &agent.SessionSnapshotConflictError{
2005 Path: "/tmp/session.jsonl",
2006 Kind: agent.SessionSnapshotConflictDiverged,
2007 ExistingMessages: 7,
2008 SnapshotMessages: 5,
2009 BaseRevision: 3,
2010 DiskRevision: 9,
2011 }
2012 attrs := snapshotConflictLogAttrs(fmt.Errorf("save: %w", conflict), "/tmp/session.jsonl", "rewrite")
2013 got := map[string]any{}
2014 for i := 0; i+1 < len(attrs); i += 2 {
2015 key, ok := attrs[i].(string)
2016 if !ok {
2017 t.Fatalf("attr key %v is not a string", attrs[i])
2018 }
2019 got[key] = attrs[i+1]
2020 }
2021 if got["mode"] != "rewrite" || got["kind"] != "diverged" {
2022 t.Fatalf("attrs = %v, want mode=rewrite kind=diverged", got)
2023 }
2024 if got["base_revision"] != int64(3) || got["disk_revision"] != int64(9) {
2025 t.Fatalf("attrs = %v, want base_revision=3 disk_revision=9", got)
2026 }
2027 if got["disk_messages"] != 7 || got["snapshot_messages"] != 5 {
2028 t.Fatalf("attrs = %v, want disk_messages=7 snapshot_messages=5", got)
2029 }
2030
2031 // A conflict error without the typed detail still logs path and mode.
2032 plain := snapshotConflictLogAttrs(agent.ErrSessionSnapshotConflict, "/tmp/session.jsonl", "snapshot")
2033 if len(plain) != 4 {
2034 t.Fatalf("plain attrs = %v, want only path and mode", plain)
2035 }
2036 }
2037
2038 type noticeSink struct {
2039 mu sync.Mutex
2040 events []event.Event
2041 }
2042
2043 func TestSessionRecoveryNoticesAreOperatorScoped(t *testing.T) {
2044 for _, code := range []string{
2045 event.NoticeCodeSessionRecoveryForked,
2046 event.NoticeCodeSessionRecoveryAdopted,
2047 event.NoticeCodeSessionRecoveryAdoptedCovered,
2048 event.NoticeCodeSessionRecoveryDepthCap,
2049 event.NoticeCodeSessionShutdownRecoveryForked,
2050 } {
2051 notice := sessionRecoveryNotice(code, "maintenance")
2052 if notice.Kind != event.Notice || notice.Level != event.LevelWarn ||
2053 notice.Audience != event.NoticeAudienceOperator || notice.Code != code {
2054 t.Fatalf("session recovery notice %q = %+v, want typed operator warning", code, notice)
2055 }
2056 }
2057 }
2058
2059 func (s *noticeSink) Emit(e event.Event) {
2060 s.mu.Lock()
2061 s.events = append(s.events, e)
2062 s.mu.Unlock()
2063 }
2064
2065 func (s *noticeSink) notices() []string {
2066 s.mu.Lock()
2067 defer s.mu.Unlock()
2068 var out []string
2069 for _, e := range s.events {
2070 if e.Kind == event.Notice {
2071 out = append(out, e.Text)
2072 }
2073 }
2074 return out
2075 }
2076
2077 func (s *noticeSink) lastNotice() (event.Event, bool) {
2078 s.mu.Lock()
2079 defer s.mu.Unlock()
2080 for i := len(s.events) - 1; i >= 0; i-- {
2081 if s.events[i].Kind == event.Notice {
2082 return s.events[i], true
2083 }
2084 }
2085 return event.Event{}, false
2086 }
2087
2088 func TestSnapshotConflictAtRecoveryDepthCapForceSavesCurrentBranch(t *testing.T) {
2089 dir := t.TempDir()
2090 path := filepath.Join(dir, "session.jsonl")
2091 disk := agent.NewSession("sys")
2092 disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2093 disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
2094 disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
2095 if err := disk.Save(path); err != nil {
2096 t.Fatalf("Save disk: %v", err)
2097 }
2098 meta, ok, err := agent.LoadBranchMeta(path)
2099 if err != nil || !ok {
2100 t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
2101 }
2102 meta.Recovered = true
2103 meta.RecoveryDepth = agent.SessionRecoveryMaxDepth
2104 if err := agent.SaveBranchMeta(path, meta); err != nil {
2105 t.Fatalf("SaveBranchMeta: %v", err)
2106 }
2107
2108 stale := agent.NewSession("sys")
2109 stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2110 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
2111 stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
2112 exec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
2113 sink := &noticeSink{}
2114 c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test", Sink: sink})
2115 stale.IncrementRewrite()
2116
2117 if err := c.Snapshot(); err != nil {
2118 t.Fatalf("Snapshot: %v", err)
2119 }
2120 if got := c.SessionPath(); got != path {
2121 t.Fatalf("session path = %q, want unchanged %q (no new fork)", got, path)
2122 }
2123 forks, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
2124 if err != nil {
2125 t.Fatalf("glob: %v", err)
2126 }
2127 if len(forks) != 0 {
2128 t.Fatalf("depth cap still forked: %v", forks)
2129 }
2130 loaded, err := agent.LoadSession(path)
2131 if err != nil {
2132 t.Fatalf("LoadSession: %v", err)
2133 }
2134 if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "local second" {
2135 t.Fatalf("disk tail = %q, want force-saved local transcript", got)
2136 }
2137 notices := sink.notices()
2138 if len(notices) == 0 || !strings.Contains(notices[len(notices)-1], "saved the current conflict copy in place") {
2139 t.Fatalf("notices = %v, want depth-cap notice", notices)
2140 }
2141 notice, ok := sink.lastNotice()
2142 if !ok || notice.Code != event.NoticeCodeSessionRecoveryDepthCap || notice.Audience != event.NoticeAudienceOperator {
2143 t.Fatalf("depth-cap notice = %+v, want typed operator recovery notice", notice)
2144 }
2145 if stale.NeedsRewriteSave() {
2146 t.Fatal("rewrite baseline not re-anchored by depth-cap force save")
2147 }
2148
2149 foreign := agent.NewSession("sys")
2150 foreign.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2151 foreign.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
2152 foreign.Add(provider.Message{Role: provider.RoleUser, Content: "foreign second"})
2153 if err := foreign.Save(path); err != nil {
2154 t.Fatalf("Save foreign: %v", err)
2155 }
2156 meta, ok, err = agent.LoadBranchMeta(path)
2157 if err != nil || !ok {
2158 t.Fatalf("LoadBranchMeta foreign ok=%v err=%v", ok, err)
2159 }
2160 meta.Recovered = true
2161 meta.RecoveryDepth = agent.SessionRecoveryMaxDepth
2162 if err := agent.SaveBranchMeta(path, meta); err != nil {
2163 t.Fatalf("SaveBranchMeta foreign: %v", err)
2164 }
2165 if err := c.Snapshot(); err != nil {
2166 t.Fatalf("repeated depth-cap Snapshot: %v", err)
2167 }
2168 if got := sink.notices(); len(got) != len(notices) {
2169 t.Fatalf("repeated depth-cap snapshot emitted duplicate notice: %v", got)
2170 }
2171
2172 // The force save re-anchored the baseline: the next snapshot must not
2173 // conflict again.
2174 stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
2175 if err := c.Snapshot(); err != nil {
2176 t.Fatalf("follow-up Snapshot: %v", err)
2177 }
2178 if got := sink.notices(); len(got) != len(notices) {
2179 t.Fatalf("follow-up snapshot emitted more notices: %v", got)
2180 }
2181 }
2182
2183 func TestNewSessionRefusesWhileTurnRunning(t *testing.T) {
2184 dir := t.TempDir()
2185 sess := agent.NewSession("sys")
2186 sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"})
2187 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
2188 path := filepath.Join(dir, "session.jsonl")
2189 c := New(Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test"})
2190
2191 c.mu.Lock()
2192 c.running = true
2193 c.mu.Unlock()
2194
2195 if err := c.NewSession(); err == nil {
2196 t.Fatal("NewSession while running = nil error, want refusal")
2197 }
2198 if got := c.SessionPath(); got != path {
2199 t.Fatalf("session path = %q, want unrotated %q", got, path)
2200 }
2201 if snap := exec.Session().Snapshot(); len(snap) != 2 {
2202 t.Fatalf("running session was reset out from under the turn: %+v", snap)
2203 }
2204
2205 c.mu.Lock()
2206 c.running = false
2207 c.mu.Unlock()
2208 if err := c.NewSession(); err != nil {
2209 t.Fatalf("NewSession after the turn stopped: %v", err)
2210 }
2211 if c.SessionPath() == path {
2212 t.Fatal("session path did not rotate once the turn stopped")
2213 }
2214 }
2215
2216 // TestNewSessionRefusesTurnStartedDuringSnapshot forces the TOCTOU interleaving
2217 // the running guard alone missed: a turn starts while NewSession is mid-Snapshot
2218 // (running was false at the entry check), and must be refused so the executor
2219 // session is not swapped out from under a live run loop.
2220 func TestNewSessionRefusesTurnStartedDuringSnapshot(t *testing.T) {
2221 dir := t.TempDir()
2222 path := filepath.Join(dir, "session.jsonl")
2223
2224 // A diverged on-disk transcript makes Snapshot enter the recovery callback,
2225 // where the test parks NewSession mid-rotation.
2226 diskSess := agent.NewSession("sys")
2227 diskSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2228 diskSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
2229 if err := diskSess.Save(path); err != nil {
2230 t.Fatalf("Save disk: %v", err)
2231 }
2232 localSess := agent.NewSession("sys")
2233 localSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
2234 localSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
2235 localExec := agent.New(nil, nil, localSess, agent.Options{}, event.Discard)
2236
2237 entered := make(chan struct{}, 1)
2238 release := make(chan struct{})
2239 c := New(Options{
2240 Executor: localExec,
2241 SystemPrompt: "sys",
2242 SessionDir: dir,
2243 SessionPath: path,
2244 Label: "test",
2245 OnSessionRecovered: func(SessionRecoveryInfo) error {
2246 entered <- struct{}{}
2247 <-release
2248 return nil
2249 },
2250 })
2251
2252 newSessionDone := make(chan error, 1)
2253 go func() { newSessionDone <- c.NewSession() }()
2254
2255 // NewSession is now parked inside Snapshot, still holding the rotation gate.
2256 <-entered
2257
2258 // A turn tries to start in exactly the window the bare running check left
2259 // open. It must be refused rather than flip running=true and read the
2260 // session NewSession is about to replace.
2261 if err := c.RunTurn(context.Background(), "hello"); !errors.Is(err, ErrTurnRunning) {
2262 close(release)
2263 <-newSessionDone
2264 t.Fatalf("RunTurn during rotation = %v, want ErrTurnRunning", err)
2265 }
2266 if c.Running() {
2267 close(release)
2268 <-newSessionDone
2269 t.Fatal("RunTurn set running=true during a rotation")
2270 }
2271 // The live session must be untouched while the refused turn could have read
2272 // it: NewSession has not swapped yet (still parked before the swap).
2273 if snap := localExec.Session().Snapshot(); len(snap) != 3 {
2274 close(release)
2275 <-newSessionDone
2276 t.Fatalf("session mutated during rotation window: %+v", snap)
2277 }
2278
2279 close(release)
2280 if err := <-newSessionDone; err != nil {
2281 t.Fatalf("NewSession: %v", err)
2282 }
2283 // Rotation completed: a fresh session with only the system prompt, on a new
2284 // path, and a turn may start again.
2285 if c.SessionPath() == path {
2286 t.Fatal("session path did not rotate")
2287 }
2288 if snap := localExec.Session().Snapshot(); len(snap) != 1 || snap[0].Role != provider.RoleSystem {
2289 t.Fatalf("post-rotation session = %+v, want only system prompt", snap)
2290 }
2291 if c.Running() {
2292 t.Fatal("rotation gate leaked: controller still marked running")
2293 }
2294 }
2295
2296 // TestSessionMutationsRefuseWhileRotating proves every session-mutating entry
2297 // point is wired to the same rotation gate: while a rotation is in progress
2298 // (c.rotating held), each refuses instead of swapping/rewriting the live
2299 // session, and a turn cannot start either. This is the TOCTOU class the bare
2300 // Running() checks left open — a mutation slipping in mid-rotation.
2301 func TestSessionMutationsRefuseWhileRotating(t *testing.T) {
2302 dir := t.TempDir()
2303 path := filepath.Join(dir, "session.jsonl")
2304 sess := agent.NewSession("sys")
2305 sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
2306 sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "there"})
2307 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
2308 c := New(Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test"})
2309
2310 // Simulate a rotation already in progress (as NewSession/ClearSession hold
2311 // it across their snapshot-then-swap window).
2312 if err := c.beginRotation(); err != nil {
2313 t.Fatalf("beginRotation: %v", err)
2314 }
2315
2316 if err := c.NewSession(); !errors.Is(err, errRotationInProgress) {
2317 t.Fatalf("NewSession while rotating = %v, want errRotationInProgress", err)
2318 }
2319 if err := c.ClearSession(); !errors.Is(err, errRotationInProgress) {
2320 t.Fatalf("ClearSession while rotating = %v, want errRotationInProgress", err)
2321 }
2322 if _, err := c.Branch("x"); !errors.Is(err, errRotationInProgress) {
2323 t.Fatalf("Branch while rotating = %v, want errRotationInProgress", err)
2324 }
2325 if _, err := c.ForkNamed(1, "x"); !errors.Is(err, errRotationInProgress) {
2326 t.Fatalf("ForkNamed while rotating = %v, want errRotationInProgress", err)
2327 }
2328 if _, err := c.SwitchBranch("x"); !errors.Is(err, errRotationInProgress) {
2329 t.Fatalf("SwitchBranch while rotating = %v, want errRotationInProgress", err)
2330 }
2331 if err := c.Compact(context.Background(), ""); !errors.Is(err, errRotationInProgress) {
2332 t.Fatalf("Compact while rotating = %v, want errRotationInProgress", err)
2333 }
2334 if err := c.Rewind(0, RewindConversation); !errors.Is(err, errRotationInProgress) {
2335 t.Fatalf("Rewind while rotating = %v, want errRotationInProgress", err)
2336 }
2337 if err := c.SummarizeFrom(context.Background(), 1); !errors.Is(err, errRotationInProgress) {
2338 t.Fatalf("SummarizeFrom while rotating = %v, want errRotationInProgress", err)
2339 }
2340 if err := c.SummarizeUpTo(context.Background(), 1); !errors.Is(err, errRotationInProgress) {
2341 t.Fatalf("SummarizeUpTo while rotating = %v, want errRotationInProgress", err)
2342 }
2343 // A turn must not start while a rotation holds the gate.
2344 if err := c.RunTurn(context.Background(), "hello"); !errors.Is(err, ErrTurnRunning) {
2345 t.Fatalf("RunTurn while rotating = %v, want ErrTurnRunning", err)
2346 }
2347 // The live session was never touched by any refused mutation.
2348 if snap := exec.Session().Snapshot(); len(snap) != 3 {
2349 t.Fatalf("session mutated during rotation = %+v, want untouched", snap)
2350 }
2351
2352 c.endRotation()
2353
2354 // Conversely: while a turn runs, every mutation is refused with its own
2355 // message and the gate cannot be claimed.
2356 c.mu.Lock()
2357 c.running = true
2358 c.mu.Unlock()
2359 if err := c.beginRotation(); !errors.Is(err, errTurnRunningRotation) {
2360 t.Fatalf("beginRotation while running = %v, want errTurnRunningRotation", err)
2361 }
2362 if err := c.Compact(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "cannot compact while a turn is running") {
2363 t.Fatalf("Compact while running = %v, want 'cannot compact' message", err)
2364 }
2365 if err := c.Rewind(0, RewindConversation); err == nil || !strings.Contains(err.Error(), "cannot rewind while a turn is running") {
2366 t.Fatalf("Rewind while running = %v, want 'cannot rewind' message", err)
2367 }
2368 if err := c.SummarizeFrom(context.Background(), 1); err == nil || !strings.Contains(err.Error(), "cannot summarize while a turn is running") {
2369 t.Fatalf("SummarizeFrom while running = %v, want 'cannot summarize' message", err)
2370 }
2371 }
2372
2373 func TestNewSessionQueuesSessionStartHookContext(t *testing.T) {
2374 dir := t.TempDir()
2375 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
2376 path := filepath.Join(dir, "session.jsonl")
2377 hooks := hook.NewRunner([]hook.ResolvedHook{{
2378 HookConfig: hook.HookConfig{Command: "session-start"},
2379 Event: hook.SessionStart,
2380 }}, dir, func(context.Context, hook.SpawnInput) hook.SpawnResult {
2381 return hook.SpawnResult{ExitCode: 0, Stdout: "new session context"}
2382 }, nil)
2383 c := New(Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test", Hooks: hooks})
2384
2385 if err := c.NewSession(); err != nil {
2386 t.Fatalf("NewSession: %v", err)
2387 }
2388 got := c.Compose("next")
2389 if !strings.Contains(got, `<hook-context event="SessionStart">`) || !strings.Contains(got, "new session context") || !strings.HasSuffix(got, "next") {
2390 t.Fatalf("new session did not queue SessionStart hook context: %q", got)
2391 }
2392 }
2393
2394 func TestNewSessionResetsTwoModelPlannerContext(t *testing.T) {
2395 dir := t.TempDir()
2396 planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
2397 textTurn("OLD PLAN: inspect alpha.go"),
2398 textTurn("NEW PLAN: inspect beta.go"),
2399 }}
2400 execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
2401 textTurn("old done"),
2402 textTurn("new done"),
2403 }}
2404 exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
2405 plannerSess := agent.NewSession("planner sys")
2406 coord := agent.NewCoordinator(planner, plannerSess, nil, tool.NewRegistry(), agent.Options{}, exec, 0, event.Discard, nil)
2407 path := filepath.Join(dir, "session.jsonl")
2408 c := New(Options{Runner: coord, Executor: exec, SystemPrompt: "exec sys", SessionDir: dir, SessionPath: path, Label: "test"})
2409
2410 if err := c.Run(context.Background(), "old task alpha"); err != nil {
2411 t.Fatal(err)
2412 }
2413 if err := c.NewSession(); err != nil {
2414 t.Fatal(err)
2415 }
2416 if err := c.Run(context.Background(), "new task beta"); err != nil {
2417 t.Fatal(err)
2418 }
2419
2420 if len(planner.requests) != 2 {
2421 t.Fatalf("planner requests = %d, want 2", len(planner.requests))
2422 }
2423 second := requestMessagesText(planner.requests[1].Messages)
2424 if strings.Contains(second, "old task alpha") || strings.Contains(second, "OLD PLAN") {
2425 t.Fatalf("new planner request leaked previous session context:\n%s", second)
2426 }
2427 if !strings.Contains(second, "new task beta") {
2428 t.Fatalf("new planner request missing current task:\n%s", second)
2429 }
2430 }
2431
2432 func TestTwoModelPlannerApprovalUsesHostGate(t *testing.T) {
2433 dir := t.TempDir()
2434 planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
2435 textTurn("Plan:\n1. Edit main.go\n\n是否批准这个方案?"),
2436 }}
2437 execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
2438 textTurn("approved execution complete"),
2439 }}
2440 exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
2441 coord := agent.NewCoordinator(planner, agent.NewSession("planner sys"), nil, tool.NewRegistry(), agent.Options{}, exec, 0, event.Discard, nil)
2442
2443 ids := make(chan string, 1)
2444 var prompts int
2445 c := New(Options{
2446 Runner: coord,
2447 Executor: exec,
2448 SystemPrompt: "exec sys",
2449 SessionDir: dir,
2450 SessionPath: filepath.Join(dir, "session.jsonl"),
2451 Label: "test",
2452 Sink: event.FuncSink(func(e event.Event) {
2453 if e.Kind != event.ApprovalRequest {
2454 return
2455 }
2456 prompts++
2457 if e.Approval.Tool != planApprovalTool {
2458 t.Errorf("approval tool = %q, want %q", e.Approval.Tool, planApprovalTool)
2459 }
2460 if !strings.Contains(e.Approval.Reason, "Planner requested") {
2461 t.Errorf("approval reason = %q, want planner source", e.Approval.Reason)
2462 }
2463 ids <- e.Approval.ID
2464 }),
2465 })
2466 c.EnableInteractiveApproval()
2467
2468 done := make(chan error, 1)
2469 go func() {
2470 done <- c.Run(context.Background(), "fix the planner approval bug")
2471 }()
2472 id := waitApprovalID(t, ids)
2473 if got := len(execProv.requests); got != 0 {
2474 t.Fatalf("executor requests before approval = %d, want 0", got)
2475 }
2476 c.Approve(id, true, false, false)
2477 select {
2478 case err := <-done:
2479 if err != nil {
2480 t.Fatalf("Run: %v", err)
2481 }
2482 case <-time.After(30 * time.Second):
2483 t.Fatal("approved two-model turn did not finish")
2484 }
2485 if prompts != 1 {
2486 t.Fatalf("approval prompts = %d, want 1", prompts)
2487 }
2488 if got := len(execProv.requests); got == 0 {
2489 t.Fatal("executor did not run after approval")
2490 }
2491 reqText := requestMessagesText(execProv.requests[0].Messages)
2492 if !strings.Contains(reqText, "Reasonix executor handoff") || !strings.Contains(reqText, "Edit main.go") {
2493 t.Fatalf("approved executor request missing planner handoff:\n%s", reqText)
2494 }
2495 }
2496
2497 func TestTwoModelPlannerUserDecisionUsesAskGate(t *testing.T) {
2498 dir := t.TempDir()
2499 planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
2500 textTurn("需要用户选择方案:\n方案一:小改当前逻辑\n方案二:重构控制流\n请选择哪个方案。"),
2501 }}
2502 execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
2503 textTurn("selected execution complete"),
2504 }}
2505 exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
2506 coord := agent.NewCoordinator(planner, agent.NewSession("planner sys"), nil, tool.NewRegistry(), agent.Options{}, exec, 0, event.Discard, nil)
2507
2508 asks := make(chan event.Ask, 1)
2509 c := New(Options{
2510 Runner: coord,
2511 Executor: exec,
2512 SystemPrompt: "exec sys",
2513 SessionDir: dir,
2514 SessionPath: filepath.Join(dir, "session.jsonl"),
2515 Label: "test",
2516 Sink: event.FuncSink(func(e event.Event) {
2517 if e.Kind == event.AskRequest {
2518 asks <- e.Ask
2519 }
2520 }),
2521 })
2522 c.EnableInteractiveApproval()
2523
2524 done := make(chan error, 1)
2525 go func() {
2526 done <- c.Run(context.Background(), "fix the planner decision bug")
2527 }()
2528 var ask event.Ask
2529 select {
2530 case ask = <-asks:
2531 case <-time.After(30 * time.Second):
2532 t.Fatal("AskRequest was not emitted")
2533 }
2534 if got := len(execProv.requests); got != 0 {
2535 t.Fatalf("executor requests before user decision = %d, want 0", got)
2536 }
2537 if len(ask.Questions) != 1 || ask.Questions[0].ID != "planner_user_decision" {
2538 t.Fatalf("ask questions = %+v, want planner decision question", ask.Questions)
2539 }
2540 c.AnswerQuestion(ask.ID, []event.AskAnswer{{QuestionID: "planner_user_decision", Selected: []string{"方案二:重构控制流"}}})
2541 select {
2542 case err := <-done:
2543 if err != nil {
2544 t.Fatalf("Run: %v", err)
2545 }
2546 case <-time.After(30 * time.Second):
2547 t.Fatal("answered two-model turn did not finish")
2548 }
2549 if got := len(execProv.requests); got == 0 {
2550 t.Fatal("executor did not run after user decision")
2551 }
2552 reqText := requestMessagesText(execProv.requests[0].Messages)
2553 if !strings.Contains(reqText, "Host user answer to planner question") || !strings.Contains(reqText, "方案二") {
2554 t.Fatalf("executor request missing host user answer:\n%s", reqText)
2555 }
2556 }
2557
2558 func TestResumeResetsTwoModelPlannerContext(t *testing.T) {
2559 dir := t.TempDir()
2560 planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
2561 textTurn("OLD PLAN: inspect alpha.go"),
2562 textTurn("RESUMED PLAN: inspect gamma.go"),
2563 }}
2564 execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
2565 textTurn("old done"),
2566 textTurn("resumed done"),
2567 }}
2568 exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
2569 plannerSess := agent.NewSession("planner sys")
2570 coord := agent.NewCoordinator(planner, plannerSess, nil, tool.NewRegistry(), agent.Options{}, exec, 0, event.Discard, nil)
2571 c := New(Options{Runner: coord, Executor: exec, SystemPrompt: "exec sys", SessionDir: dir, SessionPath: filepath.Join(dir, "old.jsonl"), Label: "test"})
2572
2573 if err := c.Run(context.Background(), "old task alpha"); err != nil {
2574 t.Fatal(err)
2575 }
2576 resumed := agent.NewSession("exec sys")
2577 resumed.Add(provider.Message{Role: provider.RoleUser, Content: "saved task gamma"})
2578 c.Resume(resumed, filepath.Join(dir, "resumed.jsonl"))
2579 if err := c.Run(context.Background(), "continue gamma"); err != nil {
2580 t.Fatal(err)
2581 }
2582
2583 if len(planner.requests) != 2 {
2584 t.Fatalf("planner requests = %d, want 2", len(planner.requests))
2585 }
2586 second := requestMessagesText(planner.requests[1].Messages)
2587 if strings.Contains(second, "old task alpha") || strings.Contains(second, "OLD PLAN") {
2588 t.Fatalf("resumed planner request leaked previous session context:\n%s", second)
2589 }
2590 if !strings.Contains(second, "continue gamma") {
2591 t.Fatalf("resumed planner request missing current task:\n%s", second)
2592 }
2593 }
2594
2595 func TestResetPlannerSessionClearsPlannerHistory(t *testing.T) {
2596 dir := t.TempDir()
2597 planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
2598 textTurn("FIRST PLAN: inspect alpha.go"),
2599 textTurn("SECOND PLAN: inspect beta.go"),
2600 }}
2601 execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
2602 textTurn("first done"),
2603 textTurn("second done"),
2604 }}
2605 exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
2606 plannerSess := agent.NewSession("planner sys")
2607 coord := agent.NewCoordinator(planner, plannerSess, nil, tool.NewRegistry(), agent.Options{}, exec, 0, event.Discard, nil)
2608 path := filepath.Join(dir, "session.jsonl")
2609 c := New(Options{Runner: coord, Executor: exec, SystemPrompt: "exec sys", SessionDir: dir, SessionPath: path, Label: "test"})
2610
2611 if err := c.Run(context.Background(), "first task"); err != nil {
2612 t.Fatal(err)
2613 }
2614 // Explicitly reset the planner session (simulates a tab switch).
2615 c.ResetPlannerSession()
2616 if err := c.Run(context.Background(), "second task"); err != nil {
2617 t.Fatal(err)
2618 }
2619
2620 if len(planner.requests) != 2 {
2621 t.Fatalf("planner requests = %d, want 2", len(planner.requests))
2622 }
2623 second := requestMessagesText(planner.requests[1].Messages)
2624 if strings.Contains(second, "first task") || strings.Contains(second, "FIRST PLAN") {
2625 t.Fatalf("planner request after reset leaked previous session context:\n%s", second)
2626 }
2627 if !strings.Contains(second, "second task") {
2628 t.Fatalf("planner request after reset missing current task:\n%s", second)
2629 }
2630 }
2631
2632 func TestTwoModelShortChoiceReplySkipsPlanner(t *testing.T) {
2633 dir := t.TempDir()
2634 planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
2635 textTurn("planner should not run for a context-dependent choice reply"),
2636 }}
2637 execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
2638 textTurn("selected option 1"),
2639 }}
2640 execSess := agent.NewSession("exec sys")
2641 execSess.Add(provider.Message{Role: provider.RoleUser, Content: "先给我两个执行方案"})
2642 execSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "两个执行方式可选:\n\n1. Subagent-Driven(推荐)\n2. 当前会话执行\n\n你选哪种?"})
2643 exec := agent.New(execProv, tool.NewRegistry(), execSess, agent.Options{}, event.Discard)
2644 coord := agent.NewCoordinator(planner, agent.NewSession("planner sys"), nil, tool.NewRegistry(), agent.Options{}, exec, 0, event.Discard, NewPlannerGate())
2645 c := New(Options{Runner: coord, Executor: exec, SystemPrompt: "exec sys", SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl"), Label: "test"})
2646
2647 if err := c.Run(context.Background(), "1"); err != nil {
2648 t.Fatal(err)
2649 }
2650
2651 if len(planner.requests) != 0 {
2652 t.Fatalf("planner requests = %d, want 0 for a short context-dependent choice reply", len(planner.requests))
2653 }
2654 if len(execProv.requests) != 1 {
2655 t.Fatalf("executor requests = %d, want 1", len(execProv.requests))
2656 }
2657 reqText := requestMessagesText(execProv.requests[0].Messages)
2658 if !strings.Contains(reqText, "1. Subagent-Driven") {
2659 t.Fatalf("executor request lost the previous assistant options:\n%s", reqText)
2660 }
2661 if strings.Contains(reqText, "Reasonix executor handoff") {
2662 t.Fatalf("short choice reply should not be wrapped as a planner handoff:\n%s", reqText)
2663 }
2664 if got := lastUserMessage(execProv.requests[0].Messages); got != "1" {
2665 t.Fatalf("executor last user = %q, want raw choice reply", got)
2666 }
2667 }
2668
2669 func TestSubmitClearDiscardsCurrentContextWithoutSavingTranscript(t *testing.T) {
2670 dir := t.TempDir()
2671 sess := agent.NewSession("sys")
2672 sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"})
2673 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
2674 path := filepath.Join(dir, "session.jsonl")
2675 cleared := make(chan struct{})
2676 sink := event.FuncSink(func(e event.Event) {
2677 if e.Kind == event.Notice && e.Text == "context cleared" {
2678 close(cleared)
2679 }
2680 })
2681 c := New(Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test", Sink: sink})
2682 if err := c.Snapshot(); err != nil {
2683 t.Fatal(err)
2684 }
2685 ckpt := ckptDir(path)
2686 if err := os.MkdirAll(ckpt, 0o755); err != nil {
2687 t.Fatal(err)
2688 }
2689 if err := os.WriteFile(filepath.Join(ckpt, "turn-0.json"), []byte("{}"), 0o644); err != nil {
2690 t.Fatal(err)
2691 }
2692
2693 c.submit("/clear", "", "")
2694 select {
2695 case <-cleared:
2696 case <-time.After(30 * time.Second):
2697 t.Fatal("/clear did not finish")
2698 }
2699 if c.SessionPath() == path {
2700 t.Fatal("/clear did not rotate to a fresh session path")
2701 }
2702 for _, p := range []string{path, agent.BranchMetaPath(path), ckpt} {
2703 if _, err := os.Stat(p); !os.IsNotExist(err) {
2704 t.Fatalf("discarded artifact %s still exists or stat failed with %v", p, err)
2705 }
2706 }
2707 if _, err := os.Stat(c.SessionPath()); !os.IsNotExist(err) {
2708 t.Fatalf("fresh empty session should not be saved yet; stat err=%v", err)
2709 }
2710 current := exec.Session().Snapshot()
2711 if len(current) != 1 || current[0].Role != provider.RoleSystem || current[0].Content != "sys" {
2712 t.Fatalf("cleared context = %+v, want only system prompt", current)
2713 }
2714 }
2715
2716 func TestDisconnectMCPServerRemovesLazyPlaceholder(t *testing.T) {
2717 reg := tool.NewRegistry()
2718 reg.Add(fakeControlTool{name: "mcp__mock__connect"})
2719 c := New(Options{Host: plugin.NewHost(), Registry: reg})
2720
2721 if ok := c.DisconnectMCPServer("mock"); !ok {
2722 t.Fatal("DisconnectMCPServer returned false for a registered lazy placeholder")
2723 }
2724 if _, found := reg.Get("mcp__mock__connect"); found {
2725 t.Fatalf("lazy placeholder still registered after disconnect; names=%v", reg.Names())
2726 }
2727 }
2728
2729 func TestRegisterMCPServerOnDemandDefersConnectionUntilFirstUse(t *testing.T) {
2730 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
2731 var requests atomic.Int32
2732 var initializes atomic.Int32
2733 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2734 requests.Add(1)
2735 var req struct {
2736 ID json.RawMessage `json:"id"`
2737 Method string `json:"method"`
2738 }
2739 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
2740 http.Error(w, "bad request", http.StatusBadRequest)
2741 return
2742 }
2743 if len(req.ID) == 0 || string(req.ID) == "null" {
2744 w.WriteHeader(http.StatusAccepted)
2745 return
2746 }
2747 var result any
2748 switch req.Method {
2749 case "initialize":
2750 initializes.Add(1)
2751 result = map[string]any{
2752 "protocolVersion": "2025-03-26",
2753 "serverInfo": map[string]any{"name": "on-demand", "version": "1"},
2754 }
2755 case "tools/list":
2756 result = map[string]any{"tools": []map[string]any{{
2757 "name": "echo",
2758 "description": "Echo a value.",
2759 "inputSchema": map[string]any{"type": "object"},
2760 }}}
2761 default:
2762 result = map[string]any{}
2763 }
2764 w.Header().Set("Content-Type", "application/json")
2765 _ = json.NewEncoder(w).Encode(map[string]any{
2766 "jsonrpc": "2.0",
2767 "id": req.ID,
2768 "result": result,
2769 })
2770 }))
2771 defer server.Close()
2772
2773 host := plugin.NewHost()
2774 defer host.Close()
2775 reg := tool.NewRegistry()
2776 ctrl := New(Options{Host: host, Registry: reg, PluginCtx: context.Background()})
2777 entry := config.PluginEntry{Name: "on-demand", Type: "http", URL: server.URL, Source: config.MCPSourceUserConfig}
2778 if _, err := ctrl.RegisterMCPServerOnDemand(entry); err != nil {
2779 t.Fatalf("RegisterMCPServerOnDemand: %v", err)
2780 }
2781 if got := requests.Load(); got != 0 {
2782 t.Fatalf("enable-time HTTP requests = %d, want zero", got)
2783 }
2784 connect, ok := reg.Get("mcp__on-demand__connect")
2785 if !ok {
2786 t.Fatalf("cache-miss connect stub missing; names=%v", reg.Names())
2787 }
2788 if _, err := connect.Execute(context.Background(), json.RawMessage(`{}`)); err == nil || !strings.Contains(err.Error(), "initializing on first use") {
2789 t.Fatalf("first-use connect result = %v, want initializing guidance", err)
2790 }
2791 deadline := time.Now().Add(5 * time.Second)
2792 for !host.HasClient("on-demand") && time.Now().Before(deadline) {
2793 time.Sleep(10 * time.Millisecond)
2794 }
2795 if !host.HasClient("on-demand") {
2796 t.Fatal("first tool use did not start the MCP connection")
2797 }
2798 if got := initializes.Load(); got != 1 {
2799 t.Fatalf("initialize calls = %d, want exactly one on-demand start", got)
2800 }
2801 }
2802
2803 func TestControllerMCPHotLifecycleUpdatesCapabilityRuntime(t *testing.T) {
2804 t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
2805 host := plugin.NewHost()
2806 defer host.Close()
2807 reg := tool.NewRegistry()
2808 runtime := agent.NewMCPCapabilityRuntime(context.Background(), host, nil, reg, nil)
2809 ctrl := New(Options{
2810 Host: host, Registry: reg, PluginCtx: context.Background(), CapabilityRuntime: runtime,
2811 })
2812 frontend := runtime.NewFrontend(nil, nil)
2813 entry := config.PluginEntry{
2814 Name: "hot", Type: "http", URL: "http://127.0.0.1:1", Source: config.MCPSourceUserConfig,
2815 }
2816
2817 if _, err := ctrl.RegisterMCPServerOnDemand(entry); err != nil {
2818 t.Fatalf("RegisterMCPServerOnDemand: %v", err)
2819 }
2820 listed, err := frontend.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
2821 if err != nil || !strings.Contains(listed, `"name": "hot"`) {
2822 t.Fatalf("hot add list = %q, %v", listed, err)
2823 }
2824
2825 if !ctrl.UnregisterMCPServerTools("hot") {
2826 t.Fatal("UnregisterMCPServerTools returned false")
2827 }
2828 listed, err = frontend.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
2829 if err != nil || !strings.Contains(listed, `"status": "disabled"`) {
2830 t.Fatalf("disabled list = %q, %v", listed, err)
2831 }
2832
2833 if _, err := ctrl.RegisterMCPServerOnDemand(entry); err != nil {
2834 t.Fatalf("re-enable RegisterMCPServerOnDemand: %v", err)
2835 }
2836 if !ctrl.DisconnectMCPServer("hot") {
2837 t.Fatal("DisconnectMCPServer returned false for runtime-only placeholder")
2838 }
2839 listed, err = frontend.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
2840 if err != nil || strings.Contains(listed, `"name": "hot"`) {
2841 t.Fatalf("runtime-only disconnect leaked list entry = %q, %v", listed, err)
2842 }
2843 }
2844
2845 func TestAddMCPServerAuthorizesExplicitUserAddBeforeConnecting(t *testing.T) {
2846 var configured plugin.Spec
2847 c := New(Options{
2848 WorkspaceRoot: "/workspace",
2849 MCPConfigureSpec: func(spec *plugin.Spec) { configured = *spec },
2850 })
2851
2852 if _, err := c.AddMCPServer(config.PluginEntry{Name: "user-added"}); err == nil {
2853 t.Fatal("AddMCPServer without a command unexpectedly succeeded")
2854 }
2855 if configured.ConfigSource != string(config.MCPSourceUserConfig) ||
2856 !configured.Authorized || configured.RequireLaunchApproval || configured.WorkspaceRoot != "/workspace" {
2857 t.Fatalf("configured spec = %+v, want user-authorized add-and-use policy", configured)
2858 }
2859 }
2860
2861 func TestAddMCPServerWritesGlobalConfigWithoutShadowingProject(t *testing.T) {
2862 isolateControlConfigHome(t)
2863 workspace := t.TempDir()
2864 projectPath := filepath.Join(workspace, "reasonix.toml")
2865 if err := os.WriteFile(projectPath, []byte(`
2866 [[plugins]]
2867 name = "project-only"
2868 command = "project-only"
2869 `), 0o644); err != nil {
2870 t.Fatal(err)
2871 }
2872 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2873 var req struct {
2874 ID json.RawMessage `json:"id"`
2875 Method string `json:"method"`
2876 }
2877 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
2878 http.Error(w, "bad request", http.StatusBadRequest)
2879 return
2880 }
2881 if len(req.ID) == 0 || string(req.ID) == "null" {
2882 w.WriteHeader(http.StatusAccepted)
2883 return
2884 }
2885 result := any(map[string]any{})
2886 switch req.Method {
2887 case "initialize":
2888 result = map[string]any{
2889 "protocolVersion": "2025-03-26",
2890 "serverInfo": map[string]any{"name": "global-docs", "version": "1"},
2891 }
2892 case "tools/list":
2893 result = map[string]any{"tools": []map[string]any{{
2894 "name": "search",
2895 "description": "Search documentation.",
2896 "inputSchema": map[string]any{"type": "object"},
2897 }}}
2898 }
2899 w.Header().Set("Content-Type", "application/json")
2900 _ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
2901 }))
2902 defer server.Close()
2903
2904 host := plugin.NewHost()
2905 defer host.Close()
2906 ctrl := New(Options{Host: host, Registry: tool.NewRegistry(), PluginCtx: context.Background(), WorkspaceRoot: workspace})
2907 if n, err := ctrl.AddMCPServer(config.PluginEntry{Name: "global-docs", Type: "http", URL: server.URL}); err != nil || n != 1 {
2908 t.Fatalf("AddMCPServer(global-docs) = (%d, %v), want one connected tool", n, err)
2909 }
2910 globalCfg := config.LoadForEdit(config.UserConfigPath())
2911 globalEntry, found := controlTestPluginByName(globalCfg.Plugins, "global-docs")
2912 if !found || globalEntry.URL != server.URL {
2913 t.Fatalf("global config entry = %+v, found=%v", globalEntry, found)
2914 }
2915 projectCfg := config.LoadForEdit(projectPath)
2916 if _, found := controlTestPluginByName(projectCfg.Plugins, "global-docs"); found {
2917 t.Fatalf("global install leaked into project config: %+v", projectCfg.Plugins)
2918 }
2919 if _, found := controlTestPluginByName(projectCfg.Plugins, "project-only"); !found {
2920 t.Fatalf("project config was not preserved: %+v", projectCfg.Plugins)
2921 }
2922 }
2923
2924 func TestAddMCPServerRejectsProjectNameCollision(t *testing.T) {
2925 isolateControlConfigHome(t)
2926 workspace := t.TempDir()
2927 if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(`
2928 [[plugins]]
2929 name = "shared"
2930 command = "project-shared"
2931 `), 0o644); err != nil {
2932 t.Fatal(err)
2933 }
2934 ctrl := New(Options{Host: plugin.NewHost(), WorkspaceRoot: workspace})
2935 defer ctrl.Close()
2936 if _, err := ctrl.AddMCPServer(config.PluginEntry{Name: "shared", Command: "global-shared"}); err == nil || !strings.Contains(err.Error(), "already configured") {
2937 t.Fatalf("AddMCPServer(shared) error = %v, want project collision", err)
2938 }
2939 if _, found := controlTestPluginByName(config.LoadForEdit(config.UserConfigPath()).Plugins, "shared"); found {
2940 t.Fatal("rejected project collision created a global shadow")
2941 }
2942 }
2943
2944 func TestConnectConfiguredProjectMCPIsTrustedByDefault(t *testing.T) {
2945 isolateControlConfigHome(t)
2946 workspace := t.TempDir()
2947 var requests atomic.Int32
2948 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
2949 requests.Add(1)
2950 var req struct {
2951 ID json.RawMessage `json:"id"`
2952 Method string `json:"method"`
2953 }
2954 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
2955 http.Error(w, "bad request", http.StatusBadRequest)
2956 return
2957 }
2958 if len(req.ID) == 0 || string(req.ID) == "null" {
2959 w.WriteHeader(http.StatusAccepted)
2960 return
2961 }
2962 result := any(map[string]any{})
2963 switch req.Method {
2964 case "initialize":
2965 result = map[string]any{
2966 "protocolVersion": "2025-03-26",
2967 "serverInfo": map[string]any{"name": "project-docs", "version": "1"},
2968 }
2969 case "tools/list":
2970 result = map[string]any{"tools": []map[string]any{{
2971 "name": "search",
2972 "description": "Search project documentation.",
2973 "inputSchema": map[string]any{"type": "object"},
2974 }}}
2975 }
2976 w.Header().Set("Content-Type", "application/json")
2977 _ = json.NewEncoder(w).Encode(map[string]any{
2978 "jsonrpc": "2.0",
2979 "id": req.ID,
2980 "result": result,
2981 })
2982 }))
2983 defer server.Close()
2984 if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(fmt.Sprintf(`
2985 [[plugins]]
2986 name = "project-docs"
2987 type = "http"
2988 url = %q
2989 `, server.URL)), 0o644); err != nil {
2990 t.Fatal(err)
2991 }
2992
2993 host := plugin.NewHost()
2994 defer host.Close()
2995 reg := tool.NewRegistry()
2996 var configured plugin.Spec
2997 ctrl := New(Options{
2998 Host: host,
2999 Registry: reg,
3000 PluginCtx: context.Background(),
3001 WorkspaceRoot: workspace,
3002 MCPConfigureSpec: func(spec *plugin.Spec) {
3003 configured = *spec
3004 },
3005 })
3006
3007 n, err := ctrl.ConnectConfiguredMCPServer("project-docs")
3008 if err != nil {
3009 t.Fatalf("ConnectConfiguredMCPServer: %v", err)
3010 }
3011 if n != 1 || requests.Load() == 0 {
3012 t.Fatalf("trusted project MCP = %d tools, %d requests; want 1 tool and a live connection", n, requests.Load())
3013 }
3014 if _, ok := reg.Get("mcp__project-docs__search"); !ok {
3015 t.Fatalf("project MCP tool missing; names=%v", reg.Names())
3016 }
3017 if !configured.Authorized || configured.RequireLaunchApproval || configured.Dir != workspace {
3018 t.Fatalf("project MCP spec = %+v, want trusted project-scoped runtime", configured)
3019 }
3020
3021 nextHost := plugin.NewHost()
3022 defer nextHost.Close()
3023 nextCtrl := New(Options{
3024 Host: nextHost,
3025 Registry: tool.NewRegistry(),
3026 PluginCtx: context.Background(),
3027 WorkspaceRoot: workspace,
3028 })
3029 if n, err := nextCtrl.ConnectConfiguredMCPServer("project-docs"); err != nil || n != 1 {
3030 t.Fatalf("subsequent project MCP connection = (%d, %v), want zero-confirmation trust", n, err)
3031 }
3032 }
3033
3034 func TestConnectMCPServerAppliesConfiguredCallTimeouts(t *testing.T) {
3035 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3036 var req struct {
3037 ID json.RawMessage `json:"id"`
3038 Method string `json:"method"`
3039 }
3040 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
3041 http.Error(w, "bad request", http.StatusBadRequest)
3042 return
3043 }
3044 if len(req.ID) == 0 || string(req.ID) == "null" {
3045 w.WriteHeader(http.StatusAccepted)
3046 return
3047 }
3048 var result any
3049 switch req.Method {
3050 case "initialize":
3051 result = map[string]any{
3052 "protocolVersion": "2025-03-26",
3053 "serverInfo": map[string]any{"name": "timeout-test", "version": "1"},
3054 }
3055 case "tools/list":
3056 result = map[string]any{"tools": []map[string]any{{
3057 "name": "slow",
3058 "description": "Wait until the caller cancels.",
3059 "inputSchema": map[string]any{"type": "object"},
3060 }}}
3061 case "tools/call":
3062 <-r.Context().Done()
3063 return
3064 default:
3065 result = map[string]any{}
3066 }
3067 w.Header().Set("Content-Type", "application/json")
3068 _ = json.NewEncoder(w).Encode(map[string]any{
3069 "jsonrpc": "2.0",
3070 "id": req.ID,
3071 "result": result,
3072 })
3073 }))
3074 defer server.Close()
3075
3076 tests := []struct {
3077 name string
3078 defaultTimeout time.Duration
3079 entry config.PluginEntry
3080 }{
3081 {
3082 name: "global default",
3083 defaultTimeout: time.Second,
3084 },
3085 {
3086 name: "server override",
3087 defaultTimeout: 10 * time.Second,
3088 entry: config.PluginEntry{CallTimeoutSeconds: 1},
3089 },
3090 {
3091 name: "tool override",
3092 defaultTimeout: 10 * time.Second,
3093 entry: config.PluginEntry{
3094 CallTimeoutSeconds: 10,
3095 ToolTimeoutSeconds: map[string]int{"slow": 1},
3096 },
3097 },
3098 }
3099 for i, tc := range tests {
3100 t.Run(tc.name, func(t *testing.T) {
3101 host := plugin.NewHost()
3102 defer host.Close()
3103 reg := tool.NewRegistry()
3104 ctrl := New(Options{
3105 Host: host,
3106 Registry: reg,
3107 MCPDefaultCallTimeout: tc.defaultTimeout,
3108 })
3109 entry := tc.entry
3110 entry.Name = fmt.Sprintf("timeout%d", i)
3111 entry.Type = "http"
3112 entry.URL = server.URL
3113 if _, err := ctrl.ConnectMCPServer(entry); err != nil {
3114 t.Fatalf("ConnectMCPServer: %v", err)
3115 }
3116 connected, ok := reg.Get("mcp__" + entry.Name + "__slow")
3117 if !ok {
3118 t.Fatalf("connected tool missing; names=%v", reg.Names())
3119 }
3120 started := time.Now()
3121 _, err := connected.Execute(context.Background(), json.RawMessage(`{}`))
3122 elapsed := time.Since(started)
3123 if !errors.Is(err, context.DeadlineExceeded) {
3124 t.Fatalf("slow tool error = %v, want deadline exceeded", err)
3125 }
3126 if elapsed < 750*time.Millisecond || elapsed > 3*time.Second {
3127 t.Fatalf("slow tool elapsed = %v, want configured 1s timeout", elapsed)
3128 }
3129 })
3130 }
3131 }
3132
3133 func TestUnregisterMCPServerToolsBlocksLateSharedHostSwap(t *testing.T) {
3134 reg := tool.NewRegistry()
3135 reg.Add(fakeControlTool{name: "mcp__mock__connect"})
3136 c := New(Options{Host: plugin.NewHost(), Registry: reg})
3137
3138 if ok := c.UnregisterMCPServerTools("mock"); !ok {
3139 t.Fatal("UnregisterMCPServerTools returned false")
3140 }
3141 reg.Add(fakeControlTool{name: "mcp__mock__echo"})
3142 if _, found := reg.Get("mcp__mock__echo"); found {
3143 t.Fatalf("late shared-host tool swap was accepted after unregister; names=%v", reg.Names())
3144 }
3145 reg.Add(fakeControlTool{name: "mcp__other__echo"})
3146 if _, found := reg.Get("mcp__other__echo"); !found {
3147 t.Fatalf("unregister blocked unrelated MCP tools; names=%v", reg.Names())
3148 }
3149 }
3150
3151 func TestRemoveMCPServerRemovesUnconnectedLazyPlaceholder(t *testing.T) {
3152 isolateControlConfigHome(t)
3153 dir := t.TempDir()
3154 home := t.TempDir()
3155 t.Setenv("HOME", home)
3156 t.Setenv("USERPROFILE", home)
3157 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
3158 t.Setenv("AppData", filepath.Join(home, "AppData", "Roaming"))
3159 t.Chdir(dir)
3160 if err := os.WriteFile("reasonix.toml", []byte(`
3161 [[plugins]]
3162 name = "mock"
3163 command = "mock-mcp"
3164 tier = "lazy"
3165 `), 0o644); err != nil {
3166 t.Fatalf("write config: %v", err)
3167 }
3168
3169 reg := tool.NewRegistry()
3170 reg.Add(fakeControlTool{name: "mcp__mock__connect"})
3171 host := plugin.NewHost()
3172 defer host.Close()
3173 spec := plugin.Spec{Name: "mock", Command: "mock-mcp", Authorized: true}
3174 runtime := agent.NewMCPCapabilityRuntime(context.Background(), host, []plugin.Spec{spec}, reg, nil)
3175 runtime.ConfigureServers([]config.PluginEntry{{Name: "mock", Command: "mock-mcp"}}, []plugin.Spec{spec}, map[string]bool{"mock": true})
3176 c := New(Options{Host: host, Registry: reg, CapabilityRuntime: runtime})
3177
3178 disconnected, err := c.RemoveMCPServer("mock")
3179 if err != nil {
3180 t.Fatalf("RemoveMCPServer: %v", err)
3181 }
3182 if disconnected {
3183 t.Fatal("RemoveMCPServer reported a live disconnect for an unconnected lazy placeholder")
3184 }
3185 if _, found := reg.Get("mcp__mock__connect"); found {
3186 t.Fatalf("lazy placeholder still registered after remove; names=%v", reg.Names())
3187 }
3188 if names := c.ConfiguredMCPNames(); len(names) != 0 {
3189 t.Fatalf("ConfiguredMCPNames() = %v, want empty after remove", names)
3190 }
3191 proxy := runtime.NewFrontend(nil, nil)
3192 listed, listErr := proxy.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
3193 if listErr != nil || strings.Contains(listed, `"name": "mock"`) {
3194 t.Fatalf("removed server leaked through capability list = %q, %v", listed, listErr)
3195 }
3196 }
3197
3198 func TestConfiguredMCPNamesUseControllerWorkspaceInsteadOfProcessCWD(t *testing.T) {
3199 isolateControlConfigHome(t)
3200 workspace := t.TempDir()
3201 other := t.TempDir()
3202 t.Chdir(other)
3203 if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(`
3204 [[plugins]]
3205 name = "workspace-mcp"
3206 command = "workspace-mcp"
3207 `), 0o644); err != nil {
3208 t.Fatal(err)
3209 }
3210
3211 c := New(Options{WorkspaceRoot: workspace, Host: plugin.NewHost()})
3212 defer c.Close()
3213 if got := c.ConfiguredMCPNames(); !reflect.DeepEqual(got, []string{"workspace-mcp"}) {
3214 t.Fatalf("ConfiguredMCPNames() = %v, want workspace-mcp from %s", got, workspace)
3215 }
3216 if got := c.DisconnectedMCPNames(); !reflect.DeepEqual(got, []string{"workspace-mcp"}) {
3217 t.Fatalf("DisconnectedMCPNames() = %v, want workspace-mcp from %s", got, workspace)
3218 }
3219 }
3220
3221 func TestRemoveMCPServerKeepsRuntimeOnlyToolsWhenPersistenceRemovalFails(t *testing.T) {
3222 isolateControlConfigHome(t)
3223 reg := tool.NewRegistry()
3224 reg.Add(fakeControlTool{name: "mcp__runtime_only__echo"})
3225 c := New(Options{Host: plugin.NewHost(), Registry: reg})
3226
3227 if disconnected, err := c.RemoveMCPServer("runtime_only"); err == nil || disconnected || !strings.Contains(err.Error(), "no removable MCP server") {
3228 t.Fatalf("RemoveMCPServer(runtime_only) = (%v, %v)", disconnected, err)
3229 }
3230 if _, found := reg.Get("mcp__runtime_only__echo"); !found {
3231 t.Fatalf("runtime-only tool was removed despite failed persistence; names=%v", reg.Names())
3232 }
3233 }
3234
3235 func TestRemoveMCPServerRejectsPluginManagedTools(t *testing.T) {
3236 home := isolateControlConfigHome(t)
3237 reasonixHome := filepath.Join(home, ".reasonix")
3238 t.Setenv("REASONIX_HOME", reasonixHome)
3239 root := filepath.Join(reasonixHome, "plugins", "superpowers")
3240 if err := os.MkdirAll(root, 0o755); err != nil {
3241 t.Fatal(err)
3242 }
3243 if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(`{
3244 "name": "superpowers",
3245 "version": "1.0.0",
3246 "mcpServers": {
3247 "helper": { "command": "bin/helper" }
3248 }
3249 }`), 0o644); err != nil {
3250 t.Fatal(err)
3251 }
3252 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
3253 Name: "superpowers",
3254 Root: "plugins/superpowers",
3255 Version: "1.0.0",
3256 ManifestKind: "reasonix",
3257 Enabled: true,
3258 }); err != nil {
3259 t.Fatal(err)
3260 }
3261
3262 reg := tool.NewRegistry()
3263 reg.Add(fakeControlTool{name: "mcp__helper__echo"})
3264 c := New(Options{Host: plugin.NewHost(), Registry: reg})
3265 disconnected, err := c.RemoveMCPServer("helper")
3266 if err == nil || disconnected || !strings.Contains(err.Error(), "managed by plugin") || !strings.Contains(err.Error(), "superpowers") {
3267 t.Fatalf("RemoveMCPServer(plugin-managed) = (%v, %v)", disconnected, err)
3268 }
3269 if _, found := reg.Get("mcp__helper__echo"); !found {
3270 t.Fatalf("plugin-managed tool was removed despite rejected removal; names=%v", reg.Names())
3271 }
3272 if disconnected := c.DisconnectMCPServer("helper"); !disconnected {
3273 t.Fatal("session-only disconnect should be allowed for a plugin-managed MCP")
3274 }
3275 if _, found := reg.Get("mcp__helper__echo"); found {
3276 t.Fatalf("plugin-managed tool survived session-only disconnect; names=%v", reg.Names())
3277 }
3278 }
3279
3280 func TestRemoveMCPServerDeletesProjectMCPJSONSource(t *testing.T) {
3281 isolateControlConfigHome(t)
3282 if err := os.WriteFile(".mcp.json", []byte(`{
3283 "mcpServers": {
3284 "mock": { "command": "mock-mcp" },
3285 "keep": { "command": "keep-mcp" }
3286 }
3287 }`), 0o644); err != nil {
3288 t.Fatal(err)
3289 }
3290
3291 reg := tool.NewRegistry()
3292 reg.Add(fakeControlTool{name: "mcp__mock__connect"})
3293 c := New(Options{Host: plugin.NewHost(), Registry: reg})
3294 disconnected, err := c.RemoveMCPServer("mock")
3295 if err != nil {
3296 t.Fatalf("RemoveMCPServer(.mcp.json): %v", err)
3297 }
3298 if disconnected {
3299 t.Fatal("RemoveMCPServer reported a live disconnect for an idle .mcp.json server")
3300 }
3301 if _, found := reg.Get("mcp__mock__connect"); found {
3302 t.Fatalf(".mcp.json placeholder survived removal; names=%v", reg.Names())
3303 }
3304 raw, err := os.ReadFile(".mcp.json")
3305 if err != nil {
3306 t.Fatal(err)
3307 }
3308 if strings.Contains(string(raw), `"mock"`) || !strings.Contains(string(raw), `"keep"`) {
3309 t.Fatalf(".mcp.json removal did not preserve unrelated servers:\n%s", raw)
3310 }
3311 }
3312
3313 // approvalIDs returns a Controller whose Sink forwards each ApprovalRequest's ID
3314 // onto the channel, plus a counter of how many requests it emitted.
3315 func approvalIDs() (*Controller, chan string, *int) {
3316 ids := make(chan string, 8)
3317 prompts := 0
3318 c := New(Options{Sink: event.FuncSink(func(e event.Event) {
3319 if e.Kind == event.ApprovalRequest {
3320 prompts++
3321 ids <- e.Approval.ID
3322 }
3323 })})
3324 return c, ids, &prompts
3325 }
3326
3327 func permissionHookController(t *testing.T, match string) (*Controller, chan string, chan hook.Payload) {
3328 t.Helper()
3329 ids := make(chan string, 8)
3330 payloads := make(chan hook.Payload, 8)
3331 spawner := func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
3332 var payload hook.Payload
3333 if err := json.Unmarshal([]byte(in.Stdin), &payload); err != nil {
3334 t.Errorf("permission hook payload json: %v", err)
3335 }
3336 payloads <- payload
3337 return hook.SpawnResult{ExitCode: 0}
3338 }
3339 c := New(Options{
3340 Sink: event.FuncSink(func(e event.Event) {
3341 if e.Kind == event.ApprovalRequest {
3342 ids <- e.Approval.ID
3343 }
3344 }),
3345 Hooks: hook.NewRunner([]hook.ResolvedHook{{
3346 HookConfig: hook.HookConfig{Command: "notify", Match: match},
3347 Event: hook.PermissionRequest,
3348 Scope: hook.ScopeGlobal,
3349 }}, "/tmp", spawner, nil),
3350 })
3351 return c, ids, payloads
3352 }
3353
3354 // claudePermissionHookController wires a Claude-imported PermissionRequest
3355 // hook (PayloadFormat "claude") whose mock spawner always returns stdout, so
3356 // tests can assert the hook's decision preempts the approval prompt instead
3357 // of only notifying — matching Claude's own PermissionRequest contract.
3358 func claudePermissionHookController(t *testing.T, exitCode int, stdout string) (*Controller, chan string) {
3359 t.Helper()
3360 ids := make(chan string, 8)
3361 spawner := func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
3362 return hook.SpawnResult{ExitCode: exitCode, Stdout: stdout}
3363 }
3364 c := New(Options{
3365 Sink: event.FuncSink(func(e event.Event) {
3366 if e.Kind == event.ApprovalRequest {
3367 ids <- e.Approval.ID
3368 }
3369 }),
3370 Hooks: hook.NewRunner([]hook.ResolvedHook{{
3371 HookConfig: hook.HookConfig{Command: "guard", Match: "Bash", PayloadFormat: "claude"},
3372 Event: hook.PermissionRequest,
3373 Scope: hook.ScopeGlobal,
3374 }}, "/tmp", spawner, nil),
3375 })
3376 return c, ids
3377 }
3378
3379 func TestPermissionRequestClaudeHookAutoDenies(t *testing.T) {
3380 c, ids := claudePermissionHookController(t, 2, "")
3381 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", "rm -rf /", json.RawMessage(`{"command":"rm -rf /"}`))
3382 if err != nil {
3383 t.Fatalf("Approve error = %v", err)
3384 }
3385 if allow {
3386 t.Fatal("a Claude PermissionRequest hook exiting 2 should auto-deny")
3387 }
3388 select {
3389 case id := <-ids:
3390 t.Fatalf("auto-deny must preempt the approval prompt, but one was emitted: %s", id)
3391 case <-time.After(50 * time.Millisecond):
3392 }
3393 }
3394
3395 func TestPermissionRequestClaudeHookAutoAllows(t *testing.T) {
3396 allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
3397 c, ids := claudePermissionHookController(t, 0, allowJSON)
3398 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", json.RawMessage(`{"command":"go test ./..."}`))
3399 if err != nil {
3400 t.Fatalf("Approve error = %v", err)
3401 }
3402 if !allow {
3403 t.Fatal("a Claude PermissionRequest hook returning decision.behavior=allow should auto-allow")
3404 }
3405 select {
3406 case id := <-ids:
3407 t.Fatalf("auto-allow must preempt the approval prompt, but one was emitted: %s", id)
3408 case <-time.After(50 * time.Millisecond):
3409 }
3410 }
3411
3412 // wildcardClaudePermissionHookController is like claudePermissionHookController
3413 // but matches every tool, for exercising fresh-human-required tools whose
3414 // names ("remember", "sandbox_escape", ...) aren't Claude tool names.
3415 func wildcardClaudePermissionHookController(t *testing.T, exitCode int, stdout string) (*Controller, chan string) {
3416 t.Helper()
3417 ids := make(chan string, 8)
3418 spawner := func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
3419 return hook.SpawnResult{ExitCode: exitCode, Stdout: stdout}
3420 }
3421 c := New(Options{
3422 Sink: event.FuncSink(func(e event.Event) {
3423 if e.Kind == event.ApprovalRequest {
3424 ids <- e.Approval.ID
3425 }
3426 }),
3427 Hooks: hook.NewRunner([]hook.ResolvedHook{{
3428 HookConfig: hook.HookConfig{Command: "guard", PayloadFormat: "claude"},
3429 Event: hook.PermissionRequest,
3430 Scope: hook.ScopeGlobal,
3431 }}, "/tmp", spawner, nil),
3432 })
3433 return c, ids
3434 }
3435
3436 func TestPermissionRequestClaudeHookCannotAutoAllowFreshHumanApproval(t *testing.T) {
3437 allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
3438 for _, tool := range []string{memoryRememberTool, memoryForgetTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool} {
3439 t.Run(tool, func(t *testing.T) {
3440 c, ids := wildcardClaudePermissionHookController(t, 0, allowJSON)
3441 done := make(chan bool, 1)
3442 go func() {
3443 allow, _, err := gateApprover{c}.Approve(context.Background(), tool, "", json.RawMessage(`{}`))
3444 if err != nil {
3445 t.Errorf("Approve error = %v", err)
3446 return
3447 }
3448 done <- allow
3449 }()
3450
3451 id := waitApprovalID(t, ids)
3452 c.Approve(id, true, false, false)
3453 select {
3454 case allow := <-done:
3455 if !allow {
3456 t.Fatal("manual approval should still allow")
3457 }
3458 case <-time.After(30 * time.Second):
3459 t.Fatal("approval stayed blocked")
3460 }
3461 })
3462 }
3463 }
3464
3465 func TestPermissionRequestClaudeHookAutoDeniesFreshHumanApproval(t *testing.T) {
3466 // A deny is always safe to auto-honor, even for fresh-human tools —
3467 // refusing something that requires a human's blessing can't leak
3468 // unauthorized access the way an auto-allow could.
3469 for _, tool := range []string{memoryRememberTool, SandboxEscapeApprovalTool} {
3470 t.Run(tool, func(t *testing.T) {
3471 c, ids := wildcardClaudePermissionHookController(t, 2, "")
3472 allow, _, err := gateApprover{c}.Approve(context.Background(), tool, "", json.RawMessage(`{}`))
3473 if err != nil {
3474 t.Fatalf("Approve error = %v", err)
3475 }
3476 if allow {
3477 t.Fatal("a Claude PermissionRequest hook exiting 2 should auto-deny even a fresh-human tool")
3478 }
3479 select {
3480 case id := <-ids:
3481 t.Fatalf("auto-deny must preempt the approval prompt, but one was emitted: %s", id)
3482 case <-time.After(50 * time.Millisecond):
3483 }
3484 })
3485 }
3486 }
3487
3488 // TestPermissionRequestClaudeHookCannotAutoAllowOptsFreshOnlyDecision covers
3489 // the fresh-human protection's other branch: a tool that requestFreshApprovalDecision
3490 // marks fresh (opts.fresh=true) without being one of
3491 // RequiresFreshHumanApprovalTool's fixed cases. PlanModeReadOnlyCommandApprovalTool
3492 // is exactly that — the earlier tests only exercised tools protected via
3493 // requiresFreshApprovalTool(tool), not the opts.fresh flag alone.
3494 func TestPermissionRequestClaudeHookCannotAutoAllowOptsFreshOnlyDecision(t *testing.T) {
3495 if RequiresFreshHumanApprovalTool(agent.PlanModeReadOnlyCommandApprovalTool) {
3496 t.Fatal("test assumes this tool is fresh-only via opts.fresh, not RequiresFreshHumanApprovalTool")
3497 }
3498 allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
3499 c, ids := wildcardClaudePermissionHookController(t, 0, allowJSON)
3500 done := make(chan bool, 1)
3501 go func() {
3502 reply, err := c.requestFreshApprovalDecision(context.Background(), agent.PlanModeReadOnlyCommandApprovalTool, "ls", nil, "trust this read-only command prefix?")
3503 if err != nil {
3504 t.Errorf("requestFreshApprovalDecision error = %v", err)
3505 return
3506 }
3507 done <- reply.allow
3508 }()
3509
3510 id := waitApprovalID(t, ids)
3511 c.Approve(id, true, false, false)
3512 select {
3513 case allow := <-done:
3514 if !allow {
3515 t.Fatal("manual approval should still allow")
3516 }
3517 case <-time.After(30 * time.Second):
3518 t.Fatal("approval stayed blocked — a Claude hook allow should not have preempted this opts.fresh decision")
3519 }
3520 }
3521
3522 func waitApprovalID(t *testing.T, ids <-chan string) string {
3523 t.Helper()
3524 select {
3525 case id := <-ids:
3526 return id
3527 case <-time.After(30 * time.Second):
3528 t.Fatal("ApprovalRequest was not emitted")
3529 }
3530 return ""
3531 }
3532
3533 func waitPermissionHook(t *testing.T, payloads <-chan hook.Payload) hook.Payload {
3534 t.Helper()
3535 select {
3536 case payload := <-payloads:
3537 return payload
3538 case <-time.After(30 * time.Second):
3539 t.Fatal("PermissionRequest hook did not fire")
3540 }
3541 return hook.Payload{}
3542 }
3543
3544 func assertNoPermissionHook(t *testing.T, payloads <-chan hook.Payload) {
3545 t.Helper()
3546 select {
3547 case payload := <-payloads:
3548 t.Fatalf("PermissionRequest hook fired unexpectedly: %+v", payload)
3549 case <-time.After(50 * time.Millisecond):
3550 }
3551 }
3552
3553 // TestApprovalAllowOnce drives the happy path: the gate emits an ApprovalRequest,
3554 // the (fake) frontend answers allow, and the gate returns allow with no grant.
3555 func TestApprovalAllowOnce(t *testing.T) {
3556 c, ids, _ := approvalIDs()
3557 go func() { c.Approve(<-ids, true, false, false) }()
3558
3559 allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test", nil)
3560 if err != nil || !allow || remember {
3561 t.Fatalf("Approve = (%v,%v,%v), want allow once", allow, remember, err)
3562 }
3563 }
3564
3565 func TestMemoryApprovalRequestShowsRememberPayload(t *testing.T) {
3566 approvals := make(chan event.Approval, 1)
3567 c := New(Options{Sink: event.FuncSink(func(e event.Event) {
3568 if e.Kind == event.ApprovalRequest {
3569 approvals <- e.Approval
3570 }
3571 })})
3572
3573 args := json.RawMessage(`{
3574 "name": "stable-retrieval-conclusion",
3575 "description": "History retrieval should reuse stable synthesized conclusions.",
3576 "type": "feedback",
3577 "body": "**Why:** repeated history scans are expensive.\n\n**How to apply:** save the stable summary as a memory document."
3578 }`)
3579 result := make(chan string, 1)
3580 go func() {
3581 allow, _, err := gateApprover{c}.Approve(context.Background(), "remember", "", args)
3582 if err != nil {
3583 result <- err.Error()
3584 return
3585 }
3586 if !allow {
3587 result <- "memory approval denied"
3588 return
3589 }
3590 result <- ""
3591 }()
3592
3593 var approval event.Approval
3594 select {
3595 case approval = <-approvals:
3596 case <-time.After(30 * time.Second):
3597 t.Fatal("memory approval request was not emitted")
3598 }
3599 for _, want := range []string{
3600 `Save/update memory "stable-retrieval-conclusion"`,
3601 "[feedback]",
3602 "History retrieval should reuse stable synthesized conclusions.",
3603 "repeated history scans are expensive",
3604 "save the stable summary",
3605 } {
3606 if !strings.Contains(approval.Subject, want) {
3607 t.Fatalf("approval subject %q does not contain %q", approval.Subject, want)
3608 }
3609 }
3610 if strings.Contains(approval.Subject, "\n") {
3611 t.Fatalf("approval subject should be compact for TUI rendering, got %q", approval.Subject)
3612 }
3613
3614 c.Approve(approval.ID, true, true, true)
3615 select {
3616 case msg := <-result:
3617 if msg != "" {
3618 t.Fatalf("Approve returned %s", msg)
3619 }
3620 case <-time.After(30 * time.Second):
3621 t.Fatal("memory approval stayed blocked after Approve")
3622 }
3623 }
3624
3625 func TestGuardianCannotAutoAllowFreshHumanApprovalTools(t *testing.T) {
3626 guardianProv := &recordingProvider{
3627 name: "guardian",
3628 streams: [][]provider.Chunk{textTurn(`{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"authorized memory update"}`)},
3629 }
3630 guardianSess := guardian.NewSession(guardianProv, tool.NewRegistry(), guardian.PolicyPrompt(), "guardian-test", 0, nil, event.Discard)
3631 exec := agent.New(&recordingProvider{name: "executor"}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
3632
3633 approvals := make(chan event.Approval, 1)
3634 c := New(Options{
3635 Executor: exec,
3636 Guardian: guardianSess,
3637 Sink: event.FuncSink(func(e event.Event) {
3638 if e.Kind == event.ApprovalRequest {
3639 approvals <- e.Approval
3640 }
3641 }),
3642 })
3643
3644 args := json.RawMessage(`{"name":"prefers-vitest","description":"Preferred test framework","body":"Use vitest for frontend tests."}`)
3645 type approveResult struct {
3646 allow bool
3647 remember bool
3648 err error
3649 }
3650 done := make(chan approveResult, 1)
3651 go func() {
3652 allow, remember, err := gateApprover{c}.Approve(context.Background(), "remember", "", args)
3653 done <- approveResult{allow: allow, remember: remember, err: err}
3654 }()
3655
3656 var approval event.Approval
3657 select {
3658 case approval = <-approvals:
3659 case <-time.After(30 * time.Second):
3660 t.Fatal("memory approval request was not emitted after Guardian allow")
3661 }
3662 if approval.Tool != "remember" {
3663 t.Fatalf("approval tool = %q, want remember", approval.Tool)
3664 }
3665 if len(guardianProv.requests) != 1 {
3666 t.Fatalf("guardian reviews = %d, want 1", len(guardianProv.requests))
3667 }
3668 select {
3669 case got := <-done:
3670 t.Fatalf("Guardian must not auto-allow remember, got %+v", got)
3671 case <-time.After(50 * time.Millisecond):
3672 }
3673
3674 c.Approve(approval.ID, true, true, true)
3675 select {
3676 case got := <-done:
3677 if got.err != nil || !got.allow || got.remember {
3678 t.Fatalf("Approve = (%v,%v,%v), want manual allow without remember", got.allow, got.remember, got.err)
3679 }
3680 case <-time.After(30 * time.Second):
3681 t.Fatal("memory approval stayed blocked after manual Approve")
3682 }
3683 }
3684
3685 func TestLowRiskProjectMemoryCreateSkipsApprovalPrompt(t *testing.T) {
3686 store := memory.Store{Dir: t.TempDir()}
3687 approvals := 0
3688 c := New(Options{
3689 Memory: &memory.Set{Store: store},
3690 Sink: event.FuncSink(func(e event.Event) {
3691 if e.Kind == event.ApprovalRequest {
3692 approvals++
3693 }
3694 }),
3695 })
3696 args := json.RawMessage(`{"name":"release-target","description":"Project release target","type":"project","body":"Release from main-v2."}`)
3697 allow, remember, reason, err := gateApprover{c}.ApproveWithReason(context.Background(), memoryRememberTool, "", args)
3698 if err != nil || !allow || remember || reason != "" || approvals != 0 {
3699 t.Fatalf("safe project create = (%v,%v,%q,%v), approvals=%d", allow, remember, reason, err, approvals)
3700 }
3701
3702 out, err := memory.NewRememberTool(store).Execute(memory.WithQueue(context.Background(), c), args)
3703 if err != nil || !strings.Contains(out, "Saved memory") {
3704 t.Fatalf("auto-approved remember execution = %q, %v", out, err)
3705 }
3706 if got := store.List(); len(got) != 1 || got[0].Name != "release-target" {
3707 t.Fatalf("saved memories = %+v", got)
3708 }
3709 }
3710
3711 func TestExistingMemoryRevokesAbandonedAutomaticCreateClaim(t *testing.T) {
3712 store := memory.Store{Dir: t.TempDir()}
3713 c := New(Options{Memory: &memory.Set{Store: store}})
3714 args := json.RawMessage(`{"name":"release-target","description":"Project release target","type":"project","body":"Release from main-v2."}`)
3715
3716 if assessment := memory.AssessRememberWrite(store, args); !assessment.AutoAllow {
3717 t.Fatalf("initial assessment = %+v", assessment)
3718 }
3719 c.memory.authorizeAutoRemember(args) // approval was issued, then the turn was cancelled
3720 if _, err := store.Save(memory.Memory{Name: "release-target", Description: "concurrent", Body: "existing"}); err != nil {
3721 t.Fatal(err)
3722 }
3723 if assessment := memory.AssessRememberWrite(store, args); assessment.AutoAllow {
3724 t.Fatalf("existing assessment = %+v", assessment)
3725 }
3726 c.memory.revokeAutoRemember(args)
3727 if c.ClaimAutoMemoryWrite(args) {
3728 t.Fatal("abandoned automatic create claim survived an existing-memory reassessment")
3729 }
3730 }
3731
3732 // TestSessionGrantShortCircuitsGuardianReview: a session grant (or YOLO / the
3733 // approved-plan window) answers an ordinary approval before any guardian review
3734 // or prompt is attempted. Absorbed from PR #6413 by @myipanta.
3735 func TestSessionGrantShortCircuitsGuardianReview(t *testing.T) {
3736 guardianProv := &recordingProvider{
3737 name: "guardian",
3738 streams: [][]provider.Chunk{textTurn(`{"risk_level":"high","user_authorization":"unknown","outcome":"deny","rationale":"should never run"}`)},
3739 }
3740 guardianSess := guardian.NewSession(guardianProv, tool.NewRegistry(), guardian.PolicyPrompt(), "guardian-test", 0, nil, event.Discard)
3741 exec := agent.New(&recordingProvider{name: "executor"}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
3742 prompts := 0
3743 c := New(Options{
3744 Executor: exec,
3745 Guardian: guardianSess,
3746 Sink: event.FuncSink(func(e event.Event) {
3747 if e.Kind == event.ApprovalRequest {
3748 prompts++
3749 }
3750 }),
3751 })
3752 subject := approvalDisplaySubject("write_file", "main.go", nil)
3753 c.approval.grantSession("write_file", subject)
3754
3755 allow, remember, _, err := gateApprover{c}.ApproveWithReason(context.Background(), "write_file", "main.go", nil)
3756 if err != nil || !allow || remember {
3757 t.Fatalf("session-granted approval = (%v,%v,%v), want plain allow", allow, remember, err)
3758 }
3759 if len(guardianProv.requests) != 0 || prompts != 0 {
3760 t.Fatalf("session grant must bypass guardian and prompts, reviews=%d prompts=%d", len(guardianProv.requests), prompts)
3761 }
3762 }
3763
3764 func TestHeadlessGateRefusesFreshHumanApprovalTools(t *testing.T) {
3765 gate := NewHeadlessPermissionGate(permission.New("ask", nil, nil, nil))
3766
3767 for _, toolName := range []string{"remember", "forget"} {
3768 allow, reason, err := gate.Check(context.Background(), toolName, json.RawMessage(`{}`), false)
3769 if err != nil || allow || !strings.Contains(reason, "fresh human approval") {
3770 t.Fatalf("%s headless check = (%v,%q,%v), want fresh-human refusal", toolName, allow, reason, err)
3771 }
3772 }
3773
3774 allow, reason, err := gate.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
3775 if err != nil || !allow || reason != "" {
3776 t.Fatalf("legacy bootstrap ask = (%v,%q,%v), want compatibility allow", allow, reason, err)
3777 }
3778 }
3779
3780 func TestMemoryApprovalSubjectsAndNotifications(t *testing.T) {
3781 forgetSubject := approvalDisplaySubject("forget", "", json.RawMessage(`{"name":"wrong-memory"}`))
3782 if forgetSubject != `Archive memory "wrong-memory"` {
3783 t.Fatalf("forget approval subject = %q", forgetSubject)
3784 }
3785 if got := approvalNotificationText("remember", "Save/update memory with private details"); got != "approval needed: remember" {
3786 t.Fatalf("remember notification = %q", got)
3787 }
3788 if got := approvalNotificationText("forget", `Archive memory "wrong-memory"`); got != "approval needed: forget" {
3789 t.Fatalf("forget notification = %q", got)
3790 }
3791 if got := approvalNotificationText("bash", "go test ./..."); got != "approval needed: bash go test ./..." {
3792 t.Fatalf("bash notification = %q", got)
3793 }
3794 moveSubject := approvalDisplaySubject("move_file", "src/a.md", json.RawMessage(`{"source_path":"src/a.md","destination_path":"docs/a.md"}`))
3795 if moveSubject != "src/a.md -> docs/a.md" {
3796 t.Fatalf("move_file approval subject = %q", moveSubject)
3797 }
3798 }
3799
3800 func TestPermissionRequestHookFiresForToolApproval(t *testing.T) {
3801 c, ids, payloads := permissionHookController(t, "bash")
3802 args := json.RawMessage(`{"command":"go test ./..."}`)
3803 type approveResult struct {
3804 allow bool
3805 remember bool
3806 err error
3807 }
3808 done := make(chan approveResult, 1)
3809 go func() {
3810 allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", args)
3811 done <- approveResult{allow: allow, remember: remember, err: err}
3812 }()
3813
3814 id := waitApprovalID(t, ids)
3815 payload := waitPermissionHook(t, payloads)
3816 if payload.Event != hook.PermissionRequest {
3817 t.Fatalf("payload event = %q, want PermissionRequest", payload.Event)
3818 }
3819 if payload.ToolName != "bash" {
3820 t.Fatalf("payload tool = %q, want bash", payload.ToolName)
3821 }
3822 if payload.Subject != "go test ./..." {
3823 t.Fatalf("payload subject = %q, want command subject", payload.Subject)
3824 }
3825 if string(payload.ToolArgs) != string(args) {
3826 t.Fatalf("payload args = %s, want %s", payload.ToolArgs, args)
3827 }
3828
3829 c.Approve(id, true, false, false)
3830 select {
3831 case got := <-done:
3832 if got.err != nil || !got.allow || got.remember {
3833 t.Fatalf("Approve = (%v,%v,%v), want allow once", got.allow, got.remember, got.err)
3834 }
3835 case <-time.After(30 * time.Second):
3836 t.Fatal("approval stayed blocked")
3837 }
3838 }
3839
3840 func TestPermissionRequestHookDoesNotFireForPolicyAllow(t *testing.T) {
3841 c, _, payloads := permissionHookController(t, "bash")
3842 g := permission.NewGate(permission.New("ask", []string{"bash(go test*)"}, nil, nil), gateApprover{c})
3843
3844 allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
3845 if err != nil || !allow {
3846 t.Fatalf("allow-listed call = (%v,%v), want allowed", allow, err)
3847 }
3848 assertNoPermissionHook(t, payloads)
3849 }
3850
3851 func TestPermissionRequestHookDoesNotFireForAutoApprovalMode(t *testing.T) {
3852 c, _, payloads := permissionHookController(t, "bash")
3853 c.SetToolApprovalMode(ToolApprovalAuto)
3854 g := c.newInteractiveGate()
3855
3856 allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
3857 if err != nil || !allow {
3858 t.Fatalf("auto-approved call = (%v,%v), want allowed", allow, err)
3859 }
3860 assertNoPermissionHook(t, payloads)
3861 }
3862
3863 func TestPermissionRequestHookDoesNotFireForSessionGrant(t *testing.T) {
3864 c, _, payloads := permissionHookController(t, "bash")
3865 c.approval.grantSession("bash", "go test ./...")
3866
3867 allow, _, err := c.requestApproval(context.Background(), "bash", "go test ./...", nil)
3868 if err != nil || !allow {
3869 t.Fatalf("session-granted approval = (%v,%v), want allowed", allow, err)
3870 }
3871 assertNoPermissionHook(t, payloads)
3872 }
3873
3874 // TestSessionAuthorizationsCarryAcrossRebuild pins the fix for a rebuild
3875 // (model/effort/profile switch) dropping same-session "Allow for this
3876 // session" tool grants and Plan-mode read-only command trust: only the
3877 // ask/auto/yolo posture string used to survive a controller swap, so a user
3878 // who had already granted a tool this session was asked again after any
3879 // switch.
3880 func TestSessionAuthorizationsCarryAcrossRebuild(t *testing.T) {
3881 old := New(Options{})
3882 old.approval.grantSession("bash", "go test ./...")
3883 old.approval.grantPlanModeReadOnlyCommand("go test ./...")
3884
3885 fresh := New(Options{})
3886 fresh.RestoreSessionAuthorizations(old.SessionAuthorizations())
3887
3888 allow, _, err := fresh.requestApproval(context.Background(), "bash", "go test ./...", nil)
3889 if err != nil || !allow {
3890 t.Fatalf("session-granted approval after restore = (%v,%v), want allowed", allow, err)
3891 }
3892 if !fresh.approval.planModeReadOnlyCommandTrusted("go test ./...") {
3893 t.Fatal("plan-mode read-only command trust did not carry across rebuild")
3894 }
3895 }
3896
3897 func TestPermissionRequestHookDoesNotFireForYolo(t *testing.T) {
3898 c, _, payloads := permissionHookController(t, "bash")
3899 c.SetToolApprovalMode(ToolApprovalYolo)
3900
3901 allow, _, err := c.requestApproval(context.Background(), "bash", "go test ./...", nil)
3902 if err != nil || !allow {
3903 t.Fatalf("YOLO approval = (%v,%v), want allowed", allow, err)
3904 }
3905 assertNoPermissionHook(t, payloads)
3906 }
3907
3908 func TestPermissionRequestHookDoesNotFireForPlanApproval(t *testing.T) {
3909 c, ids, payloads := permissionHookController(t, ".*")
3910 done := make(chan bool, 1)
3911 errs := make(chan error, 1)
3912 go func() {
3913 allow, _, err := c.requestApproval(context.Background(), planApprovalTool, "", nil)
3914 if err != nil {
3915 errs <- err
3916 return
3917 }
3918 done <- allow
3919 }()
3920
3921 id := waitApprovalID(t, ids)
3922 assertNoPermissionHook(t, payloads)
3923 c.Approve(id, true, false, false)
3924
3925 select {
3926 case err := <-errs:
3927 t.Fatalf("plan approval: %v", err)
3928 case allow := <-done:
3929 if !allow {
3930 t.Fatal("manual plan approval should allow")
3931 }
3932 case <-time.After(30 * time.Second):
3933 t.Fatal("plan approval stayed blocked")
3934 }
3935 }
3936
3937 func TestPermissionRequestHookRedactsMemoryApprovalPayload(t *testing.T) {
3938 cases := []struct {
3939 tool string
3940 args json.RawMessage
3941 }{
3942 {
3943 tool: "remember",
3944 args: json.RawMessage(`{"name":"private-memory","description":"private description","body":"private memory body"}`),
3945 },
3946 {
3947 tool: "forget",
3948 args: json.RawMessage(`{"name":"private-memory"}`),
3949 },
3950 }
3951 for _, tc := range cases {
3952 t.Run(tc.tool, func(t *testing.T) {
3953 c, ids, payloads := permissionHookController(t, tc.tool)
3954 done := make(chan string, 1)
3955 go func() {
3956 allow, _, err := gateApprover{c}.Approve(context.Background(), tc.tool, "", tc.args)
3957 if err != nil {
3958 done <- err.Error()
3959 return
3960 }
3961 if !allow {
3962 done <- tc.tool + " approval denied"
3963 return
3964 }
3965 done <- ""
3966 }()
3967
3968 id := waitApprovalID(t, ids)
3969 payload := waitPermissionHook(t, payloads)
3970 if payload.ToolName != tc.tool {
3971 t.Fatalf("payload tool = %q, want %s", payload.ToolName, tc.tool)
3972 }
3973 if payload.Subject != "" {
3974 t.Fatalf("memory PermissionRequest subject = %q, want redacted", payload.Subject)
3975 }
3976 if len(payload.ToolArgs) != 0 {
3977 t.Fatalf("memory PermissionRequest args = %s, want redacted", payload.ToolArgs)
3978 }
3979
3980 c.Approve(id, true, false, false)
3981 select {
3982 case msg := <-done:
3983 if msg != "" {
3984 t.Fatal(msg)
3985 }
3986 case <-time.After(30 * time.Second):
3987 t.Fatal("memory approval stayed blocked")
3988 }
3989 })
3990 }
3991 }
3992
3993 // TestApprovalDeny confirms a declined call returns allow=false.
3994 func TestApprovalDeny(t *testing.T) {
3995 c, ids, _ := approvalIDs()
3996 go func() { c.Approve(<-ids, false, false, false) }()
3997
3998 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", "rm -rf /", nil)
3999 if err != nil || allow {
4000 t.Fatalf("Approve = (%v,%v), want deny", allow, err)
4001 }
4002 }
4003
4004 // TestApprovalSessionGrantScopesBashToCommand proves an "allow this session"
4005 // answer short-circuits later prompts for the same bash command, but a different
4006 // command still reaches the frontend.
4007 func TestApprovalSessionGrantScopesBashToCommand(t *testing.T) {
4008 c, ids, prompts := approvalIDs()
4009 go func() {
4010 c.Approve(<-ids, true, true, false) // grant go build for this session
4011 c.Approve(<-ids, true, false, false)
4012 }()
4013
4014 for i, subject := range []string{"go build", "go build", "go test ./..."} {
4015 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", subject, nil)
4016 if err != nil || !allow {
4017 t.Fatalf("call %d = (%v,%v), want allow", i, allow, err)
4018 }
4019 }
4020 if *prompts != 2 {
4021 t.Errorf("prompted %d times, want 2 (same command granted, different command prompts)", *prompts)
4022 }
4023 }
4024
4025 func TestApprovalSessionGrantCanScopeBashToCommandPrefix(t *testing.T) {
4026 c, ids, prompts := approvalIDs()
4027 go func() {
4028 c.Approve(<-ids, true, true, false) // grant bash session (prefix preferred)
4029 c.Approve(<-ids, true, false, false)
4030 }()
4031
4032 for i, subject := range []string{"go test ./...", "go test ./internal/control", "go build ./..."} {
4033 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", subject, nil)
4034 if err != nil || !allow {
4035 t.Fatalf("call %d = (%v,%v), want allow", i, allow, err)
4036 }
4037 }
4038 if *prompts != 2 {
4039 t.Errorf("prompted %d times, want 2 (prefix grant should cover similar command only)", *prompts)
4040 }
4041 }
4042
4043 func TestApprovalPersistentBashPrefixRememberRule(t *testing.T) {
4044 ids := make(chan string, 1)
4045 var remembered string
4046 var notices []string
4047 c := New(Options{
4048 Sink: event.FuncSink(func(e event.Event) {
4049 if e.Kind == event.ApprovalRequest {
4050 ids <- e.Approval.ID
4051 }
4052 if e.Kind == event.Notice {
4053 notices = append(notices, e.Text)
4054 }
4055 }),
4056 OnRemember: func(rule string) RememberResult {
4057 remembered = rule
4058 return RememberResult{Rule: rule, Path: "reasonix.toml", Saved: true}
4059 },
4060 })
4061 go func() {
4062 c.Approve(<-ids, true, true, true)
4063 }()
4064
4065 allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", nil)
4066 if err != nil || !allow || remember {
4067 t.Fatalf("Approve = (%v,%v,%v), want allow with controller-managed persistence", allow, remember, err)
4068 }
4069 if remembered != "Bash(go test:*)" {
4070 t.Fatalf("remembered rule = %q, want Bash(go test:*)", remembered)
4071 }
4072 if len(notices) != 1 || !strings.Contains(notices[0], "Bash(go test:*)") || !strings.Contains(notices[0], "reasonix.toml") {
4073 t.Fatalf("notices = %v, want saved rule notice", notices)
4074 }
4075 }
4076
4077 func TestApprovalPersistenceFailureKeepsSessionGrant(t *testing.T) {
4078 ids := make(chan string, 1)
4079 var notices []event.Event
4080 prompts := 0
4081 c := New(Options{
4082 Sink: event.FuncSink(func(e event.Event) {
4083 if e.Kind == event.ApprovalRequest {
4084 prompts++
4085 ids <- e.Approval.ID
4086 }
4087 if e.Kind == event.Notice {
4088 notices = append(notices, e)
4089 }
4090 }),
4091 OnRemember: func(rule string) RememberResult {
4092 return RememberResult{Rule: rule, Path: "reasonix.toml", Err: errors.New("disk unavailable")}
4093 },
4094 })
4095 go func() {
4096 c.Approve(<-ids, true, true, true)
4097 }()
4098
4099 for i := 0; i < 2; i++ {
4100 allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", nil)
4101 if err != nil || !allow || remember {
4102 t.Fatalf("Approve call %d = (%v,%v,%v), want session-allowed despite persistence failure", i, allow, remember, err)
4103 }
4104 }
4105 if prompts != 1 {
4106 t.Fatalf("approval prompts = %d, want one because failed persistence must retain the session grant", prompts)
4107 }
4108 if len(notices) != 1 || notices[0].Level != event.LevelWarn || !strings.Contains(notices[0].Text, "disk unavailable") {
4109 t.Fatalf("notices = %+v, want one persistence failure warning", notices)
4110 }
4111 }
4112
4113 func TestPlanModeReadOnlyTrustApprovalPersistsBashCommandTrust(t *testing.T) {
4114 ids := make(chan string, 2)
4115 var approval event.Approval
4116 var notices []string
4117 var rememberedPrefix string
4118 prompts := 0
4119 c := New(Options{
4120 Sink: event.FuncSink(func(e event.Event) {
4121 if e.Kind == event.ApprovalRequest {
4122 prompts++
4123 approval = e.Approval
4124 ids <- e.Approval.ID
4125 }
4126 if e.Kind == event.Notice {
4127 notices = append(notices, e.Text)
4128 }
4129 }),
4130 OnRememberPlanModeReadOnlyCommand: func(prefix string) PlanModeReadOnlyCommandTrustResult {
4131 rememberedPrefix = prefix
4132 return PlanModeReadOnlyCommandTrustResult{Prefix: prefix, Path: "reasonix.toml", Saved: true}
4133 },
4134 })
4135
4136 go func() {
4137 c.Approve(<-ids, true, true, true)
4138 }()
4139 req := agent.PlanModeReadOnlyTrustRequest{
4140 ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
4141 Command: "gh issue view 5867 --json title",
4142 Prefix: "gh issue view",
4143 Args: json.RawMessage(`{"command":"gh issue view 5867 --json title"}`),
4144 }
4145 allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
4146 if err != nil || !allow || reason != "" {
4147 t.Fatalf("CheckPlanModeReadOnlyTrust = (%v,%q,%v), want allow", allow, reason, err)
4148 }
4149 if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool || !strings.Contains(approval.Subject, `Trust "gh issue view"`) || !strings.Contains(approval.Subject, "gh issue view 5867") || !strings.Contains(approval.Reason, "Auto/YOLO") {
4150 t.Fatalf("approval = %+v, want plan-mode bash read-only command trust prompt", approval)
4151 }
4152 if rememberedPrefix != "gh issue view" {
4153 t.Fatalf("remembered prefix = %q, want gh issue view", rememberedPrefix)
4154 }
4155 if len(notices) != 1 || !strings.Contains(notices[0], "gh issue view") {
4156 t.Fatalf("notices = %v, want read-only command trust saved notice", notices)
4157 }
4158
4159 ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
4160 defer cancel()
4161 allow, reason, err = planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(ctx, req)
4162 if err != nil || !allow || reason != "" {
4163 t.Fatalf("second CheckPlanModeReadOnlyTrust = (%v,%q,%v), want session grant", allow, reason, err)
4164 }
4165 if prompts != 1 {
4166 t.Fatalf("approval prompts = %d, want 1", prompts)
4167 }
4168 }
4169
4170 func TestApprovalSubjectsUseChineseCatalog(t *testing.T) {
4171 i18n.DetectLanguage("zh")
4172 t.Cleanup(func() { i18n.DetectLanguage("en") })
4173
4174 rememberArgs := json.RawMessage(`{"name":"prefers-vitest","type":"user","description":"Preferred test framework","body":"Use Vitest for frontend tests."}`)
4175 if got := approvalDisplaySubject(memoryRememberTool, "", rememberArgs); !strings.Contains(got, "保存/更新记忆") || !strings.Contains(got, "正文: Use Vitest") {
4176 t.Fatalf("remember approval subject = %q, want Chinese labels", got)
4177 }
4178
4179 forgetArgs := json.RawMessage(`{"name":"old-fact"}`)
4180 if got := approvalDisplaySubject(memoryForgetTool, "", forgetArgs); got != `归档记忆 "old-fact"` {
4181 t.Fatalf("forget approval subject = %q, want Chinese archive label", got)
4182 }
4183 }
4184
4185 func TestPlanModeReadOnlyTrustApprovalUsesChineseCatalog(t *testing.T) {
4186 i18n.DetectLanguage("zh")
4187 t.Cleanup(func() { i18n.DetectLanguage("en") })
4188
4189 approvalRequests := make(chan event.Approval, 1)
4190 c := New(Options{
4191 Sink: event.FuncSink(func(e event.Event) {
4192 if e.Kind == event.ApprovalRequest {
4193 approvalRequests <- e.Approval
4194 }
4195 }),
4196 })
4197 done := make(chan struct {
4198 allow bool
4199 reason string
4200 err error
4201 }, 1)
4202 req := agent.PlanModeReadOnlyTrustRequest{
4203 ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
4204 Command: "gh issue view 5867 --json title",
4205 Prefix: "gh issue view",
4206 Args: json.RawMessage(`{"command":"gh issue view 5867 --json title"}`),
4207 }
4208 go func() {
4209 allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
4210 done <- struct {
4211 allow bool
4212 reason string
4213 err error
4214 }{allow: allow, reason: reason, err: err}
4215 }()
4216
4217 var approval event.Approval
4218 select {
4219 case approval = <-approvalRequests:
4220 case <-time.After(30 * time.Second):
4221 t.Fatal("plan-mode bash trust approval request was not emitted")
4222 }
4223 if !strings.Contains(approval.Subject, "在计划模式中信任") || !strings.Contains(approval.Subject, "gh issue view 5867") {
4224 t.Fatalf("approval subject = %q, want Chinese plan-mode trust subject", approval.Subject)
4225 }
4226 if !strings.Contains(approval.Reason, "不在 Reasonix 内置只读集合中") {
4227 t.Fatalf("approval reason = %q, want Chinese plan-mode trust reason", approval.Reason)
4228 }
4229
4230 c.Approve(approval.ID, false, false, false)
4231 select {
4232 case got := <-done:
4233 if got.err != nil || got.allow || !strings.Contains(got.reason, "用户拒绝") {
4234 t.Fatalf("rejected trust result = %+v, want Chinese denial", got)
4235 }
4236 case <-time.After(30 * time.Second):
4237 t.Fatal("plan-mode bash trust approval stayed blocked after rejection")
4238 }
4239 }
4240
4241 func TestPlanModeReadOnlyCommandTrustApprovalIgnoresToolAutoApproval(t *testing.T) {
4242 approvalRequests := make(chan event.Approval, 1)
4243 c := New(Options{
4244 Sink: event.FuncSink(func(e event.Event) {
4245 if e.Kind == event.ApprovalRequest {
4246 approvalRequests <- e.Approval
4247 }
4248 }),
4249 })
4250 c.SetAutoApproveTools(true)
4251
4252 type trustResult struct {
4253 allow bool
4254 reason string
4255 err error
4256 }
4257 done := make(chan trustResult, 1)
4258 req := agent.PlanModeReadOnlyTrustRequest{
4259 ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
4260 Command: "gh issue view 5867",
4261 Prefix: "gh issue view",
4262 Args: json.RawMessage(`{"command":"gh issue view 5867"}`),
4263 }
4264 go func() {
4265 allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
4266 done <- trustResult{allow: allow, reason: reason, err: err}
4267 }()
4268
4269 var approval event.Approval
4270 select {
4271 case approval = <-approvalRequests:
4272 case <-time.After(30 * time.Second):
4273 t.Fatal("plan-mode bash read-only command trust prompt was not emitted under tool auto-approval")
4274 }
4275 if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool || !strings.Contains(approval.Subject, `Trust "gh issue view"`) {
4276 t.Fatalf("approval = %+v, want plan-mode bash read-only command trust prompt", approval)
4277 }
4278 select {
4279 case got := <-done:
4280 t.Fatalf("tool auto-approval must not answer plan-mode bash read-only command trust, got %+v", got)
4281 case <-time.After(50 * time.Millisecond):
4282 }
4283
4284 c.Approve(approval.ID, true, true, false)
4285 select {
4286 case got := <-done:
4287 if got.err != nil || !got.allow || got.reason != "" {
4288 t.Fatalf("CheckPlanModeReadOnlyTrust after approval = %+v, want allow", got)
4289 }
4290 case <-time.After(30 * time.Second):
4291 t.Fatal("plan-mode bash read-only command trust prompt stayed blocked after Approve")
4292 }
4293
4294 allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
4295 if err != nil || !allow || reason != "" {
4296 t.Fatalf("session-granted plan-mode bash read-only command trust under YOLO = (%v,%q,%v), want allow", allow, reason, err)
4297 }
4298 }
4299
4300 func TestApprovalSessionGrantGroupsFileMutationTools(t *testing.T) {
4301 c, ids, prompts := approvalIDs()
4302 go func() { c.Approve(<-ids, true, true, false) }()
4303
4304 for i, call := range []struct {
4305 tool string
4306 subject string
4307 }{
4308 {"edit_file", "src/a.go"},
4309 {"write_file", "src/b.go"},
4310 {"multi_edit", "src/c.go"},
4311 {"move_file", "src/d.go"},
4312 } {
4313 allow, _, err := gateApprover{c}.Approve(context.Background(), call.tool, call.subject, nil)
4314 if err != nil || !allow {
4315 t.Fatalf("call %d = (%v,%v), want allow", i, allow, err)
4316 }
4317 }
4318 if *prompts != 1 {
4319 t.Errorf("prompted %d times, want 1 (file mutation session grant should short-circuit)", *prompts)
4320 }
4321 }
4322
4323 func TestApprovalSessionGrantKeepsPolicyDenyPrecedence(t *testing.T) {
4324 c, ids, prompts := approvalIDs()
4325 g := permission.NewGate(permission.New("ask", nil, nil, []string{"bash(rm*)"}), gateApprover{c})
4326 go func() { c.Approve(<-ids, true, true, false) }()
4327
4328 allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go build"}`), false)
4329 if err != nil || !allow {
4330 t.Fatalf("first approved call = (%v,%v), want allow", allow, err)
4331 }
4332 allow, _, err = g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go build"}`), false)
4333 if err != nil || !allow {
4334 t.Fatalf("same-command call after session grant = (%v,%v), want allow", allow, err)
4335 }
4336 allow, reason, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"rm -rf /tmp/x"}`), false)
4337 if err != nil || allow || reason == "" {
4338 t.Fatalf("deny-listed call = (%v,%q,%v), want blocked with reason", allow, reason, err)
4339 }
4340 if *prompts != 1 {
4341 t.Errorf("prompted %d times, want 1", *prompts)
4342 }
4343 }
4344
4345 // TestApprovalCtxCancel ensures a cancelled turn unblocks the gate with an error
4346 // (rather than hanging) when no one answers.
4347 func TestApprovalCtxCancel(t *testing.T) {
4348 c := New(Options{Sink: event.Discard})
4349 ctx, cancel := context.WithCancel(context.Background())
4350 cancel()
4351
4352 allow, _, err := gateApprover{c}.Approve(ctx, "bash", "x", nil)
4353 if err == nil || allow {
4354 t.Fatalf("Approve on cancelled ctx = (%v,%v), want (false, error)", allow, err)
4355 }
4356 }
4357
4358 func TestParseRewind(t *testing.T) {
4359 cps := []checkpoint.Meta{
4360 {Turn: 0, Prompt: "first"},
4361 {Turn: 1, Prompt: "second"},
4362 {Turn: 2, Prompt: "third"},
4363 }
4364 cases := []struct {
4365 args string
4366 wantT int
4367 wantS RewindScope
4368 wantErr bool
4369 }{
4370 {"", 2, RewindBoth, false}, // no args -> latest turn, both
4371 {"1", 1, RewindBoth, false}, // turn only
4372 {"0 code", 0, RewindCode, false}, // turn + code
4373 {"1 conversation", 1, RewindConversation, false}, // turn + conversation
4374 {"2 both", 2, RewindBoth, false}, // turn + both
4375 {"abc", 0, RewindBoth, true}, // invalid turn
4376 {"0 unknown", 0, RewindBoth, true}, // unknown scope
4377 }
4378 for _, tc := range cases {
4379 t.Run(tc.args, func(t *testing.T) {
4380 gotT, gotS, err := parseRewind(tc.args, cps)
4381 if (err != nil) != tc.wantErr {
4382 t.Fatalf("parseRewind(%q) err=%v, wantErr=%v", tc.args, err, tc.wantErr)
4383 }
4384 if err != nil {
4385 return
4386 }
4387 if gotT != tc.wantT || gotS != tc.wantS {
4388 t.Fatalf("parseRewind(%q) = (%d,%d), want (%d,%d)", tc.args, gotT, gotS, tc.wantT, tc.wantS)
4389 }
4390 })
4391 }
4392 }
4393
4394 func TestParseRewindEmptyCheckpoints(t *testing.T) {
4395 _, _, err := parseRewind("", nil)
4396 if err == nil {
4397 t.Fatal("expected error when no checkpoints")
4398 }
4399 }
4400
4401 func TestRunGuardedPanicEmitsTurnDone(t *testing.T) {
4402 sess := agent.NewSession("sys")
4403 events := make(chan event.Event, 4)
4404 c := New(Options{
4405 Runner: appendingRunner{session: sess},
4406 Sink: event.FuncSink(func(e event.Event) { events <- e }),
4407 })
4408
4409 go func() {
4410 c.runGuarded(func(ctx context.Context) error {
4411 panic("boom")
4412 })
4413 }()
4414
4415 select {
4416 case e := <-events:
4417 if e.Kind != event.TurnDone {
4418 t.Fatalf("expected TurnDone after panic, got %v", e.Kind)
4419 }
4420 if e.Err == nil || !strings.Contains(e.Err.Error(), "boom") {
4421 t.Fatalf("expected TurnDone.Err to contain panic message, got %v", e.Err)
4422 }
4423 case <-time.After(30 * time.Second):
4424 t.Fatal("timed out waiting for TurnDone after panic")
4425 }
4426
4427 c.mu.Lock()
4428 running := c.running
4429 c.mu.Unlock()
4430 if running {
4431 t.Fatal("c.running should be false after panic recovery")
4432 }
4433 }
4434
4435 // TestRunGuardedParksReplacementUntilTurnDoneReturns pins the finishing-window
4436 // admission contract from both sides: a replacement turn arriving while
4437 // TurnDone is being delivered must NOT start inside the window (the original
4438 // transport-crosstalk guarantee this window exists for), and it must START —
4439 // exactly once — when the window closes. The second half replaces the old
4440 // silent-drop behavior, which lost real input: every caller that submits upon
4441 // seeing turn_done (a frontend's queued auto-send, a bot, a fast Enter) raced
4442 // this window, observed as a CI-flaky lost turn and worked around in
4443 // Composer.tsx by gating auto-send on submitDisabled instead of turn_done.
4444 func TestRunGuardedParksReplacementUntilTurnDoneReturns(t *testing.T) {
4445 firstTurnDone := make(chan struct{})
4446 releaseTurnDone := make(chan struct{})
4447 firstBodyDone := make(chan struct{})
4448 secondBodyRan := make(chan struct{}, 2)
4449 var turnDones int32
4450 c := New(Options{Sink: event.FuncSink(func(e event.Event) {
4451 if e.Kind == event.TurnDone {
4452 if atomic.AddInt32(&turnDones, 1) == 1 {
4453 close(firstTurnDone)
4454 <-releaseTurnDone
4455 }
4456 }
4457 })})
4458
4459 if got := c.runGuarded(func(context.Context) error {
4460 close(firstBodyDone)
4461 return nil
4462 }); got != turnStarted {
4463 t.Fatalf("first admission = %v, want turnStarted", got)
4464 }
4465 <-firstBodyDone
4466 select {
4467 case <-firstTurnDone:
4468 case <-time.After(time.Second):
4469 t.Fatal("TurnDone delivery did not start")
4470 }
4471 if !c.RuntimeStatus().Running {
4472 t.Fatal("controller reported idle while TurnDone was still being delivered")
4473 }
4474 if got := c.runGuarded(func(context.Context) error {
4475 secondBodyRan <- struct{}{}
4476 return nil
4477 }); got != turnParked {
4478 t.Fatalf("finishing-window admission = %v, want turnParked", got)
4479 }
4480 select {
4481 case <-secondBodyRan:
4482 t.Fatal("replacement turn started before TurnDone delivery completed")
4483 case <-time.After(50 * time.Millisecond):
4484 }
4485
4486 close(releaseTurnDone)
4487 select {
4488 case <-secondBodyRan:
4489 case <-time.After(30 * time.Second):
4490 t.Fatal("parked turn was never started after the finishing window closed")
4491 }
4492 deadline := time.Now().Add(30 * time.Second)
4493 for c.Running() && time.Now().Before(deadline) {
4494 time.Sleep(time.Millisecond)
4495 }
4496 if c.Running() {
4497 t.Fatal("controller remained busy after the parked turn completed")
4498 }
4499 if got := atomic.LoadInt32(&turnDones); got != 2 {
4500 t.Fatalf("TurnDone emitted %d times, want 2 (one per turn)", got)
4501 }
4502 select {
4503 case <-secondBodyRan:
4504 t.Fatal("parked turn ran more than once")
4505 default:
4506 }
4507 }
4508
4509 func TestRunGuardedPanicDoesNotDoubleEmitTurnDone(t *testing.T) {
4510 sess := agent.NewSession("sys")
4511 var count int32
4512 events := make(chan event.Event, 8)
4513 c := New(Options{
4514 Runner: appendingRunner{session: sess},
4515 Sink: event.FuncSink(func(e event.Event) {
4516 if e.Kind == event.TurnDone {
4517 atomic.AddInt32(&count, 1)
4518 }
4519 events <- e
4520 }),
4521 })
4522
4523 go func() {
4524 c.runGuarded(func(ctx context.Context) error {
4525 panic("boom")
4526 })
4527 }()
4528
4529 deadline := time.After(30 * time.Second)
4530 for {
4531 select {
4532 case <-events:
4533 n := atomic.LoadInt32(&count)
4534 if n >= 1 {
4535 time.Sleep(50 * time.Millisecond)
4536 n2 := atomic.LoadInt32(&count)
4537 if n2 > 1 {
4538 t.Fatalf("TurnDone emitted %d times, expected 1", n2)
4539 }
4540 return
4541 }
4542 case <-deadline:
4543 t.Fatal("timed out waiting for TurnDone")
4544 }
4545 }
4546 }
4547
4548 type blockingRunner struct {
4549 session *agent.Session
4550 release chan struct{}
4551 }
4552
4553 func (r blockingRunner) Run(_ context.Context, input string) error {
4554 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
4555 <-r.release
4556 return nil
4557 }
4558
4559 func TestRunTurnReportsErrTurnRunning(t *testing.T) {
4560 sess := agent.NewSession("sys")
4561 release := make(chan struct{})
4562 c := New(Options{Runner: blockingRunner{session: sess, release: release}})
4563
4564 done := make(chan error, 1)
4565 go func() {
4566 done <- c.RunTurn(context.Background(), "first")
4567 }()
4568 waitForRunning(t, c)
4569
4570 if err := c.RunTurn(context.Background(), "second"); err != ErrTurnRunning {
4571 t.Fatalf("RunTurn while running error = %v, want ErrTurnRunning", err)
4572 }
4573
4574 close(release)
4575 select {
4576 case err := <-done:
4577 if err != nil {
4578 t.Fatalf("first RunTurn returned %v", err)
4579 }
4580 case <-time.After(30 * time.Second):
4581 t.Fatal("first RunTurn did not finish after release")
4582 }
4583 }
4584
4585 func TestSendWhileRunningDoesNotInterleaveTurns(t *testing.T) {
4586 sess := agent.NewSession("sys")
4587 release := make(chan struct{})
4588 events := make(chan event.Event, 4)
4589 c := New(Options{
4590 Runner: blockingRunner{session: sess, release: release},
4591 Sink: event.FuncSink(func(e event.Event) {
4592 events <- e
4593 }),
4594 })
4595 defer c.autosaveWG.Wait()
4596
4597 c.Send("first")
4598 waitForRunning(t, c)
4599 c.Send("second")
4600 close(release)
4601 waitForTurnDone(t, events)
4602
4603 var users []string
4604 for _, m := range sess.Messages {
4605 if m.Role == provider.RoleUser {
4606 users = append(users, m.Content)
4607 }
4608 }
4609 if len(users) != 1 || users[0] != "first" {
4610 t.Fatalf("user turns = %v, want only first turn recorded", users)
4611 }
4612 }
4613
4614 func waitForRunning(t *testing.T, c *Controller) {
4615 t.Helper()
4616 deadline := time.Now().Add(2 * time.Second)
4617 for time.Now().Before(deadline) {
4618 if c.Running() {
4619 return
4620 }
4621 time.Sleep(10 * time.Millisecond)
4622 }
4623 t.Fatal("controller did not enter running state")
4624 }
4625
4626 func TestMidTurnAutosavePersistsDuringLongTurn(t *testing.T) {
4627 old := midTurnSnapshotInterval.Load()
4628 midTurnSnapshotInterval.Store(int64(10 * time.Millisecond))
4629 defer midTurnSnapshotInterval.Store(old)
4630
4631 dir := t.TempDir()
4632 sess := agent.NewSession("sys")
4633 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
4634 path := filepath.Join(dir, "session.jsonl")
4635 release := make(chan struct{})
4636 c := New(Options{Runner: blockingRunner{session: sess, release: release}, Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
4637 // Unblock the turn and wait for the autosaver to exit before TempDir
4638 // cleanup, which fails on Windows while a snapshot tmp write is in flight.
4639 defer c.autosaveWG.Wait()
4640 defer close(release)
4641
4642 c.Send("hello mid-turn persistence")
4643
4644 deadline := time.Now().Add(3 * time.Second)
4645 for time.Now().Before(deadline) {
4646 if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), "hello mid-turn persistence") {
4647 return
4648 }
4649 time.Sleep(5 * time.Millisecond)
4650 }
4651 t.Fatal("session file was not written while the turn was still running")
4652 }
4653
4654 type scriptedRunner struct {
4655 exec *agent.Agent
4656 scripts []func(input string)
4657 }
4658
4659 func (r *scriptedRunner) Run(_ context.Context, input string) error {
4660 if len(r.scripts) == 0 {
4661 return nil
4662 }
4663 next := r.scripts[0]
4664 r.scripts = r.scripts[1:]
4665 next(input)
4666 return nil
4667 }
4668
4669 func TestApprovedPlanAutoApproveEndsWithExecutionTurn(t *testing.T) {
4670 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
4671 runner := &scriptedRunner{exec: exec}
4672
4673 var c *Controller
4674 approvalPrompts := 0
4675 sink := event.FuncSink(func(e event.Event) {
4676 if e.Kind != event.ApprovalRequest {
4677 return
4678 }
4679 approvalPrompts++
4680 if e.Approval.Tool == planApprovalTool {
4681 go c.Approve(e.Approval.ID, true, false, false)
4682 return
4683 }
4684 go c.Approve(e.Approval.ID, false, false, false)
4685 })
4686 c = New(Options{Runner: runner, Executor: exec, Sink: sink})
4687 c.SetPlanMode(true)
4688
4689 runner.scripts = append(runner.scripts,
4690 func(input string) {
4691 exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "1. Create the file\n2. Update the file"})
4692 },
4693 func(input string) {
4694 if input != planApprovedMessage {
4695 t.Fatalf("approved execution input = %q, want planApprovedMessage", input)
4696 }
4697 exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "first step done; paused for review", ToolCalls: []provider.ToolCall{{
4698 ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"Create the file","status":"completed"},{"content":"Update the file","status":"in_progress"}]}`,
4699 }}})
4700 },
4701 )
4702
4703 if err := c.runTurn(context.Background(), "plan this"); err != nil {
4704 t.Fatal(err)
4705 }
4706 if approvalPrompts != 1 {
4707 t.Fatalf("approval prompts after plan = %d, want 1", approvalPrompts)
4708 }
4709
4710 // The plan approval auto-approves writers for the execution turn only. A later
4711 // turn does not inherit it, and "继续" carries no special meaning — Compose must
4712 // not inject any marker, and the next writer falls back to per-tool approval.
4713 if got := c.Compose("继续"); StripComposePrefixes(got) != "继续" {
4714 t.Fatalf("a paused approved plan must not marker-prefix the next turn, got %q", got)
4715 }
4716 allow, _, err := gateApprover{c}.Approve(context.Background(), "write_file", "/tmp/a", nil)
4717 if err != nil {
4718 t.Fatal(err)
4719 }
4720 if allow {
4721 t.Fatal("writer after the execution turn should return to per-tool approval, not auto-allow")
4722 }
4723 if approvalPrompts != 2 {
4724 t.Fatalf("writer after the execution turn should prompt, prompts=%d", approvalPrompts)
4725 }
4726 }
4727
4728 func TestApprovedPlanDoesNotAutoApproveNonContinuationTurn(t *testing.T) {
4729 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
4730 runner := &scriptedRunner{exec: exec}
4731
4732 var c *Controller
4733 approvalPrompts := 0
4734 sink := event.FuncSink(func(e event.Event) {
4735 if e.Kind != event.ApprovalRequest {
4736 return
4737 }
4738 approvalPrompts++
4739 if e.Approval.Tool == planApprovalTool {
4740 go c.Approve(e.Approval.ID, true, false, false)
4741 return
4742 }
4743 go c.Approve(e.Approval.ID, false, false, false)
4744 })
4745 c = New(Options{Runner: runner, Executor: exec, Sink: sink})
4746 c.SetPlanMode(true)
4747
4748 runner.scripts = append(runner.scripts,
4749 func(input string) {
4750 exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "1. Create the file\n2. Update the file"})
4751 },
4752 func(input string) {
4753 exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "paused", ToolCalls: []provider.ToolCall{{
4754 ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"Create the file","status":"completed"},{"content":"Update the file","status":"in_progress"}]}`,
4755 }}})
4756 },
4757 )
4758
4759 if err := c.runTurn(context.Background(), "plan this"); err != nil {
4760 t.Fatal(err)
4761 }
4762 if got := c.Compose("先别继续"); StripComposePrefixes(got) != "先别继续" {
4763 t.Fatalf("non-continuation input should not be marker-prefixed, got %q", got)
4764 }
4765
4766 allow, _, err := gateApprover{c}.Approve(context.Background(), "write_file", "/tmp/a", nil)
4767 if err != nil {
4768 t.Fatal(err)
4769 }
4770 if allow {
4771 t.Fatal("non-continuation turn should not inherit approved-plan auto approval")
4772 }
4773 if approvalPrompts != 2 {
4774 t.Fatalf("non-continuation writer should prompt after plan approval, prompts=%d", approvalPrompts)
4775 }
4776 }
4777
4778 // writeCmdFile creates a command .md file with frontmatter under dir.
4779 func writeCmdFile(t *testing.T, dir, name, description, body string) {
4780 t.Helper()
4781 if err := os.MkdirAll(dir, 0o755); err != nil {
4782 t.Fatal(err)
4783 }
4784 content := fmt.Sprintf("---\ndescription: %s\n---\n%s\n", description, body)
4785 if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte(content), 0o644); err != nil {
4786 t.Fatal(err)
4787 }
4788 }
4789
4790 // TestCommandsAtomicPointer verifies that commands passed via Options.Commands are
4791 // correctly exposed through the atomic-pointer Commands() getter and that
4792 // CustomCommand resolves and renders them. Missing commands return found=false.
4793 func TestCommandsAtomicPointer(t *testing.T) {
4794 cmds := []command.Command{
4795 {Name: "review", Description: "Review code", Body: "Review $1"},
4796 {Name: "test", Description: "Run tests", Body: "Test $1"},
4797 }
4798 c := New(Options{
4799 Commands: cmds,
4800 Sink: &typedNilControllerSink{},
4801 Registry: tool.NewRegistry(),
4802 })
4803
4804 // Commands() returns what was passed via Options
4805 got := c.Commands()
4806 if len(got) != 2 {
4807 t.Fatalf("Commands() = %d, want 2", len(got))
4808 }
4809 // Check retrieval via CustomCommand
4810 sent, ok := c.CustomCommand("/review myfile.go")
4811 if !ok {
4812 t.Error("/review should be found")
4813 }
4814 if !strings.Contains(sent, "Review myfile.go") {
4815 t.Errorf("unexpected render: %q", sent)
4816 }
4817 _, ok = c.CustomCommand("/missing")
4818 if ok {
4819 t.Error("/missing should not be found")
4820 }
4821
4822 // Commands() getter uses atomic.Pointer internally
4823 if cmds2 := c.Commands(); len(cmds2) != 2 {
4824 t.Errorf("Commands() = %d after change, want 2", len(cmds2))
4825 }
4826 }
4827
4828 // TestReloadCommandsFromFilesystem exercises ReloadCommands against real .md
4829 // files in a temp workspace: initial load, hot-reload with a new file, and
4830 // hot-reload after modifying an existing file. Also verifies that skills are
4831 // preserved across the reload.
4832 func TestReloadCommandsFromFilesystem(t *testing.T) {
4833 // Isolate HOME so CommandDirsForRoot does not pick up global .md command files.
4834 home := t.TempDir()
4835 t.Setenv("HOME", home)
4836 t.Setenv("USERPROFILE", home)
4837 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
4838 t.Setenv("AppData", filepath.Join(home, "AppData"))
4839
4840 wsRoot := t.TempDir()
4841 cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
4842 writeCmdFile(t, cmdDir, "review", "Review code", "Review $1")
4843 writeCmdFile(t, cmdDir, "test", "Run tests", "Test $1")
4844
4845 // Create a minimal in-memory skill to verify skills are preserved across reload.
4846 sk := skill.Skill{
4847 Name: "myskill",
4848 Description: "Test skill",
4849 Body: "You are a test skill. User says: {{.Input}}",
4850 }
4851
4852 reg := tool.NewRegistry()
4853 c := New(Options{
4854 Sink: &typedNilControllerSink{},
4855 Registry: reg,
4856 WorkspaceRoot: wsRoot,
4857 Skills: []skill.Skill{sk},
4858 })
4859
4860 // ReloadCommands should pick up the two .md files and preserve the skill.
4861 if err := c.ReloadCommands(context.Background()); err != nil {
4862 t.Fatalf("ReloadCommands: %v", err)
4863 }
4864 cmds := c.Commands()
4865 if len(cmds) != 2 {
4866 t.Fatalf("Commands() = %d after reload, want 2", len(cmds))
4867 }
4868
4869 // CustomCommand should resolve through the hot-swapped getter
4870 sent, ok := c.CustomCommand("/review hello.go")
4871 if !ok {
4872 t.Fatal("/review should be found after reload")
4873 }
4874 if !strings.Contains(sent, "Review hello.go") {
4875 t.Errorf("render = %q, want Review hello.go", sent)
4876 }
4877
4878 // Missing command still not found
4879 if _, ok := c.CustomCommand("/nope"); ok {
4880 t.Error("/nope should not be found")
4881 }
4882
4883 // Skill should appear in the slash_command tool's description after reload.
4884 if tool, found := reg.Get("slash_command"); found {
4885 if !strings.Contains(tool.Description(), "myskill") {
4886 t.Error("skill 'myskill' should appear in slash_command tool Description after ReloadCommands")
4887 }
4888 } else {
4889 t.Error("slash_command tool should be registered after ReloadCommands")
4890 }
4891
4892 // Skill should still be callable via RunSkill after reload.
4893 if _, ok := c.RunSkill("/myskill"); !ok {
4894 t.Error("RunSkill(/myskill) should find the skill after ReloadCommands")
4895 }
4896
4897 // Hot-reload: add a new command file
4898 writeCmdFile(t, cmdDir, "count", "Count to N", "Count from 1 to $1")
4899 if err := c.ReloadCommands(context.Background()); err != nil {
4900 t.Fatalf("ReloadCommands (add): %v", err)
4901 }
4902 if cmds := c.Commands(); len(cmds) != 3 {
4903 t.Fatalf("Commands() = %d after add, want 3", len(cmds))
4904 }
4905
4906 // Hot-reload: modify an existing command
4907 writeCmdFile(t, cmdDir, "review", "Review code (friendly)", "Kindly review $1")
4908 if err := c.ReloadCommands(context.Background()); err != nil {
4909 t.Fatalf("ReloadCommands (modify): %v", err)
4910 }
4911 if cmds := c.Commands(); len(cmds) != 3 {
4912 t.Fatalf("Commands() = %d after modify, want 3", len(cmds))
4913 }
4914 sent, ok = c.CustomCommand("/review world.go")
4915 if !ok {
4916 t.Fatal("/review should be found after modify")
4917 }
4918 if !strings.Contains(sent, "Kindly review world.go") {
4919 t.Errorf("render after modify = %q, want Kindly review world.go", sent)
4920 }
4921
4922 // The slash_command tool should be registered and updated
4923 if _, found := reg.Get("slash_command"); !found {
4924 t.Error("slash_command tool should be registered after ReloadCommands")
4925 }
4926 }
4927
4928 // TestReloadCommandsDeleteFile verifies that removing a command .md file and
4929 // reloading causes the command to disappear from both Commands() and
4930 // CustomCommand(), while other commands remain intact.
4931 func TestReloadCommandsDeleteFile(t *testing.T) {
4932 home := t.TempDir()
4933 t.Setenv("HOME", home)
4934 t.Setenv("USERPROFILE", home)
4935 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
4936 t.Setenv("AppData", filepath.Join(home, "AppData"))
4937
4938 wsRoot := t.TempDir()
4939 cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
4940 writeCmdFile(t, cmdDir, "alpha", "Alpha cmd", "Alpha $1")
4941 writeCmdFile(t, cmdDir, "beta", "Beta cmd", "Beta $1")
4942
4943 reg := tool.NewRegistry()
4944 c := New(Options{
4945 Sink: &typedNilControllerSink{},
4946 Registry: reg,
4947 WorkspaceRoot: wsRoot,
4948 })
4949
4950 if err := c.ReloadCommands(context.Background()); err != nil {
4951 t.Fatalf("initial reload: %v", err)
4952 }
4953 if got := len(c.Commands()); got != 2 {
4954 t.Fatalf("Commands() = %d, want 2", got)
4955 }
4956
4957 // Delete alpha.md
4958 if err := os.Remove(filepath.Join(cmdDir, "alpha.md")); err != nil {
4959 t.Fatal(err)
4960 }
4961
4962 if err := c.ReloadCommands(context.Background()); err != nil {
4963 t.Fatalf("reload after delete: %v", err)
4964 }
4965 if got := len(c.Commands()); got != 1 {
4966 t.Fatalf("Commands() = %d after delete, want 1", got)
4967 }
4968
4969 // /alpha should no longer be found
4970 if _, ok := c.CustomCommand("/alpha x"); ok {
4971 t.Error("/alpha should NOT be found after deletion")
4972 }
4973 // /beta should still work
4974 sent, ok := c.CustomCommand("/beta y")
4975 if !ok {
4976 t.Error("/beta should still be found")
4977 }
4978 if !strings.Contains(sent, "Beta y") {
4979 t.Errorf("render = %q, want Beta y", sent)
4980 }
4981 }
4982
4983 // TestReloadCommandsMalformedFile verifies that a malformed .md file causes
4984 // ReloadCommands to return an error but does not prevent other valid commands
4985 // from loading.
4986 func TestReloadCommandsMalformedFile(t *testing.T) {
4987 home := t.TempDir()
4988 t.Setenv("HOME", home)
4989 t.Setenv("USERPROFILE", home)
4990 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
4991 t.Setenv("AppData", filepath.Join(home, "AppData"))
4992
4993 wsRoot := t.TempDir()
4994 cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
4995 writeCmdFile(t, cmdDir, "good", "Good cmd", "Good $1")
4996
4997 // Write a malformed file (no valid frontmatter)
4998 if err := os.MkdirAll(cmdDir, 0o755); err != nil {
4999 t.Fatal(err)
5000 }
5001 broken := filepath.Join(cmdDir, "broken.md")
5002 if err := os.WriteFile(broken, []byte("this is not valid yaml\n---\nrandom\n"), 0o644); err != nil {
5003 t.Fatal(err)
5004 }
5005
5006 reg := tool.NewRegistry()
5007 c := New(Options{
5008 Sink: &typedNilControllerSink{},
5009 Registry: reg,
5010 WorkspaceRoot: wsRoot,
5011 })
5012
5013 err := c.ReloadCommands(context.Background())
5014 // We expect some error from the malformed file
5015 if err == nil {
5016 t.Log("ReloadCommands returned nil error despite malformed file — command.Load may tolerate it")
5017 } else {
5018 t.Logf("ReloadCommands returned error (expected): %v", err)
5019 }
5020
5021 // The valid command should still be loadable
5022 cmds := c.Commands()
5023 foundGood := false
5024 for _, cmd := range cmds {
5025 if cmd.Name == "good" {
5026 foundGood = true
5027 }
5028 }
5029 if !foundGood {
5030 t.Errorf("valid command 'good' should be present, got commands: %v", cmdNames(cmds))
5031 }
5032 }
5033
5034 // TestReloadCommandsSameNameAcrossDirs verifies that when the same command
5035 // name exists in multiple convention directories, the later-scanned directory
5036 // (higher priority) wins. ConventionDirs = [".reasonix", ".agents", ".agent",
5037 // ".claude"], scanned in reverse, so .reasonix is highest priority.
5038 func TestReloadCommandsSameNameAcrossDirs(t *testing.T) {
5039 home := t.TempDir()
5040 t.Setenv("HOME", home)
5041 t.Setenv("USERPROFILE", home)
5042 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
5043 t.Setenv("AppData", filepath.Join(home, "AppData"))
5044
5045 wsRoot := t.TempDir()
5046
5047 // Lower priority: .claude/commands
5048 claudeDir := filepath.Join(wsRoot, ".claude", "commands")
5049 writeCmdFile(t, claudeDir, "greet", "Claude greet", "Hello from Claude: $1")
5050
5051 // Higher priority: .reasonix/commands
5052 reasonixDir := filepath.Join(wsRoot, ".reasonix", "commands")
5053 writeCmdFile(t, reasonixDir, "greet", "Reasonix greet", "Hello from Reasonix: $1")
5054
5055 reg := tool.NewRegistry()
5056 c := New(Options{
5057 Sink: &typedNilControllerSink{},
5058 Registry: reg,
5059 WorkspaceRoot: wsRoot,
5060 })
5061
5062 if err := c.ReloadCommands(context.Background()); err != nil {
5063 t.Fatalf("reload: %v", err)
5064 }
5065
5066 // There should be exactly 1 command named "greet"
5067 cmds := c.Commands()
5068 count := 0
5069 for _, cmd := range cmds {
5070 if cmd.Name == "greet" {
5071 count++
5072 }
5073 }
5074 if count != 1 {
5075 t.Fatalf("expected exactly 1 'greet' command, got %d", count)
5076 }
5077
5078 // The winning version should be from .reasonix (highest priority)
5079 sent, ok := c.CustomCommand("/greet world")
5080 if !ok {
5081 t.Fatal("/greet should be found")
5082 }
5083 if !strings.Contains(sent, "Hello from Reasonix") {
5084 t.Errorf("expected .reasonix version to win, got render: %q", sent)
5085 }
5086 }
5087
5088 func TestReloadCommandsUsesCanonicalPluginNameAlongsideProjectShortName(t *testing.T) {
5089 home := t.TempDir()
5090 t.Setenv("HOME", home)
5091 t.Setenv("USERPROFILE", home)
5092 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
5093 t.Setenv("AppData", filepath.Join(home, "AppData"))
5094 reasonixHome := filepath.Join(home, ".reasonix")
5095 t.Setenv("REASONIX_HOME", reasonixHome)
5096
5097 pluginRoot := filepath.Join(reasonixHome, "plugins", "pwf")
5098 if err := os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0o755); err != nil {
5099 t.Fatal(err)
5100 }
5101 if err := os.WriteFile(filepath.Join(pluginRoot, pluginpkg.ClaudeManifest), []byte(`{"name":"pwf"}`), 0o644); err != nil {
5102 t.Fatal(err)
5103 }
5104 writeCmdFile(t, filepath.Join(pluginRoot, "commands"), "plan", "Plugin plan", "PLUGIN $1")
5105 writeCmdFile(t, filepath.Join(pluginRoot, "commands"), "status", "Plugin status", "STATUS $1")
5106 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{Name: "pwf", Root: "plugins/pwf", ManifestKind: "claude", Enabled: true}); err != nil {
5107 t.Fatal(err)
5108 }
5109
5110 workspace := t.TempDir()
5111 writeCmdFile(t, filepath.Join(workspace, ".reasonix", "commands"), "plan", "Project plan", "PROJECT $1")
5112 c := New(Options{Sink: &typedNilControllerSink{}, Registry: tool.NewRegistry(), WorkspaceRoot: workspace})
5113 if err := c.ReloadCommands(context.Background()); err != nil {
5114 t.Fatalf("ReloadCommands: %v", err)
5115 }
5116
5117 if got, ok := c.CustomCommand("/plan task"); !ok || got != "PROJECT task" {
5118 t.Fatalf("short command = %q, %v; want project winner", got, ok)
5119 }
5120 if got, ok := c.CustomCommand("/pwf:plan task"); !ok || got != "PLUGIN task" {
5121 t.Fatalf("qualified plugin command = %q, %v", got, ok)
5122 }
5123 if got, ok := c.CustomCommand("/status now"); !ok || got != "STATUS now" {
5124 t.Fatalf("hidden compatible short command = %q, %v", got, ok)
5125 }
5126 if got, ok := c.CustomCommand("/pwf:status now"); !ok || got != "STATUS now" {
5127 t.Fatalf("canonical plugin status command = %q, %v", got, ok)
5128 }
5129 cmds := c.Commands()
5130 canonicalFound := false
5131 hiddenFound := false
5132 for _, cmd := range cmds {
5133 if cmd.Name == "pwf:plan" && cmd.Plugin == "pwf" && cmd.ShortName == "plan" && !cmd.Hidden {
5134 canonicalFound = true
5135 }
5136 if cmd.Name == "status" && cmd.Plugin == "pwf" && cmd.ShortName == "status" && cmd.Hidden {
5137 hiddenFound = true
5138 }
5139 }
5140 if !canonicalFound || !hiddenFound {
5141 t.Fatalf("plugin command metadata missing: %+v", cmds)
5142 }
5143 }
5144
5145 // TestReloadCommandsEmptySet verifies that deleting all command files and
5146 // reloading results in an empty Commands() slice, while the slash_command tool
5147 // still exists in the Registry (containing only Skills).
5148 func TestReloadCommandsEmptySet(t *testing.T) {
5149 home := t.TempDir()
5150 t.Setenv("HOME", home)
5151 t.Setenv("USERPROFILE", home)
5152 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
5153 t.Setenv("AppData", filepath.Join(home, "AppData"))
5154
5155 wsRoot := t.TempDir()
5156 cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
5157 writeCmdFile(t, cmdDir, "temp", "Temp cmd", "Temp $1")
5158
5159 sk := skill.Skill{
5160 Name: "preserved",
5161 Description: "A skill to keep",
5162 Body: "Skill body: {{.Input}}",
5163 }
5164
5165 reg := tool.NewRegistry()
5166 c := New(Options{
5167 Sink: &typedNilControllerSink{},
5168 Registry: reg,
5169 WorkspaceRoot: wsRoot,
5170 Skills: []skill.Skill{sk},
5171 })
5172
5173 if err := c.ReloadCommands(context.Background()); err != nil {
5174 t.Fatalf("initial reload: %v", err)
5175 }
5176 if got := len(c.Commands()); got != 1 {
5177 t.Fatalf("Commands() = %d, want 1", got)
5178 }
5179
5180 // Delete all command files
5181 if err := os.Remove(filepath.Join(cmdDir, "temp.md")); err != nil {
5182 t.Fatal(err)
5183 }
5184
5185 if err := c.ReloadCommands(context.Background()); err != nil {
5186 t.Fatalf("reload after delete all: %v", err)
5187 }
5188 if got := len(c.Commands()); got != 0 {
5189 t.Fatalf("Commands() = %d after delete all, want 0", got)
5190 }
5191
5192 // /temp should no longer be found
5193 if _, ok := c.CustomCommand("/temp x"); ok {
5194 t.Error("/temp should NOT be found after deletion")
5195 }
5196
5197 // slash_command tool should still exist (for Skills)
5198 slashTool, found := reg.Get("slash_command")
5199 if !found {
5200 t.Fatal("slash_command tool should still exist even with 0 commands")
5201 }
5202 // It should still contain the skill
5203 if !strings.Contains(slashTool.Description(), "preserved") {
5204 t.Error("skill 'preserved' should still appear in slash_command tool Description")
5205 }
5206 }
5207
5208 // TestReloadCommandsDesktopManagementNotice verifies the desktop/HTTP path:
5209 // when the frontend submits "/reload-cmd" as raw input, Submit → managementNotice
5210 // handles it and emits a Notice event with the correct count.
5211 func TestReloadCommandsDesktopManagementNotice(t *testing.T) {
5212 home := t.TempDir()
5213 t.Setenv("HOME", home)
5214 t.Setenv("USERPROFILE", home)
5215 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
5216 t.Setenv("AppData", filepath.Join(home, "AppData"))
5217
5218 wsRoot := t.TempDir()
5219 cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
5220 writeCmdFile(t, cmdDir, "hello", "Greet", "Hello $1")
5221 writeCmdFile(t, cmdDir, "review", "Review code", "Review $1")
5222
5223 var notices []string
5224 sink := event.FuncSink(func(e event.Event) {
5225 if e.Kind == event.Notice {
5226 notices = append(notices, e.Text)
5227 }
5228 })
5229
5230 reg := tool.NewRegistry()
5231 c := New(Options{
5232 Sink: sink,
5233 Registry: reg,
5234 WorkspaceRoot: wsRoot,
5235 })
5236
5237 // Initial load so Commands() is populated.
5238 if err := c.ReloadCommands(context.Background()); err != nil {
5239 t.Fatalf("initial reload: %v", err)
5240 }
5241 notices = nil // reset
5242
5243 // Desktop path: managementNotice("/reload-cmd") should emit a notice.
5244 handled := c.managementNotice("/reload-cmd")
5245 if !handled {
5246 t.Fatal("managementNotice(/reload-cmd) should return true")
5247 }
5248 if len(notices) != 1 {
5249 t.Fatalf("expected 1 notice, got %d: %v", len(notices), notices)
5250 }
5251 if !strings.Contains(notices[0], "commands reloaded") {
5252 t.Errorf("notice = %q, want 'commands reloaded'", notices[0])
5253 }
5254 if !strings.Contains(notices[0], "2 available") {
5255 t.Errorf("notice = %q, want '2 available'", notices[0])
5256 }
5257
5258 // Delete one command file and reload again.
5259 if err := os.Remove(filepath.Join(cmdDir, "hello.md")); err != nil {
5260 t.Fatal(err)
5261 }
5262 notices = nil
5263 handled = c.managementNotice("/reload-cmd")
5264 if !handled {
5265 t.Fatal("managementNotice(/reload-cmd) after delete should return true")
5266 }
5267 if len(notices) != 1 {
5268 t.Fatalf("expected 1 notice after delete, got %d: %v", len(notices), notices)
5269 }
5270 if !strings.Contains(notices[0], "1 available") {
5271 t.Errorf("notice after delete = %q, want '1 available'", notices[0])
5272 }
5273
5274 // Delete all and verify empty-set notice.
5275 if err := os.Remove(filepath.Join(cmdDir, "review.md")); err != nil {
5276 t.Fatal(err)
5277 }
5278 notices = nil
5279 handled = c.managementNotice("/reload-cmd")
5280 if !handled {
5281 t.Fatal("managementNotice(/reload-cmd) empty set should return true")
5282 }
5283 if len(notices) != 1 {
5284 t.Fatalf("expected 1 notice for empty set, got %d: %v", len(notices), notices)
5285 }
5286 if !strings.Contains(notices[0], "0 available") {
5287 t.Errorf("notice for empty set = %q, want '0 available'", notices[0])
5288 }
5289 }
5290
5291 // cmdNames is a test helper that extracts command names from a slice.
5292 func cmdNames(cmds []command.Command) []string {
5293 names := make([]string, len(cmds))
5294 for i, c := range cmds {
5295 names[i] = c.Name
5296 }
5297 return names
5298 }
5299
5300 // TestCacheColdAfterFailureFallsBackTo24h:配置加载失败/模型解析失败时
5301 // 保守回退 24h(评审 #7168 第 4 点)——不得用 10m 提前触发 prune。
5302 func TestCacheColdAfterFailureFallsBackTo24h(t *testing.T) {
5303 c := New(Options{})
5304 orig := c.workspaceRoot
5305 c.workspaceRoot = "/nonexistent/definitely-missing-root"
5306 defer func() { c.workspaceRoot = orig }()
5307 if got := c.cacheColdAfter(); got != 24*time.Hour {
5308 t.Fatalf("load failure must fall back to 24h, got %v", got)
5309 }
5310 // 未知模型同样 24h
5311 c2 := New(Options{})
5312 c2.modelRef = "definitely-not-a-real-model-xyz"
5313 if got := c2.cacheColdAfter(); got != 24*time.Hour {
5314 t.Fatalf("ResolveModel failure must fall back to 24h, got %v", got)
5315 }
5316 }
5317
5317 lines GO