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