返回 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 ctrl := newOwnedTestController(t, Options{Host: plugin.NewHost(), WorkspaceRoot: workspace})
3004 defer ctrl.Close()
3005 if _, err := ctrl.AddMCPServer(config.PluginEntry{Name: "shared", Command: "global-shared"}); err == nil || !strings.Contains(err.Error(), "already configured") {
3006 t.Fatalf("AddMCPServer(shared) error = %v, want project collision", err)
3007 }
3008 if _, found := controlTestPluginByName(config.LoadForEdit(config.UserConfigPath()).Plugins, "shared"); found {
3009 t.Fatal("rejected project collision created a global shadow")
3010 }
3011 }
3012
3013 func TestConnectConfiguredProjectMCPIsTrustedByDefault(t *testing.T) {
3014 isolateControlConfigHome(t)
3015 workspace := t.TempDir()
3016 var requests atomic.Int32
3017 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3018 requests.Add(1)
3019 var req struct {
3020 ID json.RawMessage `json:"id"`
3021 Method string `json:"method"`
3022 }
3023 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
3024 http.Error(w, "bad request", http.StatusBadRequest)
3025 return
3026 }
3027 if len(req.ID) == 0 || string(req.ID) == "null" {
3028 w.WriteHeader(http.StatusAccepted)
3029 return
3030 }
3031 result := any(map[string]any{})
3032 switch req.Method {
3033 case "initialize":
3034 result = map[string]any{
3035 "protocolVersion": "2025-03-26",
3036 "serverInfo": map[string]any{"name": "project-docs", "version": "1"},
3037 }
3038 case "tools/list":
3039 result = map[string]any{"tools": []map[string]any{{
3040 "name": "search",
3041 "description": "Search project documentation.",
3042 "inputSchema": map[string]any{"type": "object"},
3043 }}}
3044 }
3045 w.Header().Set("Content-Type", "application/json")
3046 _ = json.NewEncoder(w).Encode(map[string]any{
3047 "jsonrpc": "2.0",
3048 "id": req.ID,
3049 "result": result,
3050 })
3051 }))
3052 defer server.Close()
3053 if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), fmt.Appendf(nil, `
3054 [[plugins]]
3055 name = "project-docs"
3056 type = "http"
3057 url = %q
3058 `, server.URL), 0o644); err != nil {
3059 t.Fatal(err)
3060 }
3061
3062 host := plugin.NewHost()
3063 defer host.Close()
3064 reg := tool.NewRegistry()
3065 var configured plugin.Spec
3066 ctrl := newOwnedTestController(t, Options{
3067 Host: host,
3068 Registry: reg,
3069 PluginCtx: context.Background(),
3070 WorkspaceRoot: workspace,
3071 MCPConfigureSpec: func(spec *plugin.Spec) {
3072 configured = *spec
3073 },
3074 })
3075
3076 n, err := ctrl.ConnectConfiguredMCPServer("project-docs")
3077 if err != nil {
3078 t.Fatalf("ConnectConfiguredMCPServer: %v", err)
3079 }
3080 if n != 1 || requests.Load() == 0 {
3081 t.Fatalf("trusted project MCP = %d tools, %d requests; want 1 tool and a live connection", n, requests.Load())
3082 }
3083 if _, ok := reg.Get("mcp__project-docs__search"); !ok {
3084 t.Fatalf("project MCP tool missing; names=%v", reg.Names())
3085 }
3086 if !configured.Authorized || configured.RequireLaunchApproval || configured.Dir != workspace {
3087 t.Fatalf("project MCP spec = %+v, want trusted project-scoped runtime", configured)
3088 }
3089
3090 nextHost := plugin.NewHost()
3091 defer nextHost.Close()
3092 nextCtrl := newOwnedTestController(t, Options{
3093 Host: nextHost,
3094 Registry: tool.NewRegistry(),
3095 PluginCtx: context.Background(),
3096 WorkspaceRoot: workspace,
3097 })
3098 if n, err := nextCtrl.ConnectConfiguredMCPServer("project-docs"); err != nil || n != 1 {
3099 t.Fatalf("subsequent project MCP connection = (%d, %v), want zero-confirmation trust", n, err)
3100 }
3101 }
3102
3103 func TestConnectMCPServerAppliesConfiguredCallTimeouts(t *testing.T) {
3104 server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3105 var req struct {
3106 ID json.RawMessage `json:"id"`
3107 Method string `json:"method"`
3108 }
3109 if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
3110 http.Error(w, "bad request", http.StatusBadRequest)
3111 return
3112 }
3113 if len(req.ID) == 0 || string(req.ID) == "null" {
3114 w.WriteHeader(http.StatusAccepted)
3115 return
3116 }
3117 var result any
3118 switch req.Method {
3119 case "initialize":
3120 result = map[string]any{
3121 "protocolVersion": "2025-03-26",
3122 "serverInfo": map[string]any{"name": "timeout-test", "version": "1"},
3123 }
3124 case "tools/list":
3125 result = map[string]any{"tools": []map[string]any{{
3126 "name": "slow",
3127 "description": "Wait until the caller cancels.",
3128 "inputSchema": map[string]any{"type": "object"},
3129 }}}
3130 case "tools/call":
3131 <-r.Context().Done()
3132 return
3133 default:
3134 result = map[string]any{}
3135 }
3136 w.Header().Set("Content-Type", "application/json")
3137 _ = json.NewEncoder(w).Encode(map[string]any{
3138 "jsonrpc": "2.0",
3139 "id": req.ID,
3140 "result": result,
3141 })
3142 }))
3143 defer server.Close()
3144
3145 tests := []struct {
3146 name string
3147 defaultTimeout time.Duration
3148 entry config.PluginEntry
3149 }{
3150 {
3151 name: "global default",
3152 defaultTimeout: time.Second,
3153 },
3154 {
3155 name: "server override",
3156 defaultTimeout: 10 * time.Second,
3157 entry: config.PluginEntry{CallTimeoutSeconds: 1},
3158 },
3159 {
3160 name: "tool override",
3161 defaultTimeout: 10 * time.Second,
3162 entry: config.PluginEntry{
3163 CallTimeoutSeconds: 10,
3164 ToolTimeoutSeconds: map[string]int{"slow": 1},
3165 },
3166 },
3167 }
3168 for i, tc := range tests {
3169 t.Run(tc.name, func(t *testing.T) {
3170 host := plugin.NewHost()
3171 defer host.Close()
3172 reg := tool.NewRegistry()
3173 ctrl := newOwnedTestController(t, Options{
3174 Host: host,
3175 Registry: reg,
3176 MCPDefaultCallTimeout: tc.defaultTimeout,
3177 })
3178 entry := tc.entry
3179 entry.Name = fmt.Sprintf("timeout%d", i)
3180 entry.Type = "http"
3181 entry.URL = server.URL
3182 if _, err := ctrl.ConnectMCPServer(entry); err != nil {
3183 t.Fatalf("ConnectMCPServer: %v", err)
3184 }
3185 connected, ok := reg.Get("mcp__" + entry.Name + "__slow")
3186 if !ok {
3187 t.Fatalf("connected tool missing; names=%v", reg.Names())
3188 }
3189 started := time.Now()
3190 _, err := connected.Execute(context.Background(), json.RawMessage(`{}`))
3191 elapsed := time.Since(started)
3192 if !errors.Is(err, context.DeadlineExceeded) {
3193 t.Fatalf("slow tool error = %v, want deadline exceeded", err)
3194 }
3195 if elapsed < 750*time.Millisecond || elapsed > 3*time.Second {
3196 t.Fatalf("slow tool elapsed = %v, want configured 1s timeout", elapsed)
3197 }
3198 })
3199 }
3200 }
3201
3202 func TestUnregisterMCPServerToolsBlocksLateSharedHostSwap(t *testing.T) {
3203 reg := tool.NewRegistry()
3204 reg.Add(fakeControlTool{name: "mcp__mock__connect"})
3205 c := newOwnedTestController(t, Options{Host: plugin.NewHost(), Registry: reg})
3206
3207 if ok := c.UnregisterMCPServerTools("mock"); !ok {
3208 t.Fatal("UnregisterMCPServerTools returned false")
3209 }
3210 reg.Add(fakeControlTool{name: "mcp__mock__echo"})
3211 if _, found := reg.Get("mcp__mock__echo"); found {
3212 t.Fatalf("late shared-host tool swap was accepted after unregister; names=%v", reg.Names())
3213 }
3214 reg.Add(fakeControlTool{name: "mcp__other__echo"})
3215 if _, found := reg.Get("mcp__other__echo"); !found {
3216 t.Fatalf("unregister blocked unrelated MCP tools; names=%v", reg.Names())
3217 }
3218 }
3219
3220 func TestRemoveMCPServerRemovesUnconnectedLazyPlaceholder(t *testing.T) {
3221 isolateControlConfigHome(t)
3222 dir := t.TempDir()
3223 home := t.TempDir()
3224 t.Setenv("HOME", home)
3225 t.Setenv("USERPROFILE", home)
3226 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
3227 t.Setenv("AppData", filepath.Join(home, "AppData", "Roaming"))
3228 t.Chdir(dir)
3229 if err := os.WriteFile("reasonix.toml", []byte(`
3230 [[plugins]]
3231 name = "mock"
3232 command = "mock-mcp"
3233 tier = "lazy"
3234 `), 0o644); err != nil {
3235 t.Fatalf("write config: %v", err)
3236 }
3237
3238 reg := tool.NewRegistry()
3239 reg.Add(fakeControlTool{name: "mcp__mock__connect"})
3240 host := plugin.NewHost()
3241 defer host.Close()
3242 spec := plugin.Spec{Name: "mock", Command: "mock-mcp", Authorized: true}
3243 runtime := agent.NewMCPCapabilityRuntime(context.Background(), host, []plugin.Spec{spec}, reg, nil)
3244 runtime.ConfigureServers([]config.PluginEntry{{Name: "mock", Command: "mock-mcp"}}, []plugin.Spec{spec}, map[string]bool{"mock": true})
3245 c := newOwnedTestController(t, Options{Host: host, Registry: reg, CapabilityRuntime: runtime, WorkspaceRoot: dir})
3246
3247 disconnected, err := c.RemoveMCPServer("mock")
3248 if err != nil {
3249 t.Fatalf("RemoveMCPServer: %v", err)
3250 }
3251 if disconnected {
3252 t.Fatal("RemoveMCPServer reported a live disconnect for an unconnected lazy placeholder")
3253 }
3254 if _, found := reg.Get("mcp__mock__connect"); found {
3255 t.Fatalf("lazy placeholder still registered after remove; names=%v", reg.Names())
3256 }
3257 if names := c.ConfiguredMCPNames(); len(names) != 0 {
3258 t.Fatalf("ConfiguredMCPNames() = %v, want empty after remove", names)
3259 }
3260 proxy := runtime.NewFrontend(nil, nil)
3261 listed, listErr := proxy.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
3262 if listErr != nil || strings.Contains(listed, `"name": "mock"`) {
3263 t.Fatalf("removed server leaked through capability list = %q, %v", listed, listErr)
3264 }
3265 }
3266
3267 func TestConfiguredMCPNamesUseControllerWorkspaceInsteadOfProcessCWD(t *testing.T) {
3268 isolateControlConfigHome(t)
3269 workspace := t.TempDir()
3270 other := t.TempDir()
3271 t.Chdir(other)
3272 if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(`
3273 [[plugins]]
3274 name = "workspace-mcp"
3275 command = "workspace-mcp"
3276 `), 0o644); err != nil {
3277 t.Fatal(err)
3278 }
3279
3280 c := newOwnedTestController(t, Options{WorkspaceRoot: workspace, Host: plugin.NewHost()})
3281 defer c.Close()
3282 if got := c.ConfiguredMCPNames(); !reflect.DeepEqual(got, []string{"workspace-mcp"}) {
3283 t.Fatalf("ConfiguredMCPNames() = %v, want workspace-mcp from %s", got, workspace)
3284 }
3285 if got := c.DisconnectedMCPNames(); !reflect.DeepEqual(got, []string{"workspace-mcp"}) {
3286 t.Fatalf("DisconnectedMCPNames() = %v, want workspace-mcp from %s", got, workspace)
3287 }
3288 }
3289
3290 func TestRemoveMCPServerKeepsRuntimeOnlyToolsWhenPersistenceRemovalFails(t *testing.T) {
3291 isolateControlConfigHome(t)
3292 reg := tool.NewRegistry()
3293 reg.Add(fakeControlTool{name: "mcp__runtime_only__echo"})
3294 c := newOwnedTestController(t, Options{Host: plugin.NewHost(), Registry: reg})
3295
3296 if disconnected, err := c.RemoveMCPServer("runtime_only"); err == nil || disconnected || !strings.Contains(err.Error(), "no removable MCP server") {
3297 t.Fatalf("RemoveMCPServer(runtime_only) = (%v, %v)", disconnected, err)
3298 }
3299 if _, found := reg.Get("mcp__runtime_only__echo"); !found {
3300 t.Fatalf("runtime-only tool was removed despite failed persistence; names=%v", reg.Names())
3301 }
3302 }
3303
3304 func TestRemoveMCPServerRejectsPluginManagedTools(t *testing.T) {
3305 home := isolateControlConfigHome(t)
3306 reasonixHome := filepath.Join(home, ".reasonix")
3307 t.Setenv("REASONIX_HOME", reasonixHome)
3308 root := filepath.Join(reasonixHome, "plugins", "superpowers")
3309 if err := os.MkdirAll(root, 0o755); err != nil {
3310 t.Fatal(err)
3311 }
3312 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 {
3313 t.Fatal(err)
3314 }
3315 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
3316 Name: "superpowers",
3317 Root: "plugins/superpowers",
3318 Version: "1.0.0",
3319 ManifestKind: "reasonix",
3320 Enabled: true,
3321 }); err != nil {
3322 t.Fatal(err)
3323 }
3324
3325 reg := tool.NewRegistry()
3326 reg.Add(fakeControlTool{name: "mcp__helper__echo"})
3327 c := newOwnedTestController(t, Options{Host: plugin.NewHost(), Registry: reg})
3328 disconnected, err := c.RemoveMCPServer("helper")
3329 if err == nil || disconnected || !strings.Contains(err.Error(), "managed by plugin") || !strings.Contains(err.Error(), "superpowers") {
3330 t.Fatalf("RemoveMCPServer(plugin-managed) = (%v, %v)", disconnected, err)
3331 }
3332 if _, found := reg.Get("mcp__helper__echo"); !found {
3333 t.Fatalf("plugin-managed tool was removed despite rejected removal; names=%v", reg.Names())
3334 }
3335 if disconnected := c.DisconnectMCPServer("helper"); !disconnected {
3336 t.Fatal("session-only disconnect should be allowed for a plugin-managed MCP")
3337 }
3338 if _, found := reg.Get("mcp__helper__echo"); found {
3339 t.Fatalf("plugin-managed tool survived session-only disconnect; names=%v", reg.Names())
3340 }
3341 }
3342
3343 func TestRemoveMCPServerDeletesProjectMCPJSONSource(t *testing.T) {
3344 isolateControlConfigHome(t)
3345 if err := os.WriteFile(".mcp.json", []byte(`{
3346 "mcpServers": {
3347 "mock": { "command": "mock-mcp" },
3348 "keep": { "command": "keep-mcp" }
3349 }
3350 }`), 0o644); err != nil {
3351 t.Fatal(err)
3352 }
3353
3354 reg := tool.NewRegistry()
3355 reg.Add(fakeControlTool{name: "mcp__mock__connect"})
3356 c := newOwnedTestController(t, Options{Host: plugin.NewHost(), Registry: reg})
3357 disconnected, err := c.RemoveMCPServer("mock")
3358 if err != nil {
3359 t.Fatalf("RemoveMCPServer(.mcp.json): %v", err)
3360 }
3361 if disconnected {
3362 t.Fatal("RemoveMCPServer reported a live disconnect for an idle .mcp.json server")
3363 }
3364 if _, found := reg.Get("mcp__mock__connect"); found {
3365 t.Fatalf(".mcp.json placeholder survived removal; names=%v", reg.Names())
3366 }
3367 raw, err := os.ReadFile(".mcp.json")
3368 if err != nil {
3369 t.Fatal(err)
3370 }
3371 if strings.Contains(string(raw), `"mock"`) || !strings.Contains(string(raw), `"keep"`) {
3372 t.Fatalf(".mcp.json removal did not preserve unrelated servers:\n%s", raw)
3373 }
3374 }
3375
3376 // approvalIDs returns a Controller whose Sink forwards each ApprovalRequest's ID
3377 // onto the channel, plus a counter of how many requests it emitted.
3378 func approvalIDs(t *testing.T) (*Controller, chan string, *int) {
3379 ids := make(chan string, 8)
3380 prompts := 0
3381 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
3382 if e.Kind == event.ApprovalRequest {
3383 prompts++
3384 ids <- e.Approval.ID
3385 }
3386 })})
3387 return c, ids, &prompts
3388 }
3389
3390 func permissionHookController(t *testing.T, match string) (*Controller, chan string, chan hook.Payload) {
3391 t.Helper()
3392 ids := make(chan string, 8)
3393 payloads := make(chan hook.Payload, 8)
3394 spawner := func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
3395 var payload hook.Payload
3396 if err := json.Unmarshal([]byte(in.Stdin), &payload); err != nil {
3397 t.Errorf("permission hook payload json: %v", err)
3398 }
3399 payloads <- payload
3400 return hook.SpawnResult{ExitCode: 0}
3401 }
3402 c := newOwnedTestController(t, Options{
3403 Sink: event.FuncSink(func(e event.Event) {
3404 if e.Kind == event.ApprovalRequest {
3405 ids <- e.Approval.ID
3406 }
3407 }),
3408 Hooks: hook.NewRunner([]hook.ResolvedHook{{
3409 HookConfig: hook.HookConfig{Command: "notify", Match: match},
3410 Event: hook.PermissionRequest,
3411 Scope: hook.ScopeGlobal,
3412 }}, "/tmp", spawner, nil),
3413 })
3414 return c, ids, payloads
3415 }
3416
3417 // claudePermissionHookController wires a Claude-imported PermissionRequest
3418 // hook (PayloadFormat "claude") whose mock spawner always returns stdout, so
3419 // tests can assert the hook's decision preempts the approval prompt instead
3420 // of only notifying — matching Claude's own PermissionRequest contract.
3421 func claudePermissionHookController(t *testing.T, exitCode int, stdout string) (*Controller, chan string) {
3422 t.Helper()
3423 ids := make(chan string, 8)
3424 spawner := func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
3425 return hook.SpawnResult{ExitCode: exitCode, Stdout: stdout}
3426 }
3427 c := newOwnedTestController(t, Options{
3428 Sink: event.FuncSink(func(e event.Event) {
3429 if e.Kind == event.ApprovalRequest {
3430 ids <- e.Approval.ID
3431 }
3432 }),
3433 Hooks: hook.NewRunner([]hook.ResolvedHook{{
3434 HookConfig: hook.HookConfig{Command: "guard", Match: "Bash", PayloadFormat: "claude"},
3435 Event: hook.PermissionRequest,
3436 Scope: hook.ScopeGlobal,
3437 }}, "/tmp", spawner, nil),
3438 })
3439 return c, ids
3440 }
3441
3442 func TestPermissionRequestClaudeHookAutoDenies(t *testing.T) {
3443 c, ids := claudePermissionHookController(t, 2, "")
3444 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", "rm -rf /", json.RawMessage(`{"command":"rm -rf /"}`))
3445 if err != nil {
3446 t.Fatalf("Approve error = %v", err)
3447 }
3448 if allow {
3449 t.Fatal("a Claude PermissionRequest hook exiting 2 should auto-deny")
3450 }
3451 select {
3452 case id := <-ids:
3453 t.Fatalf("auto-deny must preempt the approval prompt, but one was emitted: %s", id)
3454 case <-time.After(50 * time.Millisecond):
3455 }
3456 }
3457
3458 func TestPermissionRequestClaudeHookAutoAllows(t *testing.T) {
3459 allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
3460 c, ids := claudePermissionHookController(t, 0, allowJSON)
3461 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", json.RawMessage(`{"command":"go test ./..."}`))
3462 if err != nil {
3463 t.Fatalf("Approve error = %v", err)
3464 }
3465 if !allow {
3466 t.Fatal("a Claude PermissionRequest hook returning decision.behavior=allow should auto-allow")
3467 }
3468 select {
3469 case id := <-ids:
3470 t.Fatalf("auto-allow must preempt the approval prompt, but one was emitted: %s", id)
3471 case <-time.After(50 * time.Millisecond):
3472 }
3473 }
3474
3475 // wildcardClaudePermissionHookController is like claudePermissionHookController
3476 // but matches every tool, for exercising fresh-human-required tools whose
3477 // names ("remember", "sandbox_escape", ...) aren't Claude tool names.
3478 func wildcardClaudePermissionHookController(t *testing.T, exitCode int, stdout string) (*Controller, chan string) {
3479 t.Helper()
3480 ids := make(chan string, 8)
3481 spawner := func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
3482 return hook.SpawnResult{ExitCode: exitCode, Stdout: stdout}
3483 }
3484 c := newOwnedTestController(t, Options{
3485 Sink: event.FuncSink(func(e event.Event) {
3486 if e.Kind == event.ApprovalRequest {
3487 ids <- e.Approval.ID
3488 }
3489 }),
3490 Hooks: hook.NewRunner([]hook.ResolvedHook{{
3491 HookConfig: hook.HookConfig{Command: "guard", PayloadFormat: "claude"},
3492 Event: hook.PermissionRequest,
3493 Scope: hook.ScopeGlobal,
3494 }}, "/tmp", spawner, nil),
3495 })
3496 return c, ids
3497 }
3498
3499 func TestPermissionRequestClaudeHookCannotAutoAllowFreshHumanApproval(t *testing.T) {
3500 allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
3501 for _, tool := range []string{memoryRememberTool, memoryForgetTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool} {
3502 t.Run(tool, func(t *testing.T) {
3503 c, ids := wildcardClaudePermissionHookController(t, 0, allowJSON)
3504 done := make(chan bool, 1)
3505 go func() {
3506 allow, _, err := gateApprover{c}.Approve(context.Background(), tool, "", json.RawMessage(`{}`))
3507 if err != nil {
3508 t.Errorf("Approve error = %v", err)
3509 return
3510 }
3511 done <- allow
3512 }()
3513
3514 id := waitApprovalID(t, ids)
3515 c.Approve(id, true, false, false)
3516 select {
3517 case allow := <-done:
3518 if !allow {
3519 t.Fatal("manual approval should still allow")
3520 }
3521 case <-time.After(30 * time.Second):
3522 t.Fatal("approval stayed blocked")
3523 }
3524 })
3525 }
3526 }
3527
3528 func TestPermissionRequestClaudeHookAutoDeniesFreshHumanApproval(t *testing.T) {
3529 // A deny is always safe to auto-honor, even for fresh-human tools —
3530 // refusing something that requires a human's blessing can't leak
3531 // unauthorized access the way an auto-allow could.
3532 for _, tool := range []string{memoryRememberTool, SandboxEscapeApprovalTool} {
3533 t.Run(tool, func(t *testing.T) {
3534 c, ids := wildcardClaudePermissionHookController(t, 2, "")
3535 allow, _, err := gateApprover{c}.Approve(context.Background(), tool, "", json.RawMessage(`{}`))
3536 if err != nil {
3537 t.Fatalf("Approve error = %v", err)
3538 }
3539 if allow {
3540 t.Fatal("a Claude PermissionRequest hook exiting 2 should auto-deny even a fresh-human tool")
3541 }
3542 select {
3543 case id := <-ids:
3544 t.Fatalf("auto-deny must preempt the approval prompt, but one was emitted: %s", id)
3545 case <-time.After(50 * time.Millisecond):
3546 }
3547 })
3548 }
3549 }
3550
3551 // TestPermissionRequestClaudeHookCannotAutoAllowOptsFreshOnlyDecision covers
3552 // the fresh-human protection's other branch: a tool that requestFreshApprovalDecision
3553 // marks fresh (opts.fresh=true) without being one of
3554 // RequiresFreshHumanApprovalTool's fixed cases. PlanModeReadOnlyCommandApprovalTool
3555 // is exactly that — the earlier tests only exercised tools protected via
3556 // requiresFreshApprovalTool(tool), not the opts.fresh flag alone.
3557 func TestPermissionRequestClaudeHookCannotAutoAllowOptsFreshOnlyDecision(t *testing.T) {
3558 if RequiresFreshHumanApprovalTool(agent.PlanModeReadOnlyCommandApprovalTool) {
3559 t.Fatal("test assumes this tool is fresh-only via opts.fresh, not RequiresFreshHumanApprovalTool")
3560 }
3561 allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
3562 c, ids := wildcardClaudePermissionHookController(t, 0, allowJSON)
3563 done := make(chan bool, 1)
3564 go func() {
3565 reply, err := c.requestFreshApprovalDecision(context.Background(), agent.PlanModeReadOnlyCommandApprovalTool, "ls", nil, "trust this read-only command prefix?")
3566 if err != nil {
3567 t.Errorf("requestFreshApprovalDecision error = %v", err)
3568 return
3569 }
3570 done <- reply.allow
3571 }()
3572
3573 id := waitApprovalID(t, ids)
3574 c.Approve(id, true, false, false)
3575 select {
3576 case allow := <-done:
3577 if !allow {
3578 t.Fatal("manual approval should still allow")
3579 }
3580 case <-time.After(30 * time.Second):
3581 t.Fatal("approval stayed blocked — a Claude hook allow should not have preempted this opts.fresh decision")
3582 }
3583 }
3584
3585 func waitApprovalID(t *testing.T, ids <-chan string) string {
3586 t.Helper()
3587 select {
3588 case id := <-ids:
3589 return id
3590 case <-time.After(30 * time.Second):
3591 t.Fatal("ApprovalRequest was not emitted")
3592 }
3593 return ""
3594 }
3595
3596 func waitPermissionHook(t *testing.T, payloads <-chan hook.Payload) hook.Payload {
3597 t.Helper()
3598 select {
3599 case payload := <-payloads:
3600 return payload
3601 case <-time.After(30 * time.Second):
3602 t.Fatal("PermissionRequest hook did not fire")
3603 }
3604 return hook.Payload{}
3605 }
3606
3607 func assertNoPermissionHook(t *testing.T, payloads <-chan hook.Payload) {
3608 t.Helper()
3609 select {
3610 case payload := <-payloads:
3611 t.Fatalf("PermissionRequest hook fired unexpectedly: %+v", payload)
3612 case <-time.After(50 * time.Millisecond):
3613 }
3614 }
3615
3616 // TestApprovalAllowOnce drives the happy path: the gate emits an ApprovalRequest,
3617 // the (fake) frontend answers allow, and the gate returns allow with no grant.
3618 func TestApprovalAllowOnce(t *testing.T) {
3619 c, ids, _ := approvalIDs(t)
3620 go func() { c.Approve(<-ids, true, false, false) }()
3621
3622 allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test", nil)
3623 if err != nil || !allow || remember {
3624 t.Fatalf("Approve = (%v,%v,%v), want allow once", allow, remember, err)
3625 }
3626 }
3627
3628 func TestMemoryApprovalRequestShowsRememberPayload(t *testing.T) {
3629 approvals := make(chan event.Approval, 1)
3630 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
3631 if e.Kind == event.ApprovalRequest {
3632 approvals <- e.Approval
3633 }
3634 })})
3635
3636 args := json.RawMessage(`{
3637 "name": "stable-retrieval-conclusion",
3638 "description": "History retrieval should reuse stable synthesized conclusions.",
3639 "type": "feedback",
3640 "body": "**Why:** repeated history scans are expensive.\n\n**How to apply:** save the stable summary as a memory document."
3641 }`)
3642 result := make(chan string, 1)
3643 go func() {
3644 allow, _, err := gateApprover{c}.Approve(context.Background(), "remember", "", args)
3645 if err != nil {
3646 result <- err.Error()
3647 return
3648 }
3649 if !allow {
3650 result <- "memory approval denied"
3651 return
3652 }
3653 result <- ""
3654 }()
3655
3656 var approval event.Approval
3657 select {
3658 case approval = <-approvals:
3659 case <-time.After(30 * time.Second):
3660 t.Fatal("memory approval request was not emitted")
3661 }
3662 for _, want := range []string{
3663 `Save/update memory "stable-retrieval-conclusion"`,
3664 "[feedback]",
3665 "History retrieval should reuse stable synthesized conclusions.",
3666 "repeated history scans are expensive",
3667 "save the stable summary",
3668 } {
3669 if !strings.Contains(approval.Subject, want) {
3670 t.Fatalf("approval subject %q does not contain %q", approval.Subject, want)
3671 }
3672 }
3673 if strings.Contains(approval.Subject, "\n") {
3674 t.Fatalf("approval subject should be compact for TUI rendering, got %q", approval.Subject)
3675 }
3676
3677 c.Approve(approval.ID, true, true, false)
3678 select {
3679 case msg := <-result:
3680 if msg != "" {
3681 t.Fatalf("Approve returned %s", msg)
3682 }
3683 case <-time.After(30 * time.Second):
3684 t.Fatal("memory approval stayed blocked after Approve")
3685 }
3686 }
3687
3688 func TestFreshHumanApprovalToolsBypassGuardianAndWaitForUser(t *testing.T) {
3689 guardianProv := &recordingProvider{
3690 name: "guardian",
3691 streams: [][]provider.Chunk{textTurn(`{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"authorized memory update"}`)},
3692 }
3693 guardianSess := guardian.NewSession(guardianProv, tool.NewRegistry(), guardian.PolicyPrompt(), "guardian-test", 0, nil, event.Discard)
3694 exec := agent.New(&recordingProvider{name: "executor"}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
3695
3696 approvals := make(chan event.Approval, 1)
3697 c := newOwnedTestController(t, Options{
3698 Executor: exec,
3699 Guardian: guardianSess,
3700 Sink: event.FuncSink(func(e event.Event) {
3701 if e.Kind == event.ApprovalRequest {
3702 approvals <- e.Approval
3703 }
3704 }),
3705 })
3706
3707 args := json.RawMessage(`{"name":"prefers-vitest","description":"Preferred test framework","body":"Use vitest for frontend tests."}`)
3708 type approveResult struct {
3709 allow bool
3710 remember bool
3711 err error
3712 }
3713 done := make(chan approveResult, 1)
3714 go func() {
3715 allow, remember, err := gateApprover{c}.Approve(context.Background(), "remember", "", args)
3716 done <- approveResult{allow: allow, remember: remember, err: err}
3717 }()
3718
3719 var approval event.Approval
3720 select {
3721 case approval = <-approvals:
3722 case <-time.After(30 * time.Second):
3723 t.Fatal("memory approval request was not emitted after Guardian allow")
3724 }
3725 if approval.Tool != "remember" {
3726 t.Fatalf("approval tool = %q, want remember", approval.Tool)
3727 }
3728 if len(guardianProv.requests) != 0 {
3729 t.Fatalf("guardian reviews = %d, want 0; approval has one authoritative user decision path", len(guardianProv.requests))
3730 }
3731 select {
3732 case got := <-done:
3733 t.Fatalf("Guardian must not auto-allow remember, got %+v", got)
3734 case <-time.After(50 * time.Millisecond):
3735 }
3736
3737 c.Approve(approval.ID, true, true, false)
3738 select {
3739 case got := <-done:
3740 if got.err != nil || !got.allow || got.remember {
3741 t.Fatalf("Approve = (%v,%v,%v), want manual allow without remember", got.allow, got.remember, got.err)
3742 }
3743 case <-time.After(30 * time.Second):
3744 t.Fatal("memory approval stayed blocked after manual Approve")
3745 }
3746 }
3747
3748 func TestLowRiskProjectMemoryCreateSkipsApprovalPrompt(t *testing.T) {
3749 store := memory.Store{Dir: t.TempDir()}
3750 approvals := 0
3751 c := newOwnedTestController(t, Options{
3752 Memory: &memory.Set{Store: store},
3753 Sink: event.FuncSink(func(e event.Event) {
3754 if e.Kind == event.ApprovalRequest {
3755 approvals++
3756 }
3757 }),
3758 })
3759 args := json.RawMessage(`{"name":"release-target","description":"Project release target","type":"project","body":"Release from main-v2."}`)
3760 allow, remember, reason, err := gateApprover{c}.ApproveWithReason(context.Background(), memoryRememberTool, "", args)
3761 if err != nil || !allow || remember || reason != "" || approvals != 0 {
3762 t.Fatalf("safe project create = (%v,%v,%q,%v), approvals=%d", allow, remember, reason, err, approvals)
3763 }
3764
3765 out, err := memory.NewRememberTool(store).Execute(memory.WithQueue(context.Background(), c), args)
3766 if err != nil || !strings.Contains(out, "Saved memory") {
3767 t.Fatalf("auto-approved remember execution = %q, %v", out, err)
3768 }
3769 if got := store.List(); len(got) != 1 || got[0].Name != "release-target" {
3770 t.Fatalf("saved memories = %+v", got)
3771 }
3772 }
3773
3774 func TestExistingMemoryRevokesAbandonedAutomaticCreateClaim(t *testing.T) {
3775 store := memory.Store{Dir: t.TempDir()}
3776 c := newOwnedTestController(t, Options{Memory: &memory.Set{Store: store}})
3777 args := json.RawMessage(`{"name":"release-target","description":"Project release target","type":"project","body":"Release from main-v2."}`)
3778
3779 if assessment := memory.AssessRememberWrite(store, args); !assessment.AutoAllow {
3780 t.Fatalf("initial assessment = %+v", assessment)
3781 }
3782 c.memory.authorizeAutoRemember(args) // approval was issued, then the turn was cancelled
3783 if _, err := store.Save(memory.Memory{Name: "release-target", Description: "concurrent", Body: "existing"}); err != nil {
3784 t.Fatal(err)
3785 }
3786 if assessment := memory.AssessRememberWrite(store, args); assessment.AutoAllow {
3787 t.Fatalf("existing assessment = %+v", assessment)
3788 }
3789 c.memory.revokeAutoRemember(args)
3790 if c.ClaimAutoMemoryWrite(args) {
3791 t.Fatal("abandoned automatic create claim survived an existing-memory reassessment")
3792 }
3793 }
3794
3795 // TestSessionGrantShortCircuitsGuardianReview: a session grant (or YOLO / the
3796 // approved-plan window) answers an ordinary approval before any guardian review
3797 // or prompt is attempted. Absorbed from PR #6413 by @myipanta.
3798 func TestSessionGrantShortCircuitsGuardianReview(t *testing.T) {
3799 guardianProv := &recordingProvider{
3800 name: "guardian",
3801 streams: [][]provider.Chunk{textTurn(`{"risk_level":"high","user_authorization":"unknown","outcome":"deny","rationale":"should never run"}`)},
3802 }
3803 guardianSess := guardian.NewSession(guardianProv, tool.NewRegistry(), guardian.PolicyPrompt(), "guardian-test", 0, nil, event.Discard)
3804 exec := agent.New(&recordingProvider{name: "executor"}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
3805 prompts := 0
3806 c := newOwnedTestController(t, Options{
3807 Executor: exec,
3808 Guardian: guardianSess,
3809 Sink: event.FuncSink(func(e event.Event) {
3810 if e.Kind == event.ApprovalRequest {
3811 prompts++
3812 }
3813 }),
3814 })
3815 subject := approvalDisplaySubject("write_file", "main.go", nil)
3816 c.approval.grantSession("write_file", subject)
3817
3818 allow, remember, _, err := gateApprover{c}.ApproveWithReason(context.Background(), "write_file", "main.go", nil)
3819 if err != nil || !allow || remember {
3820 t.Fatalf("session-granted approval = (%v,%v,%v), want plain allow", allow, remember, err)
3821 }
3822 if len(guardianProv.requests) != 0 || prompts != 0 {
3823 t.Fatalf("session grant must bypass guardian and prompts, reviews=%d prompts=%d", len(guardianProv.requests), prompts)
3824 }
3825 }
3826
3827 func TestHeadlessGateRefusesFreshHumanApprovalTools(t *testing.T) {
3828 gate := NewHeadlessPermissionGate(permission.New("ask", nil, nil, nil))
3829
3830 for _, toolName := range []string{"remember", "forget"} {
3831 allow, reason, err := gate.Check(context.Background(), toolName, json.RawMessage(`{}`), false)
3832 if err != nil || allow || !strings.Contains(reason, "fresh human approval") {
3833 t.Fatalf("%s headless check = (%v,%q,%v), want fresh-human refusal", toolName, allow, reason, err)
3834 }
3835 }
3836
3837 allow, reason, err := gate.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
3838 if err != nil || !allow || reason != "" {
3839 t.Fatalf("legacy bootstrap ask = (%v,%q,%v), want compatibility allow", allow, reason, err)
3840 }
3841 }
3842
3843 func TestMemoryApprovalSubjectsAndNotifications(t *testing.T) {
3844 forgetSubject := approvalDisplaySubject("forget", "", json.RawMessage(`{"name":"wrong-memory"}`))
3845 if forgetSubject != `Archive memory "wrong-memory"` {
3846 t.Fatalf("forget approval subject = %q", forgetSubject)
3847 }
3848 if got := approvalNotificationText("remember", "Save/update memory with private details"); got != "approval needed: remember" {
3849 t.Fatalf("remember notification = %q", got)
3850 }
3851 if got := approvalNotificationText("forget", `Archive memory "wrong-memory"`); got != "approval needed: forget" {
3852 t.Fatalf("forget notification = %q", got)
3853 }
3854 if got := approvalNotificationText("bash", "go test ./..."); got != "approval needed: bash go test ./..." {
3855 t.Fatalf("bash notification = %q", got)
3856 }
3857 moveSubject := approvalDisplaySubject("move_file", "src/a.md", json.RawMessage(`{"source_path":"src/a.md","destination_path":"docs/a.md"}`))
3858 if moveSubject != "src/a.md -> docs/a.md" {
3859 t.Fatalf("move_file approval subject = %q", moveSubject)
3860 }
3861 }
3862
3863 func TestPermissionRequestHookFiresForToolApproval(t *testing.T) {
3864 c, ids, payloads := permissionHookController(t, "bash")
3865 args := json.RawMessage(`{"command":"go test ./..."}`)
3866 type approveResult struct {
3867 allow bool
3868 remember bool
3869 err error
3870 }
3871 done := make(chan approveResult, 1)
3872 go func() {
3873 allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", args)
3874 done <- approveResult{allow: allow, remember: remember, err: err}
3875 }()
3876
3877 id := waitApprovalID(t, ids)
3878 payload := waitPermissionHook(t, payloads)
3879 if payload.Event != hook.PermissionRequest {
3880 t.Fatalf("payload event = %q, want PermissionRequest", payload.Event)
3881 }
3882 if payload.ToolName != "bash" {
3883 t.Fatalf("payload tool = %q, want bash", payload.ToolName)
3884 }
3885 if payload.Subject != "go test ./..." {
3886 t.Fatalf("payload subject = %q, want command subject", payload.Subject)
3887 }
3888 if string(payload.ToolArgs) != string(args) {
3889 t.Fatalf("payload args = %s, want %s", payload.ToolArgs, args)
3890 }
3891
3892 c.Approve(id, true, false, false)
3893 select {
3894 case got := <-done:
3895 if got.err != nil || !got.allow || got.remember {
3896 t.Fatalf("Approve = (%v,%v,%v), want allow once", got.allow, got.remember, got.err)
3897 }
3898 case <-time.After(30 * time.Second):
3899 t.Fatal("approval stayed blocked")
3900 }
3901 }
3902
3903 func TestPermissionRequestHookDoesNotFireForPolicyAllow(t *testing.T) {
3904 c, _, payloads := permissionHookController(t, "bash")
3905 g := permission.NewGate(permission.New("ask", []string{"bash(go test*)"}, nil, nil), gateApprover{c})
3906
3907 allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
3908 if err != nil || !allow {
3909 t.Fatalf("allow-listed call = (%v,%v), want allowed", allow, err)
3910 }
3911 assertNoPermissionHook(t, payloads)
3912 }
3913
3914 func TestPermissionRequestHookDoesNotFireForAutoApprovalMode(t *testing.T) {
3915 c, _, payloads := permissionHookController(t, "bash")
3916 c.SetToolApprovalMode(ToolApprovalAuto)
3917 g := c.newInteractiveGate()
3918
3919 allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
3920 if err != nil || !allow {
3921 t.Fatalf("auto-approved call = (%v,%v), want allowed", allow, err)
3922 }
3923 assertNoPermissionHook(t, payloads)
3924 }
3925
3926 func TestPermissionRequestHookDoesNotFireForSessionGrant(t *testing.T) {
3927 c, _, payloads := permissionHookController(t, "bash")
3928 c.approval.grantSession("bash", "go test ./...")
3929
3930 allow, _, err := c.requestApproval(context.Background(), "bash", "go test ./...", nil)
3931 if err != nil || !allow {
3932 t.Fatalf("session-granted approval = (%v,%v), want allowed", allow, err)
3933 }
3934 assertNoPermissionHook(t, payloads)
3935 }
3936
3937 // TestSessionAuthorizationsCarryAcrossRebuild pins the fix for a rebuild
3938 // (model/effort/profile switch) dropping same-session "Allow for this
3939 // session" tool grants and Plan-mode read-only command trust: only the
3940 // ask/auto/yolo posture string used to survive a controller swap, so a user
3941 // who had already granted a tool this session was asked again after any
3942 // switch.
3943 func TestSessionAuthorizationsCarryAcrossRebuild(t *testing.T) {
3944 old := newOwnedTestController(t, Options{})
3945 old.approval.grantSession("bash", "go test ./...")
3946 old.approval.grantPlanModeReadOnlyCommand("go test ./...")
3947
3948 fresh := newOwnedTestController(t, Options{})
3949 fresh.RestoreSessionAuthorizations(old.SessionAuthorizations())
3950
3951 allow, _, err := fresh.requestApproval(context.Background(), "bash", "go test ./...", nil)
3952 if err != nil || !allow {
3953 t.Fatalf("session-granted approval after restore = (%v,%v), want allowed", allow, err)
3954 }
3955 if !fresh.approval.planModeReadOnlyCommandTrusted("go test ./...") {
3956 t.Fatal("plan-mode read-only command trust did not carry across rebuild")
3957 }
3958 }
3959
3960 func TestPermissionRequestHookDoesNotFireForYolo(t *testing.T) {
3961 c, _, payloads := permissionHookController(t, "bash")
3962 c.SetToolApprovalMode(ToolApprovalYolo)
3963
3964 allow, _, err := c.requestApproval(context.Background(), "bash", "go test ./...", nil)
3965 if err != nil || !allow {
3966 t.Fatalf("YOLO approval = (%v,%v), want allowed", allow, err)
3967 }
3968 assertNoPermissionHook(t, payloads)
3969 }
3970
3971 func TestPermissionRequestHookDoesNotFireForPlanApproval(t *testing.T) {
3972 c, ids, payloads := permissionHookController(t, ".*")
3973 done := make(chan bool, 1)
3974 errs := make(chan error, 1)
3975 go func() {
3976 allow, _, err := c.requestApproval(context.Background(), planApprovalTool, "", nil)
3977 if err != nil {
3978 errs <- err
3979 return
3980 }
3981 done <- allow
3982 }()
3983
3984 id := waitApprovalID(t, ids)
3985 assertNoPermissionHook(t, payloads)
3986 c.Approve(id, true, false, false)
3987
3988 select {
3989 case err := <-errs:
3990 t.Fatalf("plan approval: %v", err)
3991 case allow := <-done:
3992 if !allow {
3993 t.Fatal("manual plan approval should allow")
3994 }
3995 case <-time.After(30 * time.Second):
3996 t.Fatal("plan approval stayed blocked")
3997 }
3998 }
3999
4000 func TestPermissionRequestHookRedactsMemoryApprovalPayload(t *testing.T) {
4001 cases := []struct {
4002 tool string
4003 args json.RawMessage
4004 }{
4005 {
4006 tool: "remember",
4007 args: json.RawMessage(`{"name":"private-memory","description":"private description","body":"private memory body"}`),
4008 },
4009 {
4010 tool: "forget",
4011 args: json.RawMessage(`{"name":"private-memory"}`),
4012 },
4013 }
4014 for _, tc := range cases {
4015 t.Run(tc.tool, func(t *testing.T) {
4016 c, ids, payloads := permissionHookController(t, tc.tool)
4017 done := make(chan string, 1)
4018 go func() {
4019 allow, _, err := gateApprover{c}.Approve(context.Background(), tc.tool, "", tc.args)
4020 if err != nil {
4021 done <- err.Error()
4022 return
4023 }
4024 if !allow {
4025 done <- tc.tool + " approval denied"
4026 return
4027 }
4028 done <- ""
4029 }()
4030
4031 id := waitApprovalID(t, ids)
4032 payload := waitPermissionHook(t, payloads)
4033 if payload.ToolName != tc.tool {
4034 t.Fatalf("payload tool = %q, want %s", payload.ToolName, tc.tool)
4035 }
4036 if payload.Subject != "" {
4037 t.Fatalf("memory PermissionRequest subject = %q, want redacted", payload.Subject)
4038 }
4039 if len(payload.ToolArgs) != 0 {
4040 t.Fatalf("memory PermissionRequest args = %s, want redacted", payload.ToolArgs)
4041 }
4042
4043 c.Approve(id, true, false, false)
4044 select {
4045 case msg := <-done:
4046 if msg != "" {
4047 t.Fatal(msg)
4048 }
4049 case <-time.After(30 * time.Second):
4050 t.Fatal("memory approval stayed blocked")
4051 }
4052 })
4053 }
4054 }
4055
4056 // TestApprovalDeny confirms a declined call returns allow=false.
4057 func TestApprovalDeny(t *testing.T) {
4058 c, ids, _ := approvalIDs(t)
4059 go func() { c.Approve(<-ids, false, false, false) }()
4060
4061 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", "rm -rf /", nil)
4062 if err != nil || allow {
4063 t.Fatalf("Approve = (%v,%v), want deny", allow, err)
4064 }
4065 }
4066
4067 // TestApprovalSessionGrantScopesBashToCommand proves an "allow this session"
4068 // answer short-circuits later prompts for the same bash command, but a different
4069 // command still reaches the frontend.
4070 func TestApprovalSessionGrantScopesBashToCommand(t *testing.T) {
4071 c, ids, prompts := approvalIDs(t)
4072 go func() {
4073 c.Approve(<-ids, true, true, false) // grant go build for this session
4074 c.Approve(<-ids, true, false, false)
4075 }()
4076
4077 for i, subject := range []string{"go build", "go build", "go test ./..."} {
4078 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", subject, nil)
4079 if err != nil || !allow {
4080 t.Fatalf("call %d = (%v,%v), want allow", i, allow, err)
4081 }
4082 }
4083 if *prompts != 2 {
4084 t.Errorf("prompted %d times, want 2 (same command granted, different command prompts)", *prompts)
4085 }
4086 }
4087
4088 func TestApprovalSessionGrantCanScopeBashToCommandPrefix(t *testing.T) {
4089 c, ids, prompts := approvalIDs(t)
4090 go func() {
4091 c.Approve(<-ids, true, true, false) // grant bash session (prefix preferred)
4092 c.Approve(<-ids, true, false, false)
4093 }()
4094
4095 for i, subject := range []string{"go test ./...", "go test ./internal/control", "go build ./..."} {
4096 allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", subject, nil)
4097 if err != nil || !allow {
4098 t.Fatalf("call %d = (%v,%v), want allow", i, allow, err)
4099 }
4100 }
4101 if *prompts != 2 {
4102 t.Errorf("prompted %d times, want 2 (prefix grant should cover similar command only)", *prompts)
4103 }
4104 }
4105
4106 func TestApprovalRejectsPersistentScopeAndAcceptsSessionScope(t *testing.T) {
4107 ids := make(chan string, 1)
4108 c := newOwnedTestController(t, Options{
4109 Sink: event.FuncSink(func(e event.Event) {
4110 if e.Kind == event.ApprovalRequest {
4111 ids <- e.Approval.ID
4112 }
4113 }),
4114 })
4115 result := make(chan error, 1)
4116 go func() {
4117 allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", nil)
4118 if err == nil && (!allow || remember) {
4119 err = fmt.Errorf("unexpected result allow=%v remember=%v", allow, remember)
4120 }
4121 result <- err
4122 }()
4123 id := <-ids
4124 if err := c.approveChecked(id, true, true, true); err == nil || !strings.Contains(err.Error(), "permanent approval") {
4125 t.Fatalf("persistent approval error = %v", err)
4126 }
4127 if err := c.approveChecked(id, true, true, false); err != nil {
4128 t.Fatalf("session approval: %v", err)
4129 }
4130 if err := <-result; err != nil {
4131 t.Fatal(err)
4132 }
4133 }
4134
4135 func TestApprovalSessionScopeDoesNotPersistRules(t *testing.T) {
4136 ids := make(chan string, 2)
4137 prompts := 0
4138 remembered := false
4139 c := newOwnedTestController(t, Options{
4140 Sink: event.FuncSink(func(e event.Event) {
4141 if e.Kind == event.ApprovalRequest {
4142 prompts++
4143 ids <- e.Approval.ID
4144 }
4145 }),
4146 OnRemember: func(rule string) RememberResult {
4147 remembered = true
4148 return RememberResult{Rule: rule, Path: "reasonix.toml", Saved: true}
4149 },
4150 })
4151 go func() {
4152 c.Approve(<-ids, true, true, false)
4153 }()
4154
4155 for i := range 2 {
4156 allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", nil)
4157 if err != nil || !allow || remember {
4158 t.Fatalf("Approve call %d = (%v,%v,%v), want session authorization", i, allow, remember, err)
4159 }
4160 }
4161 if prompts != 1 {
4162 t.Fatalf("approval prompts = %d, want one session-scoped prompt", prompts)
4163 }
4164 if remembered {
4165 t.Fatal("session authorization must not persist a permission rule")
4166 }
4167 }
4168
4169 func TestPlanModeReadOnlyTrustApprovalGrantsSessionCommandTrust(t *testing.T) {
4170 ids := make(chan string, 2)
4171 var approval event.Approval
4172 prompts := 0
4173 c := newOwnedTestController(t, Options{
4174 Sink: event.FuncSink(func(e event.Event) {
4175 if e.Kind == event.ApprovalRequest {
4176 prompts++
4177 approval = e.Approval
4178 ids <- e.Approval.ID
4179 }
4180 }),
4181 })
4182
4183 go func() {
4184 c.Approve(<-ids, true, true, false)
4185 }()
4186 req := agent.PlanModeReadOnlyTrustRequest{
4187 ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
4188 Command: "gh issue view 5867 --json title",
4189 Prefix: "gh issue view",
4190 Args: json.RawMessage(`{"command":"gh issue view 5867 --json title"}`),
4191 }
4192 allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
4193 if err != nil || !allow || reason != "" {
4194 t.Fatalf("CheckPlanModeReadOnlyTrust = (%v,%q,%v), want allow", allow, reason, err)
4195 }
4196 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") {
4197 t.Fatalf("approval = %+v, want plan-mode bash read-only command trust prompt", approval)
4198 }
4199
4200 ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
4201 defer cancel()
4202 allow, reason, err = planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(ctx, req)
4203 if err != nil || !allow || reason != "" {
4204 t.Fatalf("second CheckPlanModeReadOnlyTrust = (%v,%q,%v), want session grant", allow, reason, err)
4205 }
4206 if prompts != 1 {
4207 t.Fatalf("approval prompts = %d, want 1", prompts)
4208 }
4209 }
4210
4211 func TestApprovalSubjectsUseChineseCatalog(t *testing.T) {
4212 i18n.DetectLanguage("zh")
4213 t.Cleanup(func() { i18n.DetectLanguage("en") })
4214
4215 rememberArgs := json.RawMessage(`{"name":"prefers-vitest","type":"user","description":"Preferred test framework","body":"Use Vitest for frontend tests."}`)
4216 if got := approvalDisplaySubject(memoryRememberTool, "", rememberArgs); !strings.Contains(got, "保存/更新记忆") || !strings.Contains(got, "正文: Use Vitest") {
4217 t.Fatalf("remember approval subject = %q, want Chinese labels", got)
4218 }
4219
4220 forgetArgs := json.RawMessage(`{"name":"old-fact"}`)
4221 if got := approvalDisplaySubject(memoryForgetTool, "", forgetArgs); got != `归档记忆 "old-fact"` {
4222 t.Fatalf("forget approval subject = %q, want Chinese archive label", got)
4223 }
4224 }
4225
4226 func TestPlanModeReadOnlyTrustApprovalUsesChineseCatalog(t *testing.T) {
4227 i18n.DetectLanguage("zh")
4228 t.Cleanup(func() { i18n.DetectLanguage("en") })
4229
4230 approvalRequests := make(chan event.Approval, 1)
4231 c := newOwnedTestController(t, Options{
4232 Sink: event.FuncSink(func(e event.Event) {
4233 if e.Kind == event.ApprovalRequest {
4234 approvalRequests <- e.Approval
4235 }
4236 }),
4237 })
4238 done := make(chan struct {
4239 allow bool
4240 reason string
4241 err error
4242 }, 1)
4243 req := agent.PlanModeReadOnlyTrustRequest{
4244 ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
4245 Command: "gh issue view 5867 --json title",
4246 Prefix: "gh issue view",
4247 Args: json.RawMessage(`{"command":"gh issue view 5867 --json title"}`),
4248 }
4249 go func() {
4250 allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
4251 done <- struct {
4252 allow bool
4253 reason string
4254 err error
4255 }{allow: allow, reason: reason, err: err}
4256 }()
4257
4258 var approval event.Approval
4259 select {
4260 case approval = <-approvalRequests:
4261 case <-time.After(30 * time.Second):
4262 t.Fatal("plan-mode bash trust approval request was not emitted")
4263 }
4264 if !strings.Contains(approval.Subject, "在计划模式中信任") || !strings.Contains(approval.Subject, "gh issue view 5867") {
4265 t.Fatalf("approval subject = %q, want Chinese plan-mode trust subject", approval.Subject)
4266 }
4267 if !strings.Contains(approval.Reason, "不在 Reasonix 内置只读集合中") {
4268 t.Fatalf("approval reason = %q, want Chinese plan-mode trust reason", approval.Reason)
4269 }
4270
4271 c.Approve(approval.ID, false, false, false)
4272 select {
4273 case got := <-done:
4274 if got.err != nil || got.allow || !strings.Contains(got.reason, "用户拒绝") {
4275 t.Fatalf("rejected trust result = %+v, want Chinese denial", got)
4276 }
4277 case <-time.After(30 * time.Second):
4278 t.Fatal("plan-mode bash trust approval stayed blocked after rejection")
4279 }
4280 }
4281
4282 func TestPlanModeReadOnlyCommandTrustApprovalIgnoresToolAutoApproval(t *testing.T) {
4283 approvalRequests := make(chan event.Approval, 1)
4284 c := newOwnedTestController(t, Options{
4285 Sink: event.FuncSink(func(e event.Event) {
4286 if e.Kind == event.ApprovalRequest {
4287 approvalRequests <- e.Approval
4288 }
4289 }),
4290 })
4291 c.SetToolApprovalMode(ToolApprovalDangerFullAccess)
4292
4293 type trustResult struct {
4294 allow bool
4295 reason string
4296 err error
4297 }
4298 done := make(chan trustResult, 1)
4299 req := agent.PlanModeReadOnlyTrustRequest{
4300 ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
4301 Command: "gh issue view 5867",
4302 Prefix: "gh issue view",
4303 Args: json.RawMessage(`{"command":"gh issue view 5867"}`),
4304 }
4305 go func() {
4306 allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
4307 done <- trustResult{allow: allow, reason: reason, err: err}
4308 }()
4309
4310 var approval event.Approval
4311 select {
4312 case approval = <-approvalRequests:
4313 case <-time.After(30 * time.Second):
4314 t.Fatal("plan-mode bash read-only command trust prompt was not emitted under tool auto-approval")
4315 }
4316 if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool || !strings.Contains(approval.Subject, `Trust "gh issue view"`) {
4317 t.Fatalf("approval = %+v, want plan-mode bash read-only command trust prompt", approval)
4318 }
4319 select {
4320 case got := <-done:
4321 t.Fatalf("tool auto-approval must not answer plan-mode bash read-only command trust, got %+v", got)
4322 case <-time.After(50 * time.Millisecond):
4323 }
4324
4325 c.Approve(approval.ID, true, true, false)
4326 select {
4327 case got := <-done:
4328 if got.err != nil || !got.allow || got.reason != "" {
4329 t.Fatalf("CheckPlanModeReadOnlyTrust after approval = %+v, want allow", got)
4330 }
4331 case <-time.After(30 * time.Second):
4332 t.Fatal("plan-mode bash read-only command trust prompt stayed blocked after Approve")
4333 }
4334
4335 allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
4336 if err != nil || !allow || reason != "" {
4337 t.Fatalf("session-granted plan-mode bash read-only command trust under YOLO = (%v,%q,%v), want allow", allow, reason, err)
4338 }
4339 }
4340
4341 func TestApprovalSessionGrantGroupsFileMutationTools(t *testing.T) {
4342 c, ids, prompts := approvalIDs(t)
4343 go func() { c.Approve(<-ids, true, true, false) }()
4344
4345 for i, call := range []struct {
4346 tool string
4347 subject string
4348 }{
4349 {"edit_file", "src/a.go"},
4350 {"write_file", "src/b.go"},
4351 {"multi_edit", "src/c.go"},
4352 {"move_file", "src/d.go"},
4353 } {
4354 allow, _, err := gateApprover{c}.Approve(context.Background(), call.tool, call.subject, nil)
4355 if err != nil || !allow {
4356 t.Fatalf("call %d = (%v,%v), want allow", i, allow, err)
4357 }
4358 }
4359 if *prompts != 1 {
4360 t.Errorf("prompted %d times, want 1 (file mutation session grant should short-circuit)", *prompts)
4361 }
4362 }
4363
4364 func TestApprovalSessionGrantKeepsPolicyDenyPrecedence(t *testing.T) {
4365 c, ids, prompts := approvalIDs(t)
4366 g := permission.NewGate(permission.New("ask", nil, nil, []string{"bash(rm*)"}), gateApprover{c})
4367 go func() { c.Approve(<-ids, true, true, false) }()
4368
4369 allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go build"}`), false)
4370 if err != nil || !allow {
4371 t.Fatalf("first approved call = (%v,%v), want allow", allow, err)
4372 }
4373 allow, _, err = g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go build"}`), false)
4374 if err != nil || !allow {
4375 t.Fatalf("same-command call after session grant = (%v,%v), want allow", allow, err)
4376 }
4377 allow, reason, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"rm -rf /tmp/x"}`), false)
4378 if err != nil || allow || reason == "" {
4379 t.Fatalf("deny-listed call = (%v,%q,%v), want blocked with reason", allow, reason, err)
4380 }
4381 if *prompts != 1 {
4382 t.Errorf("prompted %d times, want 1", *prompts)
4383 }
4384 }
4385
4386 // TestApprovalCtxCancel ensures a cancelled turn unblocks the gate with an error
4387 // (rather than hanging) when no one answers.
4388 func TestApprovalCtxCancel(t *testing.T) {
4389 c := newOwnedTestController(t, Options{Sink: event.Discard})
4390 ctx, cancel := context.WithCancel(context.Background())
4391 cancel()
4392
4393 allow, _, err := gateApprover{c}.Approve(ctx, "bash", "x", nil)
4394 if err == nil || allow {
4395 t.Fatalf("Approve on cancelled ctx = (%v,%v), want (false, error)", allow, err)
4396 }
4397 }
4398
4399 func TestParseRewind(t *testing.T) {
4400 cps := []checkpoint.Meta{
4401 {Turn: 0, Prompt: "first"},
4402 {Turn: 1, Prompt: "second"},
4403 {Turn: 2, Prompt: "third"},
4404 }
4405 cases := []struct {
4406 args string
4407 wantT int
4408 wantS RewindScope
4409 wantErr bool
4410 }{
4411 {"", 2, RewindBoth, false}, // no args -> latest turn, both
4412 {"1", 1, RewindBoth, false}, // turn only
4413 {"0 code", 0, RewindCode, false}, // turn + code
4414 {"1 conversation", 1, RewindConversation, false}, // turn + conversation
4415 {"2 both", 2, RewindBoth, false}, // turn + both
4416 {"abc", 0, RewindBoth, true}, // invalid turn
4417 {"0 unknown", 0, RewindBoth, true}, // unknown scope
4418 }
4419 for _, tc := range cases {
4420 t.Run(tc.args, func(t *testing.T) {
4421 gotT, gotS, err := parseRewind(tc.args, cps)
4422 if (err != nil) != tc.wantErr {
4423 t.Fatalf("parseRewind(%q) err=%v, wantErr=%v", tc.args, err, tc.wantErr)
4424 }
4425 if err != nil {
4426 return
4427 }
4428 if gotT != tc.wantT || gotS != tc.wantS {
4429 t.Fatalf("parseRewind(%q) = (%d,%d), want (%d,%d)", tc.args, gotT, gotS, tc.wantT, tc.wantS)
4430 }
4431 })
4432 }
4433 }
4434
4435 func TestParseRewindEmptyCheckpoints(t *testing.T) {
4436 _, _, err := parseRewind("", nil)
4437 if err == nil {
4438 t.Fatal("expected error when no checkpoints")
4439 }
4440 }
4441
4442 func TestRunGuardedPanicEmitsTurnDone(t *testing.T) {
4443 sess := agent.NewSession("sys")
4444 events := make(chan event.Event, 4)
4445 c := newOwnedTestController(t, Options{
4446 Runner: appendingRunner{session: sess},
4447 Sink: event.FuncSink(func(e event.Event) { events <- e }),
4448 })
4449
4450 go func() {
4451 c.runGuarded(func(ctx context.Context) error {
4452 panic("boom")
4453 })
4454 }()
4455
4456 deadline := time.After(30 * time.Second)
4457 for {
4458 select {
4459 case e := <-events:
4460 if e.Kind != event.TurnDone {
4461 continue
4462 }
4463 if e.Err == nil || !strings.Contains(e.Err.Error(), "boom") {
4464 t.Fatalf("expected TurnDone.Err to contain panic message, got %v", e.Err)
4465 }
4466 goto done
4467 case <-deadline:
4468 t.Fatal("timed out waiting for TurnDone after panic")
4469 }
4470 }
4471 done:
4472
4473 waitIdle(t, c)
4474 if c.Running() {
4475 t.Fatal("controller still running after panic recovery")
4476 }
4477 }
4478
4479 // TestRunGuardedParksReplacementUntilTurnDoneReturns pins the finishing-window
4480 // admission contract from both sides: a replacement turn arriving while
4481 // TurnDone is being delivered must NOT start inside the window (the original
4482 // transport-crosstalk guarantee this window exists for), and it must START —
4483 // exactly once — when the window closes. The second half replaces the old
4484 // silent-drop behavior, which lost real input: every caller that submits upon
4485 // seeing turn_done (a frontend's queued auto-send, a bot, a fast Enter) raced
4486 // this window, observed as a CI-flaky lost turn and worked around in
4487 // Composer.tsx by gating auto-send on submitDisabled instead of turn_done.
4488 func TestRunGuardedParksReplacementUntilTurnDoneReturns(t *testing.T) {
4489 firstTurnDone := make(chan struct{})
4490 releaseTurnDone := make(chan struct{})
4491 firstBodyDone := make(chan struct{})
4492 secondBodyRan := make(chan struct{}, 2)
4493 var turnDones atomic.Int32
4494 c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
4495 if e.Kind == event.TurnDone {
4496 if turnDones.Add(1) == 1 {
4497 close(firstTurnDone)
4498 <-releaseTurnDone
4499 }
4500 }
4501 })})
4502
4503 if got := c.runGuarded(func(context.Context) error {
4504 close(firstBodyDone)
4505 return nil
4506 }); got != turnStarted {
4507 t.Fatalf("first admission = %v, want turnStarted", got)
4508 }
4509 <-firstBodyDone
4510 select {
4511 case <-firstTurnDone:
4512 case <-time.After(time.Second):
4513 t.Fatal("TurnDone delivery did not start")
4514 }
4515 if !c.RuntimeStatus().Running {
4516 t.Fatal("controller reported idle while TurnDone was still being delivered")
4517 }
4518 if got := c.runGuarded(func(context.Context) error {
4519 secondBodyRan <- struct{}{}
4520 return nil
4521 }); got != turnParked {
4522 t.Fatalf("finishing-window admission = %v, want turnParked", got)
4523 }
4524 select {
4525 case <-secondBodyRan:
4526 t.Fatal("replacement turn started before TurnDone delivery completed")
4527 case <-time.After(50 * time.Millisecond):
4528 }
4529
4530 close(releaseTurnDone)
4531 select {
4532 case <-secondBodyRan:
4533 case <-time.After(30 * time.Second):
4534 t.Fatal("parked turn was never started after the finishing window closed")
4535 }
4536 deadline := time.Now().Add(30 * time.Second)
4537 for c.Running() && time.Now().Before(deadline) {
4538 time.Sleep(time.Millisecond)
4539 }
4540 if c.Running() {
4541 t.Fatal("controller remained busy after the parked turn completed")
4542 }
4543 if got := turnDones.Load(); got != 2 {
4544 t.Fatalf("TurnDone emitted %d times, want 2 (one per turn)", got)
4545 }
4546 select {
4547 case <-secondBodyRan:
4548 t.Fatal("parked turn ran more than once")
4549 default:
4550 }
4551 }
4552
4553 func TestRunGuardedPanicDoesNotDoubleEmitTurnDone(t *testing.T) {
4554 sess := agent.NewSession("sys")
4555 var count atomic.Int32
4556 events := make(chan event.Event, 8)
4557 c := newOwnedTestController(t, Options{
4558 Runner: appendingRunner{session: sess},
4559 Sink: event.FuncSink(func(e event.Event) {
4560 if e.Kind == event.TurnDone {
4561 count.Add(1)
4562 }
4563 events <- e
4564 }),
4565 })
4566
4567 go func() {
4568 c.runGuarded(func(ctx context.Context) error {
4569 panic("boom")
4570 })
4571 }()
4572
4573 deadline := time.After(30 * time.Second)
4574 for {
4575 select {
4576 case <-events:
4577 n := count.Load()
4578 if n >= 1 {
4579 time.Sleep(50 * time.Millisecond)
4580 n2 := count.Load()
4581 if n2 > 1 {
4582 t.Fatalf("TurnDone emitted %d times, expected 1", n2)
4583 }
4584 return
4585 }
4586 case <-deadline:
4587 t.Fatal("timed out waiting for TurnDone")
4588 }
4589 }
4590 }
4591
4592 type blockingRunner struct {
4593 session *agent.Session
4594 release chan struct{}
4595 }
4596
4597 func (r blockingRunner) Run(_ context.Context, input string) error {
4598 r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
4599 <-r.release
4600 return nil
4601 }
4602
4603 func TestRunTurnReportsErrTurnRunning(t *testing.T) {
4604 sess := agent.NewSession("sys")
4605 release := make(chan struct{})
4606 c := newOwnedTestController(t, Options{Runner: blockingRunner{session: sess, release: release}})
4607
4608 done := make(chan error, 1)
4609 go func() {
4610 done <- c.RunTurn(context.Background(), "first")
4611 }()
4612 waitForRunning(t, c)
4613
4614 if err := c.RunTurn(context.Background(), "second"); !errors.Is(err, ErrTurnRunning) {
4615 t.Fatalf("RunTurn while running error = %v, want ErrTurnRunning", err)
4616 }
4617
4618 close(release)
4619 select {
4620 case err := <-done:
4621 if err != nil {
4622 t.Fatalf("first RunTurn returned %v", err)
4623 }
4624 case <-time.After(30 * time.Second):
4625 t.Fatal("first RunTurn did not finish after release")
4626 }
4627 }
4628
4629 func TestSendWhileRunningDoesNotInterleaveTurns(t *testing.T) {
4630 sess := agent.NewSession("sys")
4631 release := make(chan struct{})
4632 events := make(chan event.Event, 4)
4633 c := newOwnedTestController(t, Options{
4634 Runner: blockingRunner{session: sess, release: release},
4635 Sink: event.FuncSink(func(e event.Event) {
4636 events <- e
4637 }),
4638 })
4639 defer c.autosaveWG.Wait()
4640
4641 c.Send("first")
4642 waitForRunning(t, c)
4643 c.Send("second")
4644 close(release)
4645 waitForTurnDone(t, events)
4646
4647 var users []string
4648 for _, m := range sess.Messages {
4649 if m.Role == provider.RoleUser {
4650 users = append(users, m.Content)
4651 }
4652 }
4653 if len(users) != 1 || users[0] != "first" {
4654 t.Fatalf("user turns = %v, want only first turn recorded", users)
4655 }
4656 }
4657
4658 func waitForRunning(t *testing.T, c *Controller) {
4659 t.Helper()
4660 deadline := time.Now().Add(2 * time.Second)
4661 for time.Now().Before(deadline) {
4662 if c.Running() {
4663 return
4664 }
4665 time.Sleep(10 * time.Millisecond)
4666 }
4667 t.Fatal("controller did not enter running state")
4668 }
4669
4670 func TestMidTurnAutosavePersistsDuringLongTurn(t *testing.T) {
4671 old := midTurnSnapshotInterval.Load()
4672 midTurnSnapshotInterval.Store(int64(10 * time.Millisecond))
4673 defer midTurnSnapshotInterval.Store(old)
4674
4675 dir := t.TempDir()
4676 sess := agent.NewSession("sys")
4677 exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
4678 path := filepath.Join(dir, "session.jsonl")
4679 release := make(chan struct{})
4680 c := newOwnedTestController(t, Options{Runner: blockingRunner{session: sess, release: release}, Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
4681 // Unblock the turn and wait for the autosaver to exit before TempDir
4682 // cleanup, which fails on Windows while a snapshot tmp write is in flight.
4683 defer c.autosaveWG.Wait()
4684 defer close(release)
4685
4686 c.Send("hello mid-turn persistence")
4687
4688 deadline := time.Now().Add(3 * time.Second)
4689 for time.Now().Before(deadline) {
4690 if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), "hello mid-turn persistence") {
4691 return
4692 }
4693 time.Sleep(5 * time.Millisecond)
4694 }
4695 t.Fatal("session file was not written while the turn was still running")
4696 }
4697
4698 type scriptedRunner struct {
4699 exec *agent.Agent
4700 scripts []func(input string)
4701 }
4702
4703 func (r *scriptedRunner) Run(_ context.Context, input string) error {
4704 if len(r.scripts) == 0 {
4705 return nil
4706 }
4707 next := r.scripts[0]
4708 r.scripts = r.scripts[1:]
4709 next(input)
4710 return nil
4711 }
4712
4713 func TestApprovedPlanAutoApproveEndsWithExecutionTurn(t *testing.T) {
4714 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
4715 runner := &scriptedRunner{exec: exec}
4716
4717 var c *Controller
4718 approvalPrompts := 0
4719 sink := event.FuncSink(func(e event.Event) {
4720 if e.Kind != event.ApprovalRequest {
4721 return
4722 }
4723 approvalPrompts++
4724 if e.Approval.Tool == planApprovalTool {
4725 go c.Approve(e.Approval.ID, true, false, false)
4726 return
4727 }
4728 go c.Approve(e.Approval.ID, false, false, false)
4729 })
4730 c = newOwnedTestController(t, Options{Runner: runner, Executor: exec, Sink: sink})
4731 c.SetPlanMode(true)
4732
4733 runner.scripts = append(runner.scripts,
4734 func(input string) {
4735 exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "1. Create the file\n2. Update the file"})
4736 },
4737 func(input string) {
4738 if input != planApprovedMessage {
4739 t.Fatalf("approved execution input = %q, want planApprovedMessage", input)
4740 }
4741 exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "first step done; paused for review", ToolCalls: []provider.ToolCall{{
4742 ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"Create the file","status":"completed"},{"content":"Update the file","status":"in_progress"}]}`,
4743 }}})
4744 },
4745 )
4746
4747 if err := c.runTurn(context.Background(), "plan this"); err != nil {
4748 t.Fatal(err)
4749 }
4750 if approvalPrompts != 1 {
4751 t.Fatalf("approval prompts after plan = %d, want 1", approvalPrompts)
4752 }
4753
4754 // The plan approval auto-approves writers for the execution turn only. A later
4755 // turn does not inherit it, and "继续" carries no special meaning — Compose must
4756 // not inject any marker, and the next writer falls back to per-tool approval.
4757 if got := c.Compose("继续"); StripComposePrefixes(got) != "继续" {
4758 t.Fatalf("a paused approved plan must not marker-prefix the next turn, got %q", got)
4759 }
4760 allow, _, err := gateApprover{c}.Approve(context.Background(), "write_file", "/tmp/a", nil)
4761 if err != nil {
4762 t.Fatal(err)
4763 }
4764 if allow {
4765 t.Fatal("writer after the execution turn should return to per-tool approval, not auto-allow")
4766 }
4767 if approvalPrompts != 2 {
4768 t.Fatalf("writer after the execution turn should prompt, prompts=%d", approvalPrompts)
4769 }
4770 }
4771
4772 func TestApprovedPlanDoesNotAutoApproveNonContinuationTurn(t *testing.T) {
4773 exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
4774 runner := &scriptedRunner{exec: exec}
4775
4776 var c *Controller
4777 approvalPrompts := 0
4778 sink := event.FuncSink(func(e event.Event) {
4779 if e.Kind != event.ApprovalRequest {
4780 return
4781 }
4782 approvalPrompts++
4783 if e.Approval.Tool == planApprovalTool {
4784 go c.Approve(e.Approval.ID, true, false, false)
4785 return
4786 }
4787 go c.Approve(e.Approval.ID, false, false, false)
4788 })
4789 c = newOwnedTestController(t, Options{Runner: runner, Executor: exec, Sink: sink})
4790 c.SetPlanMode(true)
4791
4792 runner.scripts = append(runner.scripts,
4793 func(input string) {
4794 exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "1. Create the file\n2. Update the file"})
4795 },
4796 func(input string) {
4797 exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "paused", ToolCalls: []provider.ToolCall{{
4798 ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"Create the file","status":"completed"},{"content":"Update the file","status":"in_progress"}]}`,
4799 }}})
4800 },
4801 )
4802
4803 if err := c.runTurn(context.Background(), "plan this"); err != nil {
4804 t.Fatal(err)
4805 }
4806 if got := c.Compose("先别继续"); StripComposePrefixes(got) != "先别继续" {
4807 t.Fatalf("non-continuation input should not be marker-prefixed, got %q", got)
4808 }
4809
4810 allow, _, err := gateApprover{c}.Approve(context.Background(), "write_file", "/tmp/a", nil)
4811 if err != nil {
4812 t.Fatal(err)
4813 }
4814 if allow {
4815 t.Fatal("non-continuation turn should not inherit approved-plan auto approval")
4816 }
4817 if approvalPrompts != 2 {
4818 t.Fatalf("non-continuation writer should prompt after plan approval, prompts=%d", approvalPrompts)
4819 }
4820 }
4821
4822 // writeCmdFile creates a command .md file with frontmatter under dir.
4823 func writeCmdFile(t *testing.T, dir, name, description, body string) {
4824 t.Helper()
4825 if err := os.MkdirAll(dir, 0o755); err != nil {
4826 t.Fatal(err)
4827 }
4828 content := fmt.Sprintf("---\ndescription: %s\n---\n%s\n", description, body)
4829 if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte(content), 0o644); err != nil {
4830 t.Fatal(err)
4831 }
4832 }
4833
4834 // TestCommandsAtomicPointer verifies that commands passed via Options.Commands are
4835 // correctly exposed through the atomic-pointer Commands() getter and that
4836 // CustomCommand resolves and renders them. Missing commands return found=false.
4837 func TestCommandsAtomicPointer(t *testing.T) {
4838 cmds := []command.Command{
4839 {Name: "review", Description: "Review code", Body: "Review $1"},
4840 {Name: "test", Description: "Run tests", Body: "Test $1"},
4841 }
4842 c := newOwnedTestController(t, Options{
4843 Commands: cmds,
4844 Sink: &typedNilControllerSink{},
4845 Registry: tool.NewRegistry(),
4846 })
4847
4848 // Commands() returns what was passed via Options
4849 got := c.Commands()
4850 if len(got) != 2 {
4851 t.Fatalf("Commands() = %d, want 2", len(got))
4852 }
4853 // Check retrieval via CustomCommand
4854 sent, ok := c.CustomCommand("/review myfile.go")
4855 if !ok {
4856 t.Error("/review should be found")
4857 }
4858 if !strings.Contains(sent, "Review myfile.go") {
4859 t.Errorf("unexpected render: %q", sent)
4860 }
4861 _, ok = c.CustomCommand("/missing")
4862 if ok {
4863 t.Error("/missing should not be found")
4864 }
4865
4866 // Commands() getter uses atomic.Pointer internally
4867 if cmds2 := c.Commands(); len(cmds2) != 2 {
4868 t.Errorf("Commands() = %d after change, want 2", len(cmds2))
4869 }
4870 }
4871
4872 // TestReloadCommandsFromFilesystem exercises ReloadCommands against real .md
4873 // files in a temp workspace: initial load, hot-reload with a new file, and
4874 // hot-reload after modifying an existing file. Also verifies that skills are
4875 // preserved across the reload.
4876 func TestReloadCommandsFromFilesystem(t *testing.T) {
4877 // Isolate HOME so CommandDirsForRoot does not pick up global .md command files.
4878 home := t.TempDir()
4879 t.Setenv("HOME", home)
4880 t.Setenv("USERPROFILE", home)
4881 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
4882 t.Setenv("AppData", filepath.Join(home, "AppData"))
4883
4884 wsRoot := t.TempDir()
4885 cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
4886 writeCmdFile(t, cmdDir, "review", "Review code", "Review $1")
4887 writeCmdFile(t, cmdDir, "test", "Run tests", "Test $1")
4888
4889 // Create a minimal in-memory skill to verify skills are preserved across reload.
4890 sk := skill.Skill{
4891 Name: "myskill",
4892 Description: "Test skill",
4893 Body: "You are a test skill. User says: {{.Input}}",
4894 }
4895
4896 reg := tool.NewRegistry()
4897 c := newOwnedTestController(t, Options{
4898 Sink: &typedNilControllerSink{},
4899 Registry: reg,
4900 WorkspaceRoot: wsRoot,
4901 Skills: []skill.Skill{sk},
4902 })
4903
4904 // ReloadCommands should pick up the two .md files and preserve the skill.
4905 if err := c.ReloadCommands(context.Background()); err != nil {
4906 t.Fatalf("ReloadCommands: %v", err)
4907 }
4908 cmds := c.Commands()
4909 if len(cmds) != 2 {
4910 t.Fatalf("Commands() = %d after reload, want 2", len(cmds))
4911 }
4912
4913 // CustomCommand should resolve through the hot-swapped getter
4914 sent, ok := c.CustomCommand("/review hello.go")
4915 if !ok {
4916 t.Fatal("/review should be found after reload")
4917 }
4918 if !strings.Contains(sent, "Review hello.go") {
4919 t.Errorf("render = %q, want Review hello.go", sent)
4920 }
4921
4922 // Missing command still not found
4923 if _, ok := c.CustomCommand("/nope"); ok {
4924 t.Error("/nope should not be found")
4925 }
4926
4927 // Skill should appear in the slash_command tool's description after reload.
4928 if tool, found := reg.Get("slash_command"); found {
4929 if !strings.Contains(tool.Description(), "myskill") {
4930 t.Error("skill 'myskill' should appear in slash_command tool Description after ReloadCommands")
4931 }
4932 } else {
4933 t.Error("slash_command tool should be registered after ReloadCommands")
4934 }
4935
4936 // Skill should still be callable via RunSkill after reload.
4937 if _, ok := c.RunSkill("/myskill"); !ok {
4938 t.Error("RunSkill(/myskill) should find the skill after ReloadCommands")
4939 }
4940
4941 // Hot-reload: add a new command file
4942 writeCmdFile(t, cmdDir, "count", "Count to N", "Count from 1 to $1")
4943 if err := c.ReloadCommands(context.Background()); err != nil {
4944 t.Fatalf("ReloadCommands (add): %v", err)
4945 }
4946 if cmds := c.Commands(); len(cmds) != 3 {
4947 t.Fatalf("Commands() = %d after add, want 3", len(cmds))
4948 }
4949
4950 // Hot-reload: modify an existing command
4951 writeCmdFile(t, cmdDir, "review", "Review code (friendly)", "Kindly review $1")
4952 if err := c.ReloadCommands(context.Background()); err != nil {
4953 t.Fatalf("ReloadCommands (modify): %v", err)
4954 }
4955 if cmds := c.Commands(); len(cmds) != 3 {
4956 t.Fatalf("Commands() = %d after modify, want 3", len(cmds))
4957 }
4958 sent, ok = c.CustomCommand("/review world.go")
4959 if !ok {
4960 t.Fatal("/review should be found after modify")
4961 }
4962 if !strings.Contains(sent, "Kindly review world.go") {
4963 t.Errorf("render after modify = %q, want Kindly review world.go", sent)
4964 }
4965
4966 // The slash_command tool should be registered and updated
4967 if _, found := reg.Get("slash_command"); !found {
4968 t.Error("slash_command tool should be registered after ReloadCommands")
4969 }
4970 }
4971
4972 // TestReloadCommandsDeleteFile verifies that removing a command .md file and
4973 // reloading causes the command to disappear from both Commands() and
4974 // CustomCommand(), while other commands remain intact.
4975 func TestReloadCommandsDeleteFile(t *testing.T) {
4976 home := t.TempDir()
4977 t.Setenv("HOME", home)
4978 t.Setenv("USERPROFILE", home)
4979 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
4980 t.Setenv("AppData", filepath.Join(home, "AppData"))
4981
4982 wsRoot := t.TempDir()
4983 cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
4984 writeCmdFile(t, cmdDir, "alpha", "Alpha cmd", "Alpha $1")
4985 writeCmdFile(t, cmdDir, "beta", "Beta cmd", "Beta $1")
4986
4987 reg := tool.NewRegistry()
4988 c := newOwnedTestController(t, Options{
4989 Sink: &typedNilControllerSink{},
4990 Registry: reg,
4991 WorkspaceRoot: wsRoot,
4992 })
4993
4994 if err := c.ReloadCommands(context.Background()); err != nil {
4995 t.Fatalf("initial reload: %v", err)
4996 }
4997 if got := len(c.Commands()); got != 2 {
4998 t.Fatalf("Commands() = %d, want 2", got)
4999 }
5000
5001 // Delete alpha.md
5002 if err := os.Remove(filepath.Join(cmdDir, "alpha.md")); err != nil {
5003 t.Fatal(err)
5004 }
5005
5006 if err := c.ReloadCommands(context.Background()); err != nil {
5007 t.Fatalf("reload after delete: %v", err)
5008 }
5009 if got := len(c.Commands()); got != 1 {
5010 t.Fatalf("Commands() = %d after delete, want 1", got)
5011 }
5012
5013 // /alpha should no longer be found
5014 if _, ok := c.CustomCommand("/alpha x"); ok {
5015 t.Error("/alpha should NOT be found after deletion")
5016 }
5017 // /beta should still work
5018 sent, ok := c.CustomCommand("/beta y")
5019 if !ok {
5020 t.Error("/beta should still be found")
5021 }
5022 if !strings.Contains(sent, "Beta y") {
5023 t.Errorf("render = %q, want Beta y", sent)
5024 }
5025 }
5026
5027 // TestReloadCommandsMalformedFile verifies that a malformed .md file causes
5028 // ReloadCommands to return an error but does not prevent other valid commands
5029 // from loading.
5030 func TestReloadCommandsMalformedFile(t *testing.T) {
5031 home := t.TempDir()
5032 t.Setenv("HOME", home)
5033 t.Setenv("USERPROFILE", home)
5034 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
5035 t.Setenv("AppData", filepath.Join(home, "AppData"))
5036
5037 wsRoot := t.TempDir()
5038 cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
5039 writeCmdFile(t, cmdDir, "good", "Good cmd", "Good $1")
5040
5041 // Write a malformed file (no valid frontmatter)
5042 if err := os.MkdirAll(cmdDir, 0o755); err != nil {
5043 t.Fatal(err)
5044 }
5045 broken := filepath.Join(cmdDir, "broken.md")
5046 if err := os.WriteFile(broken, []byte("this is not valid yaml\n---\nrandom\n"), 0o644); err != nil {
5047 t.Fatal(err)
5048 }
5049
5050 reg := tool.NewRegistry()
5051 c := newOwnedTestController(t, Options{
5052 Sink: &typedNilControllerSink{},
5053 Registry: reg,
5054 WorkspaceRoot: wsRoot,
5055 })
5056
5057 err := c.ReloadCommands(context.Background())
5058 // We expect some error from the malformed file
5059 if err == nil {
5060 t.Log("ReloadCommands returned nil error despite malformed file — command.Load may tolerate it")
5061 } else {
5062 t.Logf("ReloadCommands returned error (expected): %v", err)
5063 }
5064
5065 // The valid command should still be loadable
5066 cmds := c.Commands()
5067 foundGood := false
5068 for _, cmd := range cmds {
5069 if cmd.Name == "good" {
5070 foundGood = true
5071 }
5072 }
5073 if !foundGood {
5074 t.Errorf("valid command 'good' should be present, got commands: %v", cmdNames(cmds))
5075 }
5076 }
5077
5078 // TestReloadCommandsSameNameAcrossDirs verifies that when the same command
5079 // name exists in multiple convention directories, the later-scanned directory
5080 // (higher priority) wins. ConventionDirs = [".reasonix", ".agents", ".agent",
5081 // ".claude"], scanned in reverse, so .reasonix is highest priority.
5082 func TestReloadCommandsSameNameAcrossDirs(t *testing.T) {
5083 home := t.TempDir()
5084 t.Setenv("HOME", home)
5085 t.Setenv("USERPROFILE", home)
5086 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
5087 t.Setenv("AppData", filepath.Join(home, "AppData"))
5088
5089 wsRoot := t.TempDir()
5090
5091 // Lower priority: .claude/commands
5092 claudeDir := filepath.Join(wsRoot, ".claude", "commands")
5093 writeCmdFile(t, claudeDir, "greet", "Claude greet", "Hello from Claude: $1")
5094
5095 // Higher priority: .reasonix/commands
5096 reasonixDir := filepath.Join(wsRoot, ".reasonix", "commands")
5097 writeCmdFile(t, reasonixDir, "greet", "Reasonix greet", "Hello from Reasonix: $1")
5098
5099 reg := tool.NewRegistry()
5100 c := newOwnedTestController(t, Options{
5101 Sink: &typedNilControllerSink{},
5102 Registry: reg,
5103 WorkspaceRoot: wsRoot,
5104 })
5105
5106 if err := c.ReloadCommands(context.Background()); err != nil {
5107 t.Fatalf("reload: %v", err)
5108 }
5109
5110 // There should be exactly 1 command named "greet"
5111 cmds := c.Commands()
5112 count := 0
5113 for _, cmd := range cmds {
5114 if cmd.Name == "greet" {
5115 count++
5116 }
5117 }
5118 if count != 1 {
5119 t.Fatalf("expected exactly 1 'greet' command, got %d", count)
5120 }
5121
5122 // The winning version should be from .reasonix (highest priority)
5123 sent, ok := c.CustomCommand("/greet world")
5124 if !ok {
5125 t.Fatal("/greet should be found")
5126 }
5127 if !strings.Contains(sent, "Hello from Reasonix") {
5128 t.Errorf("expected .reasonix version to win, got render: %q", sent)
5129 }
5130 }
5131
5132 func TestReloadCommandsUsesCanonicalPluginNameAlongsideProjectShortName(t *testing.T) {
5133 home := t.TempDir()
5134 t.Setenv("HOME", home)
5135 t.Setenv("USERPROFILE", home)
5136 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
5137 t.Setenv("AppData", filepath.Join(home, "AppData"))
5138 reasonixHome := filepath.Join(home, ".reasonix")
5139 t.Setenv("REASONIX_HOME", reasonixHome)
5140
5141 pluginRoot := filepath.Join(reasonixHome, "plugins", "pwf")
5142 if err := os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0o755); err != nil {
5143 t.Fatal(err)
5144 }
5145 if err := os.WriteFile(filepath.Join(pluginRoot, pluginpkg.ClaudeManifest), []byte(`{"name":"pwf"}`), 0o644); err != nil {
5146 t.Fatal(err)
5147 }
5148 writeCmdFile(t, filepath.Join(pluginRoot, "commands"), "plan", "Plugin plan", "PLUGIN $1")
5149 writeCmdFile(t, filepath.Join(pluginRoot, "commands"), "status", "Plugin status", "STATUS $1")
5150 if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{Name: "pwf", Root: "plugins/pwf", ManifestKind: "claude", Enabled: true}); err != nil {
5151 t.Fatal(err)
5152 }
5153
5154 workspace := t.TempDir()
5155 writeCmdFile(t, filepath.Join(workspace, ".reasonix", "commands"), "plan", "Project plan", "PROJECT $1")
5156 c := newOwnedTestController(t, Options{Sink: &typedNilControllerSink{}, Registry: tool.NewRegistry(), WorkspaceRoot: workspace})
5157 if err := c.ReloadCommands(context.Background()); err != nil {
5158 t.Fatalf("ReloadCommands: %v", err)
5159 }
5160
5161 if got, ok := c.CustomCommand("/plan task"); !ok || got != "PROJECT task" {
5162 t.Fatalf("short command = %q, %v; want project winner", got, ok)
5163 }
5164 if got, ok := c.CustomCommand("/pwf:plan task"); !ok || got != "PLUGIN task" {
5165 t.Fatalf("qualified plugin command = %q, %v", got, ok)
5166 }
5167 if got, ok := c.CustomCommand("/status now"); !ok || got != "STATUS now" {
5168 t.Fatalf("hidden compatible short command = %q, %v", got, ok)
5169 }
5170 if got, ok := c.CustomCommand("/pwf:status now"); !ok || got != "STATUS now" {
5171 t.Fatalf("canonical plugin status command = %q, %v", got, ok)
5172 }
5173 cmds := c.Commands()
5174 canonicalFound := false
5175 hiddenFound := false
5176 for _, cmd := range cmds {
5177 if cmd.Name == "pwf:plan" && cmd.Plugin == "pwf" && cmd.ShortName == "plan" && !cmd.Hidden {
5178 canonicalFound = true
5179 }
5180 if cmd.Name == "status" && cmd.Plugin == "pwf" && cmd.ShortName == "status" && cmd.Hidden {
5181 hiddenFound = true
5182 }
5183 }
5184 if !canonicalFound || !hiddenFound {
5185 t.Fatalf("plugin command metadata missing: %+v", cmds)
5186 }
5187 }
5188
5189 // TestReloadCommandsEmptySet verifies that deleting all command files and
5190 // reloading results in an empty Commands() slice, while the slash_command tool
5191 // still exists in the Registry (containing only Skills).
5192 func TestReloadCommandsEmptySet(t *testing.T) {
5193 home := t.TempDir()
5194 t.Setenv("HOME", home)
5195 t.Setenv("USERPROFILE", home)
5196 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
5197 t.Setenv("AppData", filepath.Join(home, "AppData"))
5198
5199 wsRoot := t.TempDir()
5200 cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
5201 writeCmdFile(t, cmdDir, "temp", "Temp cmd", "Temp $1")
5202
5203 sk := skill.Skill{
5204 Name: "preserved",
5205 Description: "A skill to keep",
5206 Body: "Skill body: {{.Input}}",
5207 }
5208
5209 reg := tool.NewRegistry()
5210 c := newOwnedTestController(t, Options{
5211 Sink: &typedNilControllerSink{},
5212 Registry: reg,
5213 WorkspaceRoot: wsRoot,
5214 Skills: []skill.Skill{sk},
5215 })
5216
5217 if err := c.ReloadCommands(context.Background()); err != nil {
5218 t.Fatalf("initial reload: %v", err)
5219 }
5220 if got := len(c.Commands()); got != 1 {
5221 t.Fatalf("Commands() = %d, want 1", got)
5222 }
5223
5224 // Delete all command files
5225 if err := os.Remove(filepath.Join(cmdDir, "temp.md")); err != nil {
5226 t.Fatal(err)
5227 }
5228
5229 if err := c.ReloadCommands(context.Background()); err != nil {
5230 t.Fatalf("reload after delete all: %v", err)
5231 }
5232 if got := len(c.Commands()); got != 0 {
5233 t.Fatalf("Commands() = %d after delete all, want 0", got)
5234 }
5235
5236 // /temp should no longer be found
5237 if _, ok := c.CustomCommand("/temp x"); ok {
5238 t.Error("/temp should NOT be found after deletion")
5239 }
5240
5241 // slash_command tool should still exist (for Skills)
5242 slashTool, found := reg.Get("slash_command")
5243 if !found {
5244 t.Fatal("slash_command tool should still exist even with 0 commands")
5245 }
5246 // It should still contain the skill
5247 if !strings.Contains(slashTool.Description(), "preserved") {
5248 t.Error("skill 'preserved' should still appear in slash_command tool Description")
5249 }
5250 }
5251
5252 // TestReloadCommandsDesktopManagementNotice verifies the desktop/HTTP path:
5253 // when the frontend submits "/reload-cmd" as raw input, Submit → managementNotice
5254 // handles it and emits a Notice event with the correct count.
5255 func TestReloadCommandsDesktopManagementNotice(t *testing.T) {
5256 home := t.TempDir()
5257 t.Setenv("HOME", home)
5258 t.Setenv("USERPROFILE", home)
5259 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
5260 t.Setenv("AppData", filepath.Join(home, "AppData"))
5261
5262 wsRoot := t.TempDir()
5263 cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
5264 writeCmdFile(t, cmdDir, "hello", "Greet", "Hello $1")
5265 writeCmdFile(t, cmdDir, "review", "Review code", "Review $1")
5266
5267 var notices []string
5268 sink := event.FuncSink(func(e event.Event) {
5269 if e.Kind == event.Notice {
5270 notices = append(notices, e.Text)
5271 }
5272 })
5273
5274 reg := tool.NewRegistry()
5275 c := newOwnedTestController(t, Options{
5276 Sink: sink,
5277 Registry: reg,
5278 WorkspaceRoot: wsRoot,
5279 })
5280
5281 // Initial load so Commands() is populated.
5282 if err := c.ReloadCommands(context.Background()); err != nil {
5283 t.Fatalf("initial reload: %v", err)
5284 }
5285 notices = nil // reset
5286
5287 // Desktop path: managementNotice("/reload-cmd") should emit a notice.
5288 handled := c.managementNotice("/reload-cmd")
5289 if !handled {
5290 t.Fatal("managementNotice(/reload-cmd) should return true")
5291 }
5292 if len(notices) != 1 {
5293 t.Fatalf("expected 1 notice, got %d: %v", len(notices), notices)
5294 }
5295 if !strings.Contains(notices[0], "commands reloaded") {
5296 t.Errorf("notice = %q, want 'commands reloaded'", notices[0])
5297 }
5298 if !strings.Contains(notices[0], "2 available") {
5299 t.Errorf("notice = %q, want '2 available'", notices[0])
5300 }
5301
5302 // Delete one command file and reload again.
5303 if err := os.Remove(filepath.Join(cmdDir, "hello.md")); err != nil {
5304 t.Fatal(err)
5305 }
5306 notices = nil
5307 handled = c.managementNotice("/reload-cmd")
5308 if !handled {
5309 t.Fatal("managementNotice(/reload-cmd) after delete should return true")
5310 }
5311 if len(notices) != 1 {
5312 t.Fatalf("expected 1 notice after delete, got %d: %v", len(notices), notices)
5313 }
5314 if !strings.Contains(notices[0], "1 available") {
5315 t.Errorf("notice after delete = %q, want '1 available'", notices[0])
5316 }
5317
5318 // Delete all and verify empty-set notice.
5319 if err := os.Remove(filepath.Join(cmdDir, "review.md")); err != nil {
5320 t.Fatal(err)
5321 }
5322 notices = nil
5323 handled = c.managementNotice("/reload-cmd")
5324 if !handled {
5325 t.Fatal("managementNotice(/reload-cmd) empty set should return true")
5326 }
5327 if len(notices) != 1 {
5328 t.Fatalf("expected 1 notice for empty set, got %d: %v", len(notices), notices)
5329 }
5330 if !strings.Contains(notices[0], "0 available") {
5331 t.Errorf("notice for empty set = %q, want '0 available'", notices[0])
5332 }
5333 }
5334
5335 // cmdNames is a test helper that extracts command names from a slice.
5336 func cmdNames(cmds []command.Command) []string {
5337 names := make([]string, len(cmds))
5338 for i, c := range cmds {
5339 names[i] = c.Name
5340 }
5341 return names
5342 }
5343
5344 // TestCacheColdAfterFailureFallsBackTo24h:配置加载失败/模型解析失败时
5345 // 保守回退 24h(评审 #7168 第 4 点)——不得用 10m 提前触发 prune。
5346 func TestCacheColdAfterFailureFallsBackTo24h(t *testing.T) {
5347 c := newOwnedTestController(t, Options{})
5348 orig := c.workspaceRoot
5349 c.workspaceRoot = "/nonexistent/definitely-missing-root"
5350 defer func() { c.workspaceRoot = orig }()
5351 if got := c.cacheColdAfter(); got != 24*time.Hour {
5352 t.Fatalf("load failure must fall back to 24h, got %v", got)
5353 }
5354 // 未知模型同样 24h
5355 c2 := newOwnedTestController(t, Options{})
5356 c2.selection.ref = "definitely-not-a-real-model-xyz"
5357 if got := c2.cacheColdAfter(); got != 24*time.Hour {
5358 t.Fatalf("ResolveModel failure must fall back to 24h, got %v", got)
5359 }
5360 }
5361
5361 lines GO