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