Files
DeepSeek-Reasonix/internal/control/controller_test.go
T

5361 lines
192 KiB
Go

package control
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"reflect"
"slices"
"strings"
"sync"
"sync/atomic"
"testing"
"time"
"reasonix/internal/agent"
"reasonix/internal/checkpoint"
"reasonix/internal/command"
"reasonix/internal/config"
"reasonix/internal/event"
"reasonix/internal/guardian"
"reasonix/internal/hook"
"reasonix/internal/i18n"
"reasonix/internal/jobs"
"reasonix/internal/memory"
"reasonix/internal/permission"
"reasonix/internal/plugin"
"reasonix/internal/pluginpkg"
"reasonix/internal/provider"
"reasonix/internal/session"
"reasonix/internal/skill"
"reasonix/internal/store"
"reasonix/internal/tool"
)
type typedNilControllerSink struct{}
func (*typedNilControllerSink) Emit(event.Event) {}
func TestResolvePlanDecisionRecordsDistinctOutcomes(t *testing.T) {
tests := []struct {
action PlanDecisionAction
allow bool
}{
{action: PlanDecisionStartExecution, allow: true},
{action: PlanDecisionRevisePlan, allow: false},
{action: PlanDecisionExitPlan, allow: false},
}
for _, tt := range tests {
t.Run(string(tt.action), func(t *testing.T) {
session := agent.NewSession("sys")
session.Add(provider.Message{Role: provider.RoleAssistant, Content: "proposed plan"})
exec := agent.New(nil, nil, session, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec})
id, reply := c.approval.registerDecisionKind(planApprovalTool, "", "", true, false, "plan", nil)
if err := c.ResolvePlanDecision(id, tt.action); err != nil {
t.Fatalf("ResolvePlanDecision: %v", err)
}
select {
case got := <-reply:
if got.allow != tt.allow {
t.Fatalf("reply allow = %v, want %v", got.allow, tt.allow)
}
default:
t.Fatal("plan decision did not unblock the approval waiter")
}
messages := session.Snapshot()
if len(messages) != 2 || len(messages[1].DecisionReceipts) != 1 {
t.Fatalf("persisted messages = %+v, want receipt attached to plan answer", messages)
}
receipt := messages[1].DecisionReceipts[0]
if receipt.Kind != "plan" || receipt.Outcome != string(tt.action) {
t.Fatalf("receipt = %+v, want plan/%s", receipt, tt.action)
}
})
}
}
func isolateControlConfigHome(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
t.Chdir(t.TempDir())
return home
}
func controlTestPluginByName(entries []config.PluginEntry, name string) (config.PluginEntry, bool) {
for _, entry := range entries {
if entry.Name == name {
return entry, true
}
}
return config.PluginEntry{}, false
}
type appendingRunner struct {
session *agent.Session
}
func (r appendingRunner) Run(_ context.Context, input string) error {
r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
return nil
}
type handoffRunner struct {
session *agent.Session
}
func (r handoffRunner) Run(_ context.Context, input string) error {
r.session.Add(provider.Message{Role: provider.RoleUser, Content: "handoff: " + input})
return nil
}
type sessionContextRunner struct {
parentSession string
jobSession string
}
func (r *sessionContextRunner) Run(ctx context.Context, input string) error {
r.parentSession = agent.ParentSession(ctx)
r.jobSession = jobs.SessionFromContext(ctx)
return nil
}
type cancelingRunner struct {
cancel context.CancelFunc
}
func (r cancelingRunner) Run(_ context.Context, _ string) error {
r.cancel()
return nil
}
// The gauge must measure what the trigger measures. Reporting the previous
// turn's billed usage is how a session displayed 8% while it was compacting.
func TestContextSnapshotMeasuresTheTriggerInput(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{{
{Type: provider.ChunkText, Text: "ok"},
{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 6840, CompletionTokens: 48, TotalTokens: 6888, ReasoningTokens: 48}},
{Type: provider.ChunkDone},
}}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{ContextWindow: 1_000_000}, event.Discard)
c := newOwnedTestController(t, Options{Runner: ag, Executor: ag})
if err := c.Run(context.Background(), "hello"); err != nil {
t.Fatal(err)
}
want := c.ContextMaintenanceSnapshot().ProjectedTokens
used, window := c.ContextSnapshot()
if used != want || used == 6888 || window != 1_000_000 {
t.Fatalf("ContextSnapshot() = (%d, %d), want (%d, 1000000): the gauge reads the trigger's live input, never the last turn's 6888", used, window, want)
}
}
type fakeControlTool struct{ name string }
func (t fakeControlTool) Name() string { return t.name }
func (fakeControlTool) Description() string {
return "fake"
}
func (fakeControlTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object"}`)
}
func (fakeControlTool) Execute(context.Context, json.RawMessage) (string, error) {
return "", nil
}
func (fakeControlTool) ReadOnly() bool { return true }
type startBackgroundJobTool struct {
started chan string
release chan struct{}
}
func TestCancelJobCannotCrossSessionBoundary(t *testing.T) {
manager := jobs.NewManager(event.Discard)
t.Cleanup(manager.Close)
pathA := filepath.Join(t.TempDir(), "session-a.jsonl")
pathB := filepath.Join(t.TempDir(), "session-b.jsonl")
controllerA := newOwnedTestController(t, Options{Jobs: manager, SessionPath: pathA})
controllerB := newOwnedTestController(t, Options{Jobs: manager, SessionPath: pathB})
t.Cleanup(controllerA.Close)
t.Cleanup(controllerB.Close)
jobA := manager.StartForSession(agent.BranchID(pathA), "bash", "a", func(ctx context.Context, _ io.Writer) (string, error) {
<-ctx.Done()
return "", ctx.Err()
})
jobB := manager.StartForSession(agent.BranchID(pathB), "bash", "b", func(ctx context.Context, _ io.Writer) (string, error) {
<-ctx.Done()
return "", ctx.Err()
})
if controllerA.CancelJob(jobB.ID) {
t.Fatal("controller A cancelled controller B's job")
}
if !controllerA.CancelJob(jobA.ID) {
t.Fatal("controller A did not cancel its own job")
}
if !controllerB.CancelJob(jobB.ID) {
t.Fatal("controller B did not retain ownership of its job")
}
}
func (t startBackgroundJobTool) Name() string { return "start_background_job" }
func (t startBackgroundJobTool) Description() string { return "start background job" }
func (t startBackgroundJobTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object"}`)
}
func (t startBackgroundJobTool) ReadOnly() bool { return true }
func (t startBackgroundJobTool) Execute(ctx context.Context, _ json.RawMessage) (string, error) {
jm, ok := jobs.FromContext(ctx)
if !ok {
return "", nil
}
j := jm.StartForSession(jobs.SessionFromContext(ctx), "bash", "controller", func(_ context.Context, out io.Writer) (string, error) {
_, _ = io.WriteString(out, "before\n")
<-t.release
_, _ = io.WriteString(out, "after\n")
return "", nil
})
t.started <- j.ID
return "started " + j.ID, nil
}
type recordingProvider struct {
name string
streams [][]provider.Chunk
requests []provider.Request
}
func (p *recordingProvider) Name() string {
if p.name != "" {
return p.name
}
return "recording"
}
func (p *recordingProvider) Stream(_ context.Context, req provider.Request) (<-chan provider.Chunk, error) {
p.requests = append(p.requests, req)
i := len(p.requests) - 1
if i >= len(p.streams) {
i = len(p.streams) - 1
}
chunks := p.streams[i]
ch := make(chan provider.Chunk, len(chunks))
for _, c := range chunks {
ch <- c
}
close(ch)
return ch, nil
}
func requestMessagesText(messages []provider.Message) string {
var b strings.Builder
for _, m := range messages {
b.WriteString(string(m.Role))
b.WriteString(": ")
b.WriteString(m.Content)
b.WriteByte('\n')
}
return b.String()
}
func lastUserMessage(messages []provider.Message) string {
for _, v := range slices.Backward(messages) {
if v.Role == provider.RoleUser {
return v.Content
}
}
return ""
}
func TestNewTreatsTypedNilSinkAsDiscard(t *testing.T) {
var sink *typedNilControllerSink
c := newOwnedTestController(t, Options{Sink: sink})
c.notice("typed nil sink should not panic")
}
func TestClearSessionMarksCleanupPendingBeforeReturningForRunningJobs(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "old.jsonl")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(oldPath, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
jm := jobs.NewManager(event.Discard)
release := make(chan struct{})
started := make(chan struct{})
defer func() {
close(release)
jm.Close()
}()
jm.StartForSession(agent.BranchID(oldPath), "task", "stuck clear", func(ctx context.Context, _ io.Writer) (string, error) {
close(started)
<-ctx.Done()
<-release
return "", ctx.Err()
})
select {
case <-started:
case <-time.After(30 * time.Second):
t.Fatal("background job never started")
}
ctrl := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: oldPath, Label: "test", Jobs: jm})
if err := ctrl.ClearSession(); err != nil {
t.Fatalf("ClearSession: %v", err)
}
if !agent.IsCleanupPending(oldPath) {
t.Fatalf("old session should be cleanup-pending before ClearSession returns")
}
if _, err := os.Stat(oldPath); err != nil {
t.Fatalf("old session file should remain until delayed cleanup: %v", err)
}
sessions, err := agent.ListSessions(dir)
if err != nil {
t.Fatal(err)
}
for _, session := range sessions {
if filepath.Clean(session.Path) == filepath.Clean(oldPath) {
t.Fatalf("cleanup-pending old session still listed: %+v", sessions)
}
}
}
func TestClearSessionQueuesSessionStartHookContext(t *testing.T) {
dir := t.TempDir()
oldPath := filepath.Join(dir, "old.jsonl")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(oldPath, []byte(`{"role":"user","content":"old"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
hooks := hook.NewRunner([]hook.ResolvedHook{{
HookConfig: hook.HookConfig{Command: "session-start"},
Event: hook.SessionStart,
}}, dir, func(context.Context, hook.SpawnInput) hook.SpawnResult {
return hook.SpawnResult{ExitCode: 0, Stdout: "clear session context"}
}, nil)
c := newOwnedTestController(t, Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: oldPath, Label: "test", Hooks: hooks})
if err := c.ClearSession(); err != nil {
t.Fatalf("ClearSession: %v", err)
}
got := c.Compose("next")
if !strings.Contains(got, `<hook-context event="SessionStart">`) || !strings.Contains(got, "clear session context") || !strings.HasSuffix(got, "next") {
t.Fatalf("clear session did not queue SessionStart hook context: %q", got)
}
}
func TestReconcileCleanupPendingRemovesOrphanedArtifacts(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "orphan.jsonl")
if err := os.WriteFile(path, []byte(`{"role":"user","content":"orphan"}`+"\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := agent.SaveBranchMeta(path, agent.BranchMeta{Name: "orphan"}); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(jobs.ArtifactDir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(jobs.ArtifactDir(path), "job.log"), []byte("job output"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(ckptDir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := agent.MarkCleanupPending(path, "delete"); err != nil {
t.Fatal(err)
}
if err := ReconcileCleanupPending(dir); err != nil {
t.Fatalf("ReconcileCleanupPending: %v", err)
}
for _, p := range []string{path, agent.BranchMetaPath(path), jobs.ArtifactDir(path), ckptDir(path), agent.CleanupPendingPath(path)} {
if _, err := os.Stat(p); !os.IsNotExist(err) {
t.Fatalf("%s still exists after reconciliation (err=%v)", p, err)
}
}
}
func TestTurnOutcomeClassifiesFinalReadiness(t *testing.T) {
err := &agent.FinalReadinessError{Attempts: 3, Reason: "missing verification"}
if got := turnOutcome(err); got != event.TurnOutcomeFinalReadiness {
t.Fatalf("turnOutcome() = %q, want %q", got, event.TurnOutcomeFinalReadiness)
}
if got := turnOutcome(errors.New("provider failed")); got != "" {
t.Fatalf("ordinary turn outcome = %q, want empty", got)
}
}
func TestRunTurnSnapshotsActivityWhenTranscriptChanges(t *testing.T) {
dir := t.TempDir()
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
path := filepath.Join(dir, "session.jsonl")
c := newOwnedTestController(t, Options{Runner: appendingRunner{session: sess}, Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
if err := c.runTurn(context.Background(), "hello"); err != nil {
t.Fatal(err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatal(err)
}
if len(loaded.Messages) != 2 {
t.Fatalf("saved messages = %d, want system + user", len(loaded.Messages))
}
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("load activity meta ok=%v err=%v", ok, err)
}
if meta.UpdatedAt.IsZero() {
t.Fatal("activity meta should be marked")
}
}
func TestFinishInFlightTurnKeepsMarkerUntilSnapshotSucceeds(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
start := sess.Len()
marker := c.markInFlightTurn(start, true)
if marker.ID == "" {
t.Fatal("in-flight marker was not created")
}
sess.Add(provider.Message{Role: provider.RoleUser, Content: "must become durable"})
if err := os.WriteFile(store.SessionEventLog(path), []byte(`{"schema_version":99,"type":"replace","messages":[]}`+"\n"), 0o600); err != nil {
t.Fatal(err)
}
c.finishInFlightTurn(start, marker)
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok || meta.InFlightTurn == nil || meta.InFlightTurn.ID != marker.ID {
t.Fatalf("marker after failed snapshot = %+v ok=%v err=%v, want original marker", meta.InFlightTurn, ok, err)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Fatalf("failed snapshot unexpectedly wrote transcript: stat err=%v", err)
}
if err := os.Remove(store.SessionEventLog(path)); err != nil {
t.Fatal(err)
}
c.finishInFlightTurn(start, marker)
meta, ok, err = agent.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta after successful snapshot ok=%v err=%v", ok, err)
}
if meta.InFlightTurn != nil {
t.Fatalf("marker survived successful snapshot: %+v", meta.InFlightTurn)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatal(err)
}
if got := loaded.Snapshot(); len(got) != 2 || got[1].Content != "must become durable" {
t.Fatalf("durable transcript = %+v", got)
}
}
func TestResumePreservesTranscriptWhenCrashFollowsFinalSnapshot(t *testing.T) {
dir := schemaOneTempDir(t)
path := filepath.Join(dir, "post-snapshot-crash.jsonl")
sess := agent.NewSession("sys")
if err := sess.Save(path); err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
marker := c.markInFlightTurn(sess.Len(), true)
sess.Add(provider.Message{Role: provider.RoleUser, Content: "completed prompt"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "completed answer"})
digest, err := sess.ContentDigest()
if err != nil {
t.Fatal(err)
}
committedMarker, matched, err := agent.PrepareSessionInFlightTurnCommit(path, marker, digest)
if err != nil || !matched {
t.Fatalf("PrepareSessionInFlightTurnCommit matched=%v err=%v", matched, err)
}
if err := sess.SaveSnapshot(path); err != nil {
t.Fatal(err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatal(err)
}
recoveredExec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
recovered := newOwnedTestController(t, Options{Executor: recoveredExec, SessionDir: dir, SessionPath: path, Label: "test"})
recovered.recoverInterruptedTurn(path)
msgs := recoveredExec.Session().Snapshot()
if len(msgs) != 3 || msgs[2].Content != "completed answer" || msgs[2].LocalOnly {
t.Fatalf("post-snapshot recovery changed durable transcript: %+v", msgs)
}
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
}
if meta.InFlightTurn != nil {
t.Fatalf("committed marker survived recovery: %+v (expected %+v)", meta.InFlightTurn, committedMarker)
}
}
func TestRunInjectsParentSessionForJobs(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
runner := &sessionContextRunner{}
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Runner: runner, Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
t.Cleanup(c.Close)
if err := c.Run(context.Background(), "hello"); err != nil {
t.Fatal(err)
}
want := agent.BranchID(path)
if runner.parentSession != want {
t.Fatalf("ParentSession = %q, want %q", runner.parentSession, want)
}
if runner.jobSession != want {
t.Fatalf("jobs session = %q, want %q", runner.jobSession, want)
}
}
func TestRunStopHookIgnoresCanceledCallerContext(t *testing.T) {
runCtx, cancel := context.WithCancel(context.Background())
var stopCalls int
var stopErr error
hooks := hook.NewRunner([]hook.ResolvedHook{{
HookConfig: hook.HookConfig{Command: "record-stop"},
Event: hook.Stop,
Scope: hook.ScopeProject,
}}, "", func(ctx context.Context, in hook.SpawnInput) hook.SpawnResult {
stopCalls++
stopErr = ctx.Err()
return hook.SpawnResult{ExitCode: 0}
}, nil)
c := newOwnedTestController(t, Options{
Runner: cancelingRunner{cancel: cancel},
Hooks: hooks,
})
t.Cleanup(c.Close)
if err := c.Run(runCtx, "hello"); err != nil {
t.Fatal(err)
}
if runCtx.Err() != context.Canceled {
t.Fatalf("caller context err = %v, want %v", runCtx.Err(), context.Canceled)
}
if stopCalls != 1 {
t.Fatalf("Stop hook calls = %d, want 1", stopCalls)
}
if stopErr != nil {
t.Fatalf("Stop hook context err = %v, want nil", stopErr)
}
}
func TestSetSessionPathAdoptsTemporaryBackgroundJobs(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
started := make(chan string, 1)
release := make(chan struct{})
jm := jobs.NewManager(event.Discard)
reg := tool.NewRegistry()
reg.Add(startBackgroundJobTool{started: started, release: release})
prov := &scriptedTurns{turns: [][]provider.Chunk{
toolCallTurn("call-1", "start_background_job", `{}`),
textTurn("done"),
}}
ag := agent.New(prov, reg, agent.NewSession("sys"), agent.Options{Jobs: jm}, event.Discard)
c := newOwnedTestController(t, Options{Runner: ag, Executor: ag, SessionDir: dir, Label: "test", Jobs: jm})
defer c.Close()
if err := c.Run(context.Background(), "start background job"); err != nil && !errors.As(err, new(*agent.FinalReadinessError)) {
t.Fatal(err)
}
jobID := <-started
c.SetSessionPath(path)
close(release)
parentSession := agent.BranchID(path)
res := c.jobs.WaitForSession(context.Background(), parentSession, []string{jobID}, 1)
if len(res) != 1 || !strings.Contains(res[0].Output, "before\n") || !strings.Contains(res[0].Output, "after\n") {
t.Fatalf("adopted controller job = %+v, want before/after output", res)
}
if _, err := os.Stat(filepath.Join(jobs.ArtifactDir(path), jobID+".log")); err != nil {
t.Fatalf("controller job artifact should be under persistent sidecar: %v", err)
}
}
func TestGoalStatePersistsNextToSessionPath(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
c.SetGoalWithResearchMode("fix the typo", GoalResearchOn)
c.GoalStrict(true)
data, err := os.ReadFile(goalStatePath(path))
if err != nil {
t.Fatal(err)
}
var state goalState
if err := json.Unmarshal(data, &state); err != nil {
t.Fatal(err)
}
if state.Goal != "fix the typo" || state.Status != GoalStatusRunning || state.BudgetClass != budgetClassResearch || state.ResearchMode != GoalResearchOff || !state.Strict {
t.Fatalf("goal state = %+v, want running strict research goal", state)
}
}
func TestSetGoalDurableRestoresInMemoryStateWhenSidecarWriteFails(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
c.SetGoal("keep the old goal")
oldStatus := c.GoalStatus()
notDirectory := filepath.Join(dir, "not-a-directory")
if err := os.WriteFile(notDirectory, []byte("block nested writes"), 0o600); err != nil {
t.Fatal(err)
}
c.goals.setStatePath(filepath.Join(notDirectory, "goal.json"))
if err := c.SetGoalDurable("replace the goal"); err == nil {
t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent")
}
if got := c.Goal(); got != "keep the old goal" {
t.Fatalf("Goal() after failed durable write = %q, want old Goal", got)
}
if got := c.GoalStatus(); got != oldStatus {
t.Fatalf("GoalStatus() after failed durable write = %q, want %q", got, oldStatus)
}
}
func TestSetGoalDurableNeverCreatesLegacyArchive(t *testing.T) {
root := t.TempDir()
path := filepath.Join(root, "session.jsonl")
sink := &noticeSink{}
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{
Executor: exec,
SessionDir: root,
SessionPath: path,
WorkspaceRoot: root,
Sink: sink,
Label: "test",
})
c.SetGoal("keep the old goal")
notDirectory := filepath.Join(root, "not-a-directory")
if err := os.WriteFile(notDirectory, []byte("block nested writes"), 0o600); err != nil {
t.Fatal(err)
}
c.goals.setStatePath(filepath.Join(notDirectory, "goal.json"))
goal := "investigate the root cause and fix the performance regression, then verify with tests"
if err := c.SetGoalDurable(goal); err == nil {
t.Fatal("SetGoalDurable succeeded despite an invalid sidecar parent")
}
if _, err := os.Stat(filepath.Join(root, ".reasonix", "autoresearch")); !os.IsNotExist(err) {
t.Fatalf("durable Goal update created legacy archive: %v", err)
}
for _, notice := range sink.notices() {
if strings.Contains(strings.ToLower(notice), "autoresearch task created") || strings.Contains(strings.ToLower(notice), "legacy research archive loaded") {
t.Fatalf("durable failure emitted success notice %q", notice)
}
}
}
func TestResumeDoesNotActivateLegacyTranscriptOrTerminalGoalTodos(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
loaded := agent.NewSession("sys")
loaded.Add(provider.Message{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{{
ID: "todo-1",
Name: "todo_write",
Arguments: `{"todos":[{"content":"Step 1","status":"in_progress"}]}`,
}},
})
loaded.Add(provider.Message{
Role: provider.RoleTool,
ToolCallID: "todo-1",
Name: "todo_write",
Content: "ok",
})
if err := os.WriteFile(goalStatePath(path), []byte(`{"status":"complete","todos":[{"content":"Step 1","status":"completed"}]}`), 0o644); err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test"})
c.Resume(loaded, path)
if got := c.Todos(); len(got) != 0 {
t.Fatalf("Todos() after legacy resume = %+v, want archival todo data inactive", got)
}
}
func TestResumeDoesNotActivateLegacyTranscriptOrRunningGoalTodos(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
loaded := agent.NewSession("sys")
loaded.Add(provider.Message{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{{
ID: "todo-1",
Name: "todo_write",
Arguments: `{"todos":[{"content":"Step 1","status":"in_progress"}]}`,
}},
})
loaded.Add(provider.Message{
Role: provider.RoleTool,
ToolCallID: "todo-1",
Name: "todo_write",
Content: "ok",
})
if err := os.WriteFile(goalStatePath(path), []byte(`{"status":"running","todos":[{"content":"Step 1","status":"completed"}]}`), 0o644); err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test"})
c.Resume(loaded, path)
if got := c.Todos(); len(got) != 0 {
t.Fatalf("Todos() after legacy resume = %+v, want running goal todos inactive", got)
}
}
func TestResumeRestoresRunningAutoResearchGoalFromSidecar(t *testing.T) {
root := t.TempDir()
if resolved, err := filepath.EvalSymlinks(root); err == nil {
root = resolved
}
path := filepath.Join(root, "session.jsonl")
taskID := "investigate-runtime-resume"
if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.MkdirAll(filepath.Join(root, ".reasonix", "autoresearch", taskID, "logs"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "task_spec.json"), []byte(`{"task_id":"investigate-runtime-resume","goal":"investigate runtime resume","allowed_operations":{"write":true},"success_criteria":[{"id":"criterion-1","description":"resume keeps Goal active","required":true}]}`), 0o644); err != nil {
t.Fatal(err)
}
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 {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "directions_tried.json"), []byte(`{"task_id":"investigate-runtime-resume","directions":[]}`), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "state", "findings.jsonl"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, ".reasonix", "autoresearch", taskID, "logs", "heartbeat.jsonl"), nil, 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(goalStatePath(path), []byte(`{"goal":"investigate runtime resume","status":"running","researchMode":1,"autoResearchTaskID":"investigate-runtime-resume"}`), 0o644); err != nil {
t.Fatal(err)
}
loaded := agent.NewSession("sys")
exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, WorkspaceRoot: root, SessionDir: root, Label: "test"})
c.Resume(loaded, path)
if got := c.Goal(); got != "investigate runtime resume" {
t.Fatalf("Goal() after resume = %q, want running goal from sidecar", got)
}
composed := c.Compose("continue")
if strings.Contains(strings.ToLower(composed), "autoresearch") {
t.Fatalf("Compose after resume exposed removed AutoResearch protocol:\n%s", composed)
}
// The class-derived turn quota is retired: a migrated legacy Goal is
// bounded by what the user configures, not by a number derived from its text.
if got := c.GoalRuntime().TurnsLimit; got != 0 {
t.Fatalf("resumed legacy Goal turn budget = %d, want it retired", got)
}
}
func TestRunTurnRecordsDisplayForPersistedUserMessage(t *testing.T) {
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Runner: handoffRunner{session: sess}, Executor: exec})
var gotContent, gotDisplay string
c.SetDisplayRecorder(func(content, display string) {
gotContent = content
gotDisplay = display
})
if err := c.runTurnWithRawDisplay(context.Background(), "expanded prompt", "raw prompt", "visible prompt"); err != nil {
t.Fatal(err)
}
if gotContent != "handoff: expanded prompt" {
t.Fatalf("display recorded against %q, want persisted user message", gotContent)
}
if gotDisplay != "visible prompt" {
t.Fatalf("display = %q, want visible prompt", gotDisplay)
}
}
func TestSnapshotDoesNotRefreshSessionActivity(t *testing.T) {
dir := t.TempDir()
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test", ModelRef: "provider/model-a"})
c.SetSessionPath(filepath.Join(dir, "session.jsonl"))
if err := c.SnapshotActivity(); err != nil {
t.Fatal(err)
}
first, ok, err := agent.LoadBranchMeta(c.SessionPath())
if err != nil || !ok {
t.Fatalf("load initial meta ok=%v err=%v", ok, err)
}
time.Sleep(10 * time.Millisecond)
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "saved without activity"})
if err := c.Snapshot(); err != nil {
t.Fatal(err)
}
second, ok, err := agent.LoadBranchMeta(c.SessionPath())
if err != nil || !ok {
t.Fatalf("load second meta ok=%v err=%v", ok, err)
}
if !second.UpdatedAt.Equal(first.UpdatedAt) {
t.Fatalf("Snapshot refreshed activity: first=%s second=%s", first.UpdatedAt, second.UpdatedAt)
}
if second.Model != "provider/model-a" {
t.Fatalf("snapshot model = %q, want provider/model-a", second.Model)
}
}
func TestSnapshotAdoptsNewerDiskForPureStalePrefix(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleExec := agent.New(nil, nil, staleSess, agent.Options{}, event.Discard)
sink := &noticeSink{}
stale := newOwnedTestController(t, Options{Executor: staleExec, SessionDir: dir, SessionPath: path, Label: "test", Sink: sink})
currentSess := agent.NewSession("sys")
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "two"})
currentExec := agent.New(nil, nil, currentSess, agent.Options{}, event.Discard)
current := newOwnedTestController(t, Options{Executor: currentExec, SessionDir: dir, SessionPath: path, Label: "test"})
if err := current.SnapshotActivity(); err != nil {
t.Fatalf("SnapshotActivity current: %v", err)
}
if err := stale.Snapshot(); err != nil {
t.Fatalf("Snapshot stale prefix: %v", err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := len(loaded.Messages); got != 5 {
t.Fatalf("message count after stale snapshot = %d, want 5", got)
}
if got := loaded.Messages[4].Content; got != "two" {
t.Fatalf("last message after stale snapshot = %q, want %q", got, "two")
}
if got := len(stale.executor.Session().Snapshot()); got != 5 {
t.Fatalf("stale controller adopted message count = %d, want 5", got)
}
notice, ok := sink.lastNotice()
if !ok || notice.Code != event.NoticeCodeSessionRecoveryAdopted || notice.Audience != event.NoticeAudienceOperator {
t.Fatalf("adoption notice = %+v, want typed operator recovery notice", notice)
}
}
func TestSnapshotRecoversDivergedControllerTranscript(t *testing.T) {
dir := schemaOneTempDir(t)
path := filepath.Join(dir, "session.jsonl")
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
staleExec := agent.New(nil, nil, staleSess, agent.Options{}, event.Discard)
stale := newOwnedTestController(t, Options{Executor: staleExec, SessionDir: dir, SessionPath: path, Label: "test"})
currentSess := agent.NewSession("sys")
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
currentExec := agent.New(nil, nil, currentSess, agent.Options{}, event.Discard)
current := newOwnedTestController(t, Options{Executor: currentExec, SessionDir: dir, SessionPath: path, Label: "test"})
if err := current.SnapshotActivity(); err != nil {
t.Fatalf("SnapshotActivity current: %v", err)
}
if err := stale.Snapshot(); err != nil {
t.Fatalf("Snapshot stale diverged: %v", err)
}
recoveryPath := stale.SessionPath()
if recoveryPath == path || recoveryPath == "" {
t.Fatalf("stale session path after recovery = %q, want recovery path", recoveryPath)
}
recovered, err := agent.LoadSession(recoveryPath)
if err != nil {
t.Fatalf("LoadSession recovery: %v", err)
}
if got := recovered.Messages[len(recovered.Messages)-1].Content; got != "local second" {
t.Fatalf("recovery tail = %q, want local second", got)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession original: %v", err)
}
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "disk second" {
t.Fatalf("original tail = %q, want disk second", got)
}
}
// TestSnapshotConflictRecoveryTransplantsInFlightTurnMarker: when a snapshot
// conflict forks the running turn onto a recovery branch, the in-flight-turn
// marker must move with it. Left on the original branch, the stale marker
// makes the next open of that branch strip messages from a turn that in fact
// kept running (and completed) on the recovery branch.
func TestSnapshotConflictRecoveryTransplantsInFlightTurnMarker(t *testing.T) {
dir := schemaOneTempDir(t)
path := filepath.Join(dir, "session.jsonl")
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
staleExec := agent.New(nil, nil, staleSess, agent.Options{}, event.Discard)
stale := newOwnedTestController(t, Options{Executor: staleExec, SessionDir: dir, SessionPath: path, Label: "test"})
currentSess := agent.NewSession("sys")
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
currentExec := agent.New(nil, nil, currentSess, agent.Options{}, event.Discard)
current := newOwnedTestController(t, Options{Executor: currentExec, SessionDir: dir, SessionPath: path, Label: "test"})
if err := current.SnapshotActivity(); err != nil {
t.Fatalf("SnapshotActivity current: %v", err)
}
// The stale runtime had a foreground turn running when the conflict fired.
if err := agent.MarkSessionInFlightTurn(path, 2, true); err != nil {
t.Fatalf("MarkSessionInFlightTurn: %v", err)
}
markedMeta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok || markedMeta.InFlightTurn == nil {
t.Fatalf("LoadBranchMeta marked ok=%v err=%v meta=%+v", ok, err, markedMeta)
}
markedAt := markedMeta.InFlightTurn.StartedAt
if err := stale.Snapshot(); err != nil {
t.Fatalf("Snapshot stale diverged: %v", err)
}
recoveryPath := stale.SessionPath()
if recoveryPath == path || recoveryPath == "" {
t.Fatalf("stale session path after recovery = %q, want recovery path", recoveryPath)
}
origMeta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta original ok=%v err=%v", ok, err)
}
if origMeta.InFlightTurn != nil {
t.Fatal("in-flight turn marker left on the forked-from branch; reopening it would strip the turn")
}
recMeta, ok, err := agent.LoadBranchMeta(recoveryPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
}
if recMeta.InFlightTurn == nil {
t.Fatal("in-flight turn marker not transplanted to the recovery branch")
}
if recMeta.InFlightTurn.StartMessageIndex != 2 || !recMeta.InFlightTurn.PreserveUser {
t.Fatalf("transplanted marker = %+v, want start index 2 with preserve_user", recMeta.InFlightTurn)
}
if !recMeta.InFlightTurn.StartedAt.Equal(markedAt) {
t.Fatalf("transplanted marker time = %v, want original %v", recMeta.InFlightTurn.StartedAt, markedAt)
}
}
// TestRecoverInterruptedTurnSparesTurnContinuedOnRecoveryBranch covers the
// legacy leftovers of the marker transplant: runtimes predating it forked a
// recovery branch mid-turn and left the in-flight marker on the original
// branch. Opening the original must clear the stale marker without stripping
// a turn that in fact kept running on the recovery branch.
func TestRecoverInterruptedTurnSparesTurnContinuedOnRecoveryBranch(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
orig := agent.NewSession("sys")
orig.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
orig.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial"})
if err := orig.Save(path); err != nil {
t.Fatalf("Save original: %v", err)
}
if err := agent.MarkSessionInFlightTurn(path, 1, true); err != nil {
t.Fatalf("MarkSessionInFlightTurn: %v", err)
}
// A legacy runtime forked the running turn onto a recovery branch and
// left the marker behind on the original.
forked := agent.NewSession("sys")
forked.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
forked.Add(provider.Message{Role: provider.RoleAssistant, Content: "continued elsewhere"})
if _, err := forked.SaveRecoveryBranch(agent.RecoveryBranchOptions{OriginalPath: path}); err != nil {
t.Fatalf("SaveRecoveryBranch: %v", err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
c.recoverInterruptedTurn(path)
if got := c.executor.Session().Len(); got != 3 {
t.Fatalf("message count after reopening forked-from branch = %d, want 3 (turn stripped despite recovery child)", got)
}
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
}
if meta.InFlightTurn != nil {
t.Fatal("fork-orphaned in-flight marker not cleared")
}
}
// TestRecoverInterruptedTurnPreservesGenuineCrashDisplay pins the crash-recovery
// behavior the recovery-child guard must not swallow: with no recovery branch
// in sight, an in-flight marker means the runtime died mid-turn and the
// partial tail becomes provider-excluded display history.
func TestRecoverInterruptedTurnPreservesGenuineCrashDisplay(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
orig := agent.NewSession("sys")
orig.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
orig.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial"})
if err := orig.Save(path); err != nil {
t.Fatalf("Save original: %v", err)
}
if err := agent.MarkSessionInFlightTurn(path, 1, true); err != nil {
t.Fatalf("MarkSessionInFlightTurn: %v", err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
c.recoverInterruptedTurn(path)
if got := c.executor.Session().Len(); got != 3 {
t.Fatalf("message count after crash recovery = %d, want system + user + local recovery", got)
}
recovery := c.executor.Session().Snapshot()[2]
if !recovery.LocalOnly || recovery.Content != "partial" || recovery.InterruptedTurn == nil || !recovery.InterruptedTurn.Pending {
t.Fatalf("crash display/recovery was not retained safely: %+v", recovery)
}
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
}
if meta.InFlightTurn != nil {
t.Fatal("in-flight marker not cleared after crash recovery")
}
}
func TestRecoverInterruptedTurnAfterCompactionRelocatesVisibleTurn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "compacted-crash.jsonl")
orig := agent.NewSession("sys")
for range 3 {
orig.Add(provider.Message{Role: provider.RoleUser, Content: "old task"})
orig.Add(provider.Message{Role: provider.RoleAssistant, Content: "old answer"})
}
staleStart := orig.Len()
if err := orig.Save(path); err != nil {
t.Fatalf("Save original: %v", err)
}
if err := agent.MarkSessionInFlightTurn(path, staleStart, true); err != nil {
t.Fatalf("MarkSessionInFlightTurn: %v", err)
}
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok || meta.InFlightTurn == nil {
t.Fatalf("LoadBranchMeta ok=%v err=%v meta=%+v", ok, err, meta)
}
compacted, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("Load compacting owner: %v", err)
}
compacted.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "<compaction-summary>\nold work\n</compaction-summary>"},
{Role: provider.RoleUser, Content: "update a.txt", CreatedAt: meta.InFlightTurn.StartedAt.UnixMilli() + 1},
{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{
ID: "write-1", Name: "write_file", Arguments: `{"path":"a.txt","content":"ok"}`,
}}},
{Role: provider.RoleTool, ToolCallID: "write-1", Name: "write_file", Content: "wrote a.txt"},
{Role: provider.RoleAssistant, Content: "partial final answer", ReasoningContent: "private partial reasoning"},
})
if compacted.Len() >= staleStart {
t.Fatalf("test setup did not stale boundary: compacted=%d start=%d", compacted.Len(), staleStart)
}
if err := compacted.SaveRewrite(path); err != nil {
t.Fatalf("Save compacted: %v", err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
c.recoverInterruptedTurn(path)
msgs := exec.Session().Snapshot()
userCount := 0
for _, m := range msgs {
if m.Role == provider.RoleUser && StripComposePrefixes(m.Content) == "update a.txt" {
userCount++
}
}
if userCount != 1 {
t.Fatalf("current user occurrences = %d, want 1: %+v", userCount, msgs)
}
if len(msgs) != 6 || !agent.IsCompactionSummary(msgs[1]) || msgs[3].Role != provider.RoleAssistant || msgs[4].Role != provider.RoleTool || !msgs[5].LocalOnly {
t.Fatalf("crash recovery transcript = %+v", msgs)
}
recovery := msgs[5].InterruptedTurn
if recovery == nil || !recovery.Pending || len(recovery.CompletedTools) != 1 || recovery.CompletedTools[0].Name != "write_file" || !recovery.DroppedPartialText || !recovery.DroppedPartialReasoning {
t.Fatalf("crash recovery metadata = %+v", recovery)
}
meta, ok, err = agent.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta after recovery ok=%v err=%v", ok, err)
}
if meta.InFlightTurn != nil {
t.Fatalf("in-flight marker survived recovery: %+v", meta.InFlightTurn)
}
}
// TestResumePreservesNewerWALAfterStaleMarker reproduces the destructive shape
// from issue #7956: the compatibility anchor is still at 1149, while the native
// event log has a newer 1315-message replace snapshot and an old marker at the
// anchor boundary. Recovery must keep the WAL state and clear only the stale
// marker instead of writing a backwards replace event.
func TestResumePreservesNewerWALAfterStaleMarker(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "wal-newer-than-anchor.jsonl")
sess := agent.NewSession("sys")
for sess.Len() < 1149 {
role := provider.RoleAssistant
if sess.Len()%2 == 1 {
role = provider.RoleUser
}
sess.Add(provider.Message{Role: role, Content: fmt.Sprintf("base-%d", sess.Len())})
}
anchor := sess.Snapshot()
if err := sess.Save(path); err != nil {
t.Fatalf("Save anchor: %v", err)
}
if err := agent.MarkSessionInFlightTurn(path, 1149, false); err != nil {
t.Fatalf("MarkSessionInFlightTurn: %v", err)
}
sess.Add(provider.Message{Role: provider.RoleUser, Content: "synthetic-start"})
for i := 1; i < 166; i++ {
role := provider.RoleAssistant
if i == 40 || i == 90 || i == 140 {
role = provider.RoleUser
}
sess.Add(provider.Message{Role: role, Content: fmt.Sprintf("tail-%d", i)})
}
if sess.Len() != 1315 {
t.Fatalf("test setup length = %d, want 1315", sess.Len())
}
if err := sess.SaveRewrite(path); err != nil {
t.Fatalf("Save newer WAL snapshot: %v", err)
}
var anchorJSON strings.Builder
for _, msg := range anchor {
encoded, err := json.Marshal(msg)
if err != nil {
t.Fatalf("marshal anchor message: %v", err)
}
anchorJSON.Write(encoded)
anchorJSON.WriteByte('\n')
}
if err := os.WriteFile(path, []byte(anchorJSON.String()), 0o600); err != nil {
t.Fatalf("restore stale compatibility anchor: %v", err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if loaded.Len() != 1315 {
t.Fatalf("WAL replay length = %d, want 1315", loaded.Len())
}
exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
c.recoverInterruptedTurn(path)
if got := exec.Session().Len(); got != 1315 {
t.Fatalf("recovery shrank newer WAL transcript to %d, want 1315", got)
}
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
}
if meta.InFlightTurn != nil {
t.Fatalf("stale marker survived safe recovery: %+v", meta.InFlightTurn)
}
if meta.Revision != 2 {
t.Fatalf("safe recovery rewrote WAL revision to %d, want unchanged 2", meta.Revision)
}
}
func TestSnapshotRewriteRecoversStaleControllerTranscript(t *testing.T) {
dir := schemaOneTempDir(t)
path := filepath.Join(dir, "session.jsonl")
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleExec := agent.New(nil, nil, staleSess, agent.Options{}, event.Discard)
stale := newOwnedTestController(t, Options{Executor: staleExec, SessionDir: dir, SessionPath: path, Label: "test"})
currentSess := agent.NewSession("sys")
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "two"})
currentExec := agent.New(nil, nil, currentSess, agent.Options{}, event.Discard)
current := newOwnedTestController(t, Options{Executor: currentExec, SessionDir: dir, SessionPath: path, Label: "test"})
if err := current.SnapshotActivity(); err != nil {
t.Fatalf("SnapshotActivity current: %v", err)
}
staleSess.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "summarized first"},
})
if err := stale.SnapshotRewrite(); err != nil {
t.Fatalf("SnapshotRewrite stale: %v", err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := len(loaded.Messages); got != 5 {
t.Fatalf("message count after stale rewrite = %d, want 5", got)
}
if got := loaded.Messages[4].Content; got != "two" {
t.Fatalf("last message after stale rewrite = %q, want %q", got, "two")
}
recoveryPath := stale.SessionPath()
if recoveryPath == path || recoveryPath == "" {
t.Fatalf("stale session path after rewrite recovery = %q, want recovery path", recoveryPath)
}
recovered, err := agent.LoadSession(recoveryPath)
if err != nil {
t.Fatalf("LoadSession recovery: %v", err)
}
if got := recovered.Messages[1].Content; got != "summarized first" {
t.Fatalf("recovery content = %q, want summarized first", got)
}
meta, ok, err := agent.LoadBranchMeta(recoveryPath)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta recovery ok=%v err=%v", ok, err)
}
if !meta.Recovered || meta.ParentID != agent.BranchID(path) {
t.Fatalf("recovery meta = %+v, want recovered parent", meta)
}
}
func TestSnapshotActivityPersistsOwnedCompactionRewrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
if err := sess.Save(path); err != nil {
t.Fatalf("Save base: %v", err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
exec := agent.New(nil, nil, loaded, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
// A mid-turn autosave can persist the pre-compaction prefix. Auto-compaction
// then rewrites older history inside the same turn; the final activity
// snapshot must persist that owned rewrite in place instead of branching.
loaded.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
if err := c.Snapshot(); err != nil {
t.Fatalf("Snapshot pre-compaction: %v", err)
}
loaded.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "<compaction-summary>\nSummary of earlier conversation: first -> one\n</compaction-summary>"},
{Role: provider.RoleUser, Content: "second"},
})
loaded.IncrementRewrite()
if err := c.SnapshotActivity(); err != nil {
t.Fatalf("SnapshotActivity after compaction: %v", err)
}
if got := c.SessionPath(); got != path {
t.Fatalf("session path after owned compaction = %q, want original %q", got, path)
}
reloaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession rewritten: %v", err)
}
if got := len(reloaded.Messages); got != 3 {
t.Fatalf("message count after compaction rewrite = %d, want 3: %+v", got, reloaded.Messages)
}
if got := reloaded.Messages[1].Content; !strings.Contains(got, "compaction-summary") {
t.Fatalf("compaction summary was not persisted: %+v", reloaded.Messages)
}
if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after owned compaction rewrite = %v err=%v, want none", matches, err)
}
}
func TestEditedPromptMetadataAfterMidTurnSnapshotStaysOnOwnedSession(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "edited-mid-turn.jsonl")
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "edited prompt"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "partial"})
if err := sess.Save(path); err != nil {
t.Fatalf("Save mid-turn transcript: %v", err)
}
// The model finishes after the periodic snapshot. Turn teardown then adds
// local inline-edit metadata to the already-persisted user message.
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "final"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
ctrl := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
ctrl.markEditedForNewUser(1, "original prompt")
if err := ctrl.SnapshotActivity(); err != nil {
t.Fatalf("SnapshotActivity edited turn: %v", err)
}
if got := ctrl.SessionPath(); got != path {
t.Fatalf("edited turn moved to recovery path %q, want owned path %q", got, path)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := len(loaded.Messages); got != 4 {
t.Fatalf("saved message count = %d, want 4: %+v", got, loaded.Messages)
}
user := loaded.Messages[1]
if !user.Edited || user.Original != "original prompt" || user.Content != "edited prompt" {
t.Fatalf("saved edited user metadata = %+v", user)
}
if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("spurious recovery branches = %v err=%v, want none", matches, err)
}
}
func TestRecoveryBranchPersistsLaterOwnedCompactionRewrite(t *testing.T) {
dir := schemaOneTempDir(t)
path := filepath.Join(dir, "session.jsonl")
currentSess := agent.NewSession("sys")
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
if err := currentSess.Save(path); err != nil {
t.Fatalf("Save current: %v", err)
}
localSess := agent.NewSession("sys")
localSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
localSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
localExec := agent.New(nil, nil, localSess, agent.Options{}, event.Discard)
sink := &noticeSink{}
c := newOwnedTestController(t, Options{Executor: localExec, SessionDir: dir, SessionPath: path, Label: "test", Sink: sink})
if err := c.Snapshot(); err != nil {
t.Fatalf("Snapshot initial recovery: %v", err)
}
recoveryPath := c.SessionPath()
if recoveryPath == "" || recoveryPath == path {
t.Fatalf("recovery path = %q, want distinct path", recoveryPath)
}
notices := sink.notices()
if len(notices) == 0 {
t.Fatal("initial recovery emitted no operator notice")
}
notice, ok := sink.lastNotice()
if !ok || notice.Code != event.NoticeCodeSessionRecoveryForked || notice.Audience != event.NoticeAudienceOperator {
t.Fatalf("fork recovery notice = %+v, want typed operator recovery notice", notice)
}
if got := notices[len(notices)-1]; strings.Contains(got, agent.BranchID(recoveryPath)) || strings.Contains(got, "recovery branch") {
t.Fatalf("initial recovery notice exposed internal branch detail: %q", got)
}
localSess.Add(provider.Message{Role: provider.RoleUser, Content: "continue"})
if err := c.Snapshot(); err != nil {
t.Fatalf("Snapshot recovery append: %v", err)
}
localSess.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "<compaction-summary>\nSummary of recovery branch work: first -> local\n</compaction-summary>"},
{Role: provider.RoleUser, Content: "continue"},
})
localSess.IncrementRewrite()
if err := c.SnapshotActivity(); err != nil {
t.Fatalf("SnapshotActivity recovery compaction: %v", err)
}
if got := c.SessionPath(); got != recoveryPath {
t.Fatalf("session path after recovery compaction = %q, want recovery %q", got, recoveryPath)
}
reloaded, err := agent.LoadSession(recoveryPath)
if err != nil {
t.Fatalf("LoadSession recovery: %v", err)
}
if got := len(reloaded.Messages); got != 3 {
t.Fatalf("message count after recovery compaction = %d, want 3: %+v", got, reloaded.Messages)
}
if got := reloaded.Messages[1].Content; !strings.Contains(got, "compaction-summary") {
t.Fatalf("recovery compaction summary was not persisted: %+v", reloaded.Messages)
}
if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("nested recovery branches after owned recovery compaction = %v err=%v, want none", matches, err)
}
}
func TestConcurrentSnapshotsShareSingleRecoveryHandoff(t *testing.T) {
dir := schemaOneTempDir(t)
path := filepath.Join(dir, "session.jsonl")
currentSess := agent.NewSession("sys")
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
if err := currentSess.Save(path); err != nil {
t.Fatalf("Save current: %v", err)
}
localSess := agent.NewSession("sys")
localSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
localSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
localExec := agent.New(nil, nil, localSess, agent.Options{}, event.Discard)
entered := make(chan controlRecoveryInfo, 16)
release := make(chan struct{})
c := newOwnedTestController(t, Options{
Executor: localExec,
SessionDir: dir,
SessionPath: path,
Label: "test",
OnSessionRecovered: func(info SessionRecoveryInfo) error {
entered <- controlRecoveryInfo{originalPath: info.OriginalPath, recoveryPath: info.RecoveryPath}
<-release
return nil
},
})
firstDone := make(chan error, 1)
go func() { firstDone <- c.Snapshot() }()
first := <-entered
if first.originalPath != path || first.recoveryPath == "" || first.recoveryPath == path {
t.Fatalf("first recovery info = %+v, want distinct recovery from original", first)
}
const racingSnapshots = 8
var wg sync.WaitGroup
errs := make(chan error, racingSnapshots)
for range racingSnapshots {
wg.Go(func() {
errs <- c.Snapshot()
})
}
select {
case extra := <-entered:
t.Fatalf("concurrent snapshot entered recovery while first handoff was blocked: %+v", extra)
case <-time.After(100 * time.Millisecond):
}
close(release)
if err := <-firstDone; err != nil {
t.Fatalf("first Snapshot: %v", err)
}
wg.Wait()
close(errs)
for err := range errs {
if err != nil {
t.Fatalf("racing Snapshot: %v", err)
}
}
select {
case extra := <-entered:
t.Fatalf("unexpected additional recovery after handoff completed: %+v", extra)
default:
}
if got := c.SessionPath(); got != first.recoveryPath {
t.Fatalf("controller session path = %q, want recovery %q", got, first.recoveryPath)
}
matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob recovery branches: %v", err)
}
recoveries := recoveryTranscriptPaths(matches)
if len(recoveries) != 1 || recoveries[0] != first.recoveryPath {
t.Fatalf("recovery branches = %v err=%v, want only %q", matches, err, first.recoveryPath)
}
}
func TestRecoverShutdownSnapshotPersistsAndReanchorsSession(t *testing.T) {
dir := schemaOneTempDir(t)
path := filepath.Join(dir, "session.jsonl")
base := agent.NewSession("sys")
base.Add(provider.Message{Role: provider.RoleUser, Content: "persisted"})
if err := base.SaveSnapshot(path); err != nil {
t.Fatalf("seed session: %v", err)
}
current, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "shutdown tail"})
exec := agent.New(nil, nil, current, agent.Options{}, event.Discard)
var handoff SessionRecoveryInfo
sink := &noticeSink{}
c := newOwnedTestController(t, Options{
Executor: exec,
SessionDir: dir,
SessionPath: path,
Label: "shutdown",
Sink: sink,
OnSessionRecovered: func(info SessionRecoveryInfo) error {
info.OnCommit(func() { handoff = info })
return nil
},
})
recoveryPath, err := c.recoverShutdownSnapshot(path, agent.ErrSessionFileLockHeld)
if err != nil {
t.Fatalf("recoverShutdownSnapshot: %v", err)
}
if recoveryPath == "" || recoveryPath == path {
t.Fatalf("recovery path = %q, want a distinct session", recoveryPath)
}
if c.SessionPath() != recoveryPath {
t.Fatalf("controller session path = %q, want %q", c.SessionPath(), recoveryPath)
}
if handoff.OriginalPath != path || handoff.RecoveryPath != recoveryPath || handoff.Reason != "shutdown session file lock timeout" {
t.Fatalf("shutdown recovery handoff = %+v", handoff)
}
recovered, err := agent.LoadSession(recoveryPath)
if err != nil {
t.Fatalf("load shutdown recovery: %v", err)
}
if got := recovered.Snapshot(); len(got) != 3 || got[2].Content != "shutdown tail" {
t.Fatalf("shutdown recovery transcript = %+v", got)
}
notice, ok := sink.lastNotice()
if !ok || notice.Code != event.NoticeCodeSessionShutdownRecoveryForked || notice.Audience != event.NoticeAudienceOperator {
t.Fatalf("shutdown recovery notice = %+v, want typed operator recovery notice", notice)
}
}
func recoveryTranscriptPaths(paths []string) []string {
out := paths[:0]
for _, path := range paths {
if !strings.HasSuffix(path, ".events.jsonl") &&
!strings.HasSuffix(path, ".turns.jsonl") &&
!strings.HasSuffix(path, ".turns.jsonl.damaged") {
out = append(out, path)
}
}
return out
}
type controlRecoveryInfo struct {
originalPath string
recoveryPath string
}
// blockedRecoveryHandoff is a controller whose first Snapshot has entered the
// recovery handoff and is parked inside OnSessionRecovered until release is
// closed, so tests can race other controller operations against an in-flight
// handoff.
type blockedRecoveryHandoff struct {
c *Controller
dir string
path string
local *agent.Session
first controlRecoveryInfo
release chan struct{}
firstDone chan error
}
func startBlockedRecoveryHandoff(t *testing.T) *blockedRecoveryHandoff {
t.Helper()
dir := schemaOneTempDir(t)
path := filepath.Join(dir, "session.jsonl")
currentSess := agent.NewSession("sys")
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
if err := currentSess.Save(path); err != nil {
t.Fatalf("Save current: %v", err)
}
localSess := agent.NewSession("sys")
localSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
localSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
localExec := agent.New(nil, nil, localSess, agent.Options{}, event.Discard)
entered := make(chan controlRecoveryInfo, 1)
release := make(chan struct{})
c := newOwnedTestController(t, Options{
Executor: localExec,
SessionDir: dir,
SessionPath: path,
Label: "test",
OnSessionRecovered: func(info SessionRecoveryInfo) error {
entered <- controlRecoveryInfo{originalPath: info.OriginalPath, recoveryPath: info.RecoveryPath}
<-release
return nil
},
})
t.Cleanup(func() {
select {
case <-release:
default:
close(release)
}
})
firstDone := make(chan error, 1)
go func() { firstDone <- c.Snapshot() }()
first := <-entered
if first.originalPath != path || first.recoveryPath == "" || first.recoveryPath == path {
t.Fatalf("recovery info = %+v, want distinct recovery from original", first)
}
return &blockedRecoveryHandoff{
c: c, dir: dir, path: path, local: localSess,
first: first, release: release, firstDone: firstDone,
}
}
// TestSessionSwapWaitsForRecoveryHandoff guards the swap side of the snapshot
// serialization: moves of controller-owned session state must wait for an
// in-flight save/recovery handoff instead of interleaving with it. A swap that
// lands mid-handoff pairs the old path with the new session, which either
// writes one transcript's messages into another's file or manufactures another
// bogus conflict on the next save.
func TestSessionSwapWaitsForRecoveryHandoff(t *testing.T) {
cases := []struct {
name string
run func(t *testing.T, h *blockedRecoveryHandoff) (done chan struct{}, wantPath string)
// during runs while the handoff is still blocked; verify after the
// racing operation completed.
during func(t *testing.T, h *blockedRecoveryHandoff)
verify func(t *testing.T, h *blockedRecoveryHandoff)
}{
{name: "SetSessionPath", run: func(t *testing.T, h *blockedRecoveryHandoff) (chan struct{}, string) {
other := filepath.Join(h.dir, "other.jsonl")
done := make(chan struct{})
go func() { h.c.SetSessionPath(other); close(done) }()
return done, other
}},
{name: "Resume", run: func(t *testing.T, h *blockedRecoveryHandoff) (chan struct{}, string) {
other := filepath.Join(h.dir, "resumed.jsonl")
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "resumed"})
if err := sess.Save(other); err != nil {
t.Fatalf("Save resumed: %v", err)
}
done := make(chan struct{})
go func() { h.c.Resume(sess, other); close(done) }()
return done, other
}},
{
name: "CancelFlush",
run: func(t *testing.T, h *blockedRecoveryHandoff) (chan struct{}, string) {
// Truncate the cancelled turn: drop the assistant reply, the
// same shape stripTurnMessagesAfter feeds this helper.
truncated := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "first"},
}
done := make(chan struct{})
go func() { h.c.replaceSessionAfterCancel(truncated); close(done) }()
return done, h.first.recoveryPath
},
during: func(t *testing.T, h *blockedRecoveryHandoff) {
// The in-memory truncation itself must wait for the handoff: an
// early Replace would let the blocked save capture the shortened
// transcript, read the longer on-disk partial as a stale-prefix
// conflict, and adopt it back over the cancel cleanup.
if got := len(h.local.Snapshot()); got != 3 {
t.Fatalf("session truncated to %d messages while the handoff was still in flight, want 3", got)
}
},
verify: func(t *testing.T, h *blockedRecoveryHandoff) {
if got := len(h.local.Snapshot()); got != 2 {
t.Fatalf("session = %d messages after cancel flush, want 2", got)
}
loaded, err := agent.LoadSession(h.first.recoveryPath)
if err != nil {
t.Fatalf("LoadSession recovery: %v", err)
}
if got := len(loaded.Messages); got != 2 {
t.Fatalf("recovery transcript = %d messages after cancel flush, want 2", got)
}
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
h := startBlockedRecoveryHandoff(t)
done, wantPath := tc.run(t, h)
select {
case <-done:
t.Fatal("session state moved while the recovery handoff was still in flight")
case <-time.After(100 * time.Millisecond):
}
if tc.during != nil {
tc.during(t, h)
}
close(h.release)
if err := <-h.firstDone; err != nil {
t.Fatalf("first Snapshot: %v", err)
}
select {
case <-done:
case <-time.After(10 * time.Second):
t.Fatal("session state move did not finish after the handoff completed")
}
if got := h.c.SessionPath(); got != wantPath {
t.Fatalf("controller session path = %q, want %q", got, wantPath)
}
matches, err := filepath.Glob(filepath.Join(h.dir, "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob recovery branches: %v", err)
}
recoveries := recoveryTranscriptPaths(matches)
if len(recoveries) != 1 || recoveries[0] != h.first.recoveryPath {
t.Fatalf("recovery branches = %v, want only %q", matches, h.first.recoveryPath)
}
if tc.verify != nil {
tc.verify(t, h)
}
})
}
}
// TestSnapshotConflictAdoptionResetsRewriteBaseline guards the baseline
// handoff on the adopt path: adopting a newer on-disk transcript installs a
// freshly loaded session, and the replaced session's rewrite version must not
// leak onto it. A leaked (higher) baseline would make the adopted session's
// own compactions look already persisted, so the next autosave would take the
// snapshot path, conflict, and fork a spurious recovery branch.
func TestSnapshotConflictAdoptionResetsRewriteBaseline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
base := agent.NewSession("sys")
base.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
if err := base.Save(path); err != nil {
t.Fatalf("Save base: %v", err)
}
stale, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession stale: %v", err)
}
other, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession other: %v", err)
}
other.Add(provider.Message{Role: provider.RoleAssistant, Content: "newer"})
if err := other.SaveSnapshot(path); err != nil {
t.Fatalf("SaveSnapshot other: %v", err)
}
exec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
// Rewrites the stale controller never persisted before it noticed the
// newer transcript; adoption must discard this counter with the session.
for range 3 {
stale.IncrementRewrite()
}
if err := c.Snapshot(); err != nil {
t.Fatalf("Snapshot adopt: %v", err)
}
if got := c.SessionPath(); got != path {
t.Fatalf("session path after adoption = %q, want original %q", got, path)
}
adopted := exec.Session()
if adopted == stale {
t.Fatal("expected adoption to replace the stale session")
}
if adopted.NeedsRewriteSave() {
t.Fatal("adopted session should carry a persisted rewrite baseline; the stale session's counter must not leak onto it")
}
// The adopted session's first compaction must persist in place.
msgs := adopted.Snapshot()
adopted.Replace([]provider.Message{
msgs[0],
{Role: provider.RoleUser, Content: "<compaction-summary>\nfirst -> newer\n</compaction-summary>"},
})
adopted.IncrementRewrite()
if err := c.SnapshotActivity(); err != nil {
t.Fatalf("SnapshotActivity after adopted compaction: %v", err)
}
if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after adopted compaction = %v err=%v, want none", matches, err)
}
reloaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession rewritten: %v", err)
}
if got := len(reloaded.Messages); got != 2 {
t.Fatalf("message count after adopted compaction = %d, want 2: %+v", got, reloaded.Messages)
}
}
// TestConcurrentCompactionAndAutosaveNeverBranch drives the real shape of the
// bug: a mid-turn autosave goroutine saving while the turn goroutine compacts.
// Whatever the interleaving, an owned single-process session must never fork a
// recovery branch — the decision/mark pipeline in snapshot() has to capture
// the rewrite version it actually persisted, and retry as an owned rewrite
// when a compaction slips in between.
func TestConcurrentCompactionAndAutosaveNeverBranch(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "turn-0"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
if err := c.Snapshot(); err != nil {
t.Fatalf("initial snapshot: %v", err)
}
stop := make(chan struct{})
var wg sync.WaitGroup
wg.Go(func() {
for {
select {
case <-stop:
return
default:
// Errors surface as recovery branches, asserted below.
_ = c.SnapshotActivity()
}
}
})
for i := 1; i <= 40; i++ {
sess.Add(provider.Message{Role: provider.RoleUser, Content: fmt.Sprintf("turn-%d", i)})
if i%4 == 0 {
msgs := sess.Snapshot()
sess.Replace([]provider.Message{
msgs[0],
{Role: provider.RoleUser, Content: fmt.Sprintf("<compaction-summary>\nrounds through %d\n</compaction-summary>", i)},
msgs[len(msgs)-1],
})
sess.IncrementRewrite()
}
}
close(stop)
wg.Wait()
if err := c.SnapshotActivity(); err != nil {
t.Fatalf("final snapshot: %v", err)
}
if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("compaction racing autosave created recovery branches: %v err=%v", matches, err)
}
reloaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession final: %v", err)
}
if got, want := len(reloaded.Messages), sess.Len(); got != want {
t.Fatalf("persisted %d messages, memory has %d", got, want)
}
}
func TestAdoptHistoryPreservesRewriteBaseline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
s := agent.NewSession("old sys")
s.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
s.Add(provider.Message{Role: provider.RoleAssistant, ToolCalls: []provider.ToolCall{{ID: "tool-1", Name: "read_file", Arguments: "{}"}}})
s.Add(provider.Message{Role: provider.RoleTool, ToolCallID: "tool-1", Name: "read_file", Content: strings.Repeat("detail ", 100)})
s.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
if err := s.Save(path); err != nil {
t.Fatalf("Save base: %v", err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
msgs := loaded.Snapshot()
msgs[0].Content = "new sys"
exec := agent.New(nil, nil, agent.NewSession("new sys"), agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test", DisableColdResumePrune: true})
c.AdoptHistory(msgs, path)
rewrite := exec.Session().Snapshot()
rewrite[3].Content = "[elided tool result]"
exec.Session().Replace(rewrite)
if err := c.SnapshotRewrite(); err != nil {
t.Fatalf("SnapshotRewrite adopted history: %v", err)
}
if got := c.SessionPath(); got != path {
t.Fatalf("SessionPath after adopted rewrite = %q, want %q", got, path)
}
reloaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession rewritten: %v", err)
}
if got := reloaded.Messages[0].Content; got != "new sys" {
t.Fatalf("system prompt after rewrite = %q, want new sys", got)
}
if got := reloaded.Messages[3].Content; got != "[elided tool result]" {
t.Fatalf("tool result after rewrite = %q, want elided", got)
}
if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after adopted rewrite = %v err=%v, want none", matches, err)
}
}
func TestAdoptEmptyHistoryRestoresPersistedGoalState(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "empty-session.jsonl")
if err := agent.NewSession("").Save(path); err != nil {
t.Fatalf("Save empty session: %v", err)
}
oldExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
old := newOwnedTestController(t, Options{Executor: oldExec, SessionDir: dir, SessionPath: path, Label: "old"})
old.SetGoal("preserve the zero-turn goal")
old.stopGoal(GoalStatusBlocked)
newExec := agent.New(nil, nil, agent.NewSession(""), agent.Options{}, event.Discard)
replacement := newOwnedTestController(t, Options{Executor: newExec, SessionDir: dir, Label: "replacement", DisableColdResumePrune: true})
replacement.AdoptHistory(nil, path)
if got := replacement.Goal(); got != "preserve the zero-turn goal" {
t.Fatalf("Goal after empty-history adoption = %q", got)
}
if got := replacement.GoalStatus(); got != GoalStatusBlocked {
t.Fatalf("GoalStatus after empty-history adoption = %q, want blocked", got)
}
}
func TestAdoptHistoryRejectsStaleCarriedHistoryBaseline(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
current := agent.NewSession("sys")
current.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
current.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
current.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk two"})
if err := current.Save(path); err != nil {
t.Fatalf("Save current: %v", err)
}
stale := []provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "first"},
{Role: provider.RoleAssistant, Content: "one"},
}
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test", DisableColdResumePrune: true})
c.AdoptHistory(stale, path)
if err := c.SnapshotRewrite(); err != nil {
t.Fatalf("SnapshotRewrite stale adopted history: %v", err)
}
if got := c.SessionPath(); got != path {
t.Fatalf("SessionPath after stale adopted rewrite = %q, want original path", got)
}
reloaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession original: %v", err)
}
if got := reloaded.Messages[len(reloaded.Messages)-1].Content; got != "disk two" {
t.Fatalf("original tail after stale adopted rewrite = %q, want disk two", got)
}
if matches, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl")); err != nil || len(matches) != 0 {
t.Fatalf("recovery branches after prefix stale adopted rewrite = %v err=%v, want none", matches, err)
}
}
func TestCancelFlushRejectsStaleControllerOverwrite(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
staleSess := agent.NewSession("sys")
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
staleSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
staleSess.Add(provider.Message{Role: provider.RoleUser, Content: "partial"})
staleExec := agent.New(nil, nil, staleSess, agent.Options{}, event.Discard)
stale := newOwnedTestController(t, Options{Executor: staleExec, SessionDir: dir, SessionPath: path, Label: "test"})
currentSess := agent.NewSession("sys")
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
currentSess.Add(provider.Message{Role: provider.RoleUser, Content: "second"})
currentSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "two"})
currentExec := agent.New(nil, nil, currentSess, agent.Options{}, event.Discard)
current := newOwnedTestController(t, Options{Executor: currentExec, SessionDir: dir, SessionPath: path, Label: "test"})
if err := current.SnapshotActivity(); err != nil {
t.Fatalf("SnapshotActivity current: %v", err)
}
stale.replaceSessionAfterCancel([]provider.Message{
{Role: provider.RoleSystem, Content: "sys"},
{Role: provider.RoleUser, Content: "first"},
})
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := len(loaded.Messages); got != 5 {
t.Fatalf("message count after stale cancel flush = %d, want 5", got)
}
if got := loaded.Messages[4].Content; got != "two" {
t.Fatalf("last message after stale cancel flush = %q, want %q", got, "two")
}
}
func TestSnapshotActivityRefreshesSessionActivity(t *testing.T) {
dir := t.TempDir()
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, Label: "test"})
c.SetSessionPath(filepath.Join(dir, "session.jsonl"))
if err := c.SnapshotActivity(); err != nil {
t.Fatal(err)
}
first, _, err := agent.LoadBranchMeta(c.SessionPath())
if err != nil {
t.Fatal(err)
}
time.Sleep(10 * time.Millisecond)
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "activity"})
if err := c.SnapshotActivity(); err != nil {
t.Fatal(err)
}
second, _, err := agent.LoadBranchMeta(c.SessionPath())
if err != nil {
t.Fatal(err)
}
if !second.UpdatedAt.After(first.UpdatedAt) {
t.Fatalf("SnapshotActivity did not refresh activity: first=%s second=%s", first.UpdatedAt, second.UpdatedAt)
}
}
func TestSnapshotActivitySavesTranscriptBeforeModelMeta(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "must persist"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test", ModelRef: "provider/model-a"})
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(agent.BranchMetaPath(path), []byte("{bad json"), 0o644); err != nil {
t.Fatal(err)
}
if err := c.SnapshotActivity(); err == nil {
t.Fatal("SnapshotActivity should report malformed branch metadata")
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("transcript was not saved before metadata error: %v", err)
}
if len(loaded.Messages) == 0 || loaded.Messages[len(loaded.Messages)-1].Content != "must persist" {
t.Fatalf("saved transcript = %+v, want persisted user message", loaded.Messages)
}
}
func TestNewSessionStartsFreshContextAndSavesTranscript(t *testing.T) {
dir := t.TempDir()
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
path := filepath.Join(dir, "session.jsonl")
c := newOwnedTestController(t, Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test"})
if err := c.NewSession(); err != nil {
t.Fatal(err)
}
if c.SessionPath() == path {
t.Fatal("/new did not rotate to a fresh session path")
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatal(err)
}
if len(loaded.Messages) != 2 || loaded.Messages[1].Content != "old context" {
t.Fatalf("previous transcript was not saved: %+v", loaded.Messages)
}
current := exec.Session().Snapshot()
if len(current) != 1 || current[0].Role != provider.RoleSystem || current[0].Content != "sys" {
t.Fatalf("fresh context = %+v, want only system prompt", current)
}
}
func TestSnapshotConflictLogAttrsCarryRevisionLedger(t *testing.T) {
conflict := &agent.SessionSnapshotConflictError{
Path: "/tmp/session.jsonl",
Kind: agent.SessionSnapshotConflictDiverged,
ExistingMessages: 7,
SnapshotMessages: 5,
BaseRevision: 3,
DiskRevision: 9,
}
attrs := snapshotConflictLogAttrs(fmt.Errorf("save: %w", conflict), "/tmp/session.jsonl", "rewrite")
got := map[string]any{}
for i := 0; i+1 < len(attrs); i += 2 {
key, ok := attrs[i].(string)
if !ok {
t.Fatalf("attr key %v is not a string", attrs[i])
}
got[key] = attrs[i+1]
}
if got["mode"] != "rewrite" || got["kind"] != "diverged" {
t.Fatalf("attrs = %v, want mode=rewrite kind=diverged", got)
}
if got["base_revision"] != int64(3) || got["disk_revision"] != int64(9) {
t.Fatalf("attrs = %v, want base_revision=3 disk_revision=9", got)
}
if got["disk_messages"] != 7 || got["snapshot_messages"] != 5 {
t.Fatalf("attrs = %v, want disk_messages=7 snapshot_messages=5", got)
}
// A conflict error without the typed detail still logs path and mode.
plain := snapshotConflictLogAttrs(agent.ErrSessionSnapshotConflict, "/tmp/session.jsonl", "snapshot")
if len(plain) != 4 {
t.Fatalf("plain attrs = %v, want only path and mode", plain)
}
}
func TestSnapshotConflictRevisionsExtractTypedConflict(t *testing.T) {
conflict := &agent.SessionSnapshotConflictError{BaseRevision: 4, DiskRevision: 8}
base, disk := snapshotConflictRevisions(fmt.Errorf("wrapped: %w", conflict))
if base != 4 || disk != 8 {
t.Fatalf("revisions = %d/%d, want 4/8", base, disk)
}
if base, disk := snapshotConflictRevisions(errors.New("other")); base != 0 || disk != 0 {
t.Fatalf("non-conflict revisions = %d/%d, want 0/0", base, disk)
}
}
type noticeSink struct {
mu sync.Mutex
events []event.Event
}
func TestSessionRecoveryNoticesAreOperatorScoped(t *testing.T) {
for _, code := range []string{
event.NoticeCodeSessionRecoveryForked,
event.NoticeCodeSessionRecoveryAdopted,
event.NoticeCodeSessionRecoveryAdoptedCovered,
event.NoticeCodeSessionRecoveryDepthCap,
event.NoticeCodeSessionShutdownRecoveryForked,
} {
notice := sessionRecoveryNotice(code, "maintenance")
if notice.Kind != event.Notice || notice.Level != event.LevelWarn ||
notice.Audience != event.NoticeAudienceOperator || notice.Code != code {
t.Fatalf("session recovery notice %q = %+v, want typed operator warning", code, notice)
}
}
}
func (s *noticeSink) Emit(e event.Event) {
s.mu.Lock()
s.events = append(s.events, e)
s.mu.Unlock()
}
func (s *noticeSink) notices() []string {
s.mu.Lock()
defer s.mu.Unlock()
var out []string
for _, e := range s.events {
if e.Kind == event.Notice {
out = append(out, e.Text)
}
}
return out
}
func (s *noticeSink) lastNotice() (event.Event, bool) {
s.mu.Lock()
defer s.mu.Unlock()
for _, v := range slices.Backward(s.events) {
if v.Kind == event.Notice {
return v, true
}
}
return event.Event{}, false
}
func TestSnapshotConflictAtRecoveryDepthCapIsolatesCurrentBranch(t *testing.T) {
dir := schemaOneTempDir(t)
path := filepath.Join(dir, "session.jsonl")
disk := agent.NewSession("sys")
disk.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
disk.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
disk.Add(provider.Message{Role: provider.RoleUser, Content: "disk second"})
if err := disk.Save(path); err != nil {
t.Fatalf("Save disk: %v", err)
}
meta, ok, err := agent.LoadBranchMeta(path)
if err != nil || !ok {
t.Fatalf("LoadBranchMeta ok=%v err=%v", ok, err)
}
meta.Recovered = true
meta.RecoveryDepth = agent.SessionRecoveryMaxDepth
if err := agent.SaveBranchMeta(path, meta); err != nil {
t.Fatalf("SaveBranchMeta: %v", err)
}
stale := agent.NewSession("sys")
stale.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "one"})
stale.Add(provider.Message{Role: provider.RoleUser, Content: "local second"})
exec := agent.New(nil, nil, stale, agent.Options{}, event.Discard)
sink := &noticeSink{}
c := newOwnedTestController(t, Options{Executor: exec, SessionDir: dir, SessionPath: path, Label: "test", Sink: sink})
stale.IncrementRewrite()
if err := c.Snapshot(); err != nil {
t.Fatalf("Snapshot: %v", err)
}
if got := c.SessionPath(); got == path || !strings.Contains(got, "-recovery-") {
t.Fatalf("session path = %q, want a stable recovery branch", got)
}
forks, err := filepath.Glob(filepath.Join(dir, "*-recovery-*.jsonl"))
if err != nil {
t.Fatalf("glob: %v", err)
}
filteredForks := forks[:0]
for _, fork := range forks {
if !strings.HasSuffix(fork, ".events.jsonl") &&
!strings.HasSuffix(fork, ".turns.jsonl") &&
!strings.HasSuffix(fork, ".turns.jsonl.damaged") {
filteredForks = append(filteredForks, fork)
}
}
forks = filteredForks
if len(forks) != 1 {
t.Fatalf("stable recovery should preserve one fork: %v", forks)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
if got := loaded.Messages[len(loaded.Messages)-1].Content; got != "disk second" {
t.Fatalf("canonical disk tail = %q, want newer disk transcript", got)
}
notices := sink.notices()
if len(notices) == 0 || !strings.Contains(notices[len(notices)-1], "unsaved local transcript was saved as a conflict copy") {
t.Fatalf("notices = %v, want forked recovery notice", notices)
}
notice, ok := sink.lastNotice()
if !ok || notice.Code != event.NoticeCodeSessionRecoveryForked || notice.Audience != event.NoticeAudienceOperator {
t.Fatalf("recovery notice = %+v, want typed operator fork notice", notice)
}
// The isolated branch was saved with its own verified baseline. Defensive
// snapshots on that branch must be no-ops rather than starting another
// recovery chain or repeating the operator notice.
if err := c.Snapshot(); err != nil {
t.Fatalf("snapshot on isolated branch: %v", err)
}
if got := sink.notices(); len(got) != len(notices) {
t.Fatalf("repeated depth-cap snapshot emitted duplicate notice: %v", got)
}
// New suffixes continue normally on the isolated branch.
stale.Add(provider.Message{Role: provider.RoleAssistant, Content: "answer"})
if err := c.Snapshot(); err != nil {
t.Fatalf("follow-up Snapshot: %v", err)
}
if got := sink.notices(); len(got) != len(notices) {
t.Fatalf("follow-up snapshot emitted more notices: %v", got)
}
}
func TestNewSessionRefusesWhileTurnRunning(t *testing.T) {
dir := t.TempDir()
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "old context"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
path := filepath.Join(dir, "session.jsonl")
c := newOwnedTestController(t, Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test"})
c.mu.Lock()
c.turns.phase = session.RuntimeRunning
c.mu.Unlock()
if err := c.NewSession(); err == nil {
t.Fatal("NewSession while running = nil error, want refusal")
}
if got := c.SessionPath(); got != path {
t.Fatalf("session path = %q, want unrotated %q", got, path)
}
if snap := exec.Session().Snapshot(); len(snap) != 2 {
t.Fatalf("running session was reset out from under the turn: %+v", snap)
}
c.mu.Lock()
c.turns.phase = session.RuntimeIdle
c.mu.Unlock()
if err := c.NewSession(); err != nil {
t.Fatalf("NewSession after the turn stopped: %v", err)
}
if c.SessionPath() == path {
t.Fatal("session path did not rotate once the turn stopped")
}
}
// TestNewSessionRefusesTurnStartedDuringSnapshot forces the TOCTOU interleaving
// the running guard alone missed: a turn starts while NewSession is mid-Snapshot
// (running was false at the entry check), and must be refused so the executor
// session is not swapped out from under a live run loop.
func TestNewSessionRefusesTurnStartedDuringSnapshot(t *testing.T) {
dir := schemaOneTempDir(t)
path := filepath.Join(dir, "session.jsonl")
// A diverged on-disk transcript makes Snapshot enter the recovery callback,
// where the test parks NewSession mid-rotation.
diskSess := agent.NewSession("sys")
diskSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
diskSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "disk"})
if err := diskSess.Save(path); err != nil {
t.Fatalf("Save disk: %v", err)
}
localSess := agent.NewSession("sys")
localSess.Add(provider.Message{Role: provider.RoleUser, Content: "first"})
localSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "local"})
localExec := agent.New(nil, nil, localSess, agent.Options{}, event.Discard)
entered := make(chan struct{}, 1)
release := make(chan struct{})
c := newOwnedTestController(t, Options{
Executor: localExec,
SystemPrompt: "sys",
SessionDir: dir,
SessionPath: path,
Label: "test",
OnSessionRecovered: func(SessionRecoveryInfo) error {
entered <- struct{}{}
<-release
return nil
},
})
newSessionDone := make(chan error, 1)
go func() { newSessionDone <- c.NewSession() }()
// NewSession is now parked inside Snapshot, still holding the rotation gate.
<-entered
// A turn tries to start in exactly the window the bare running check left
// open. It must be refused rather than flip running=true and read the
// session NewSession is about to replace.
if err := c.RunTurn(context.Background(), "hello"); !errors.Is(err, ErrTurnRunning) {
close(release)
<-newSessionDone
t.Fatalf("RunTurn during rotation = %v, want ErrTurnRunning", err)
}
if c.Running() {
close(release)
<-newSessionDone
t.Fatal("RunTurn set running=true during a rotation")
}
// The live session must be untouched while the refused turn could have read
// it: NewSession has not swapped yet (still parked before the swap).
if snap := localExec.Session().Snapshot(); len(snap) != 3 {
close(release)
<-newSessionDone
t.Fatalf("session mutated during rotation window: %+v", snap)
}
close(release)
if err := <-newSessionDone; err != nil {
t.Fatalf("NewSession: %v", err)
}
// Rotation completed: a fresh session with only the system prompt, on a new
// path, and a turn may start again.
if c.SessionPath() == path {
t.Fatal("session path did not rotate")
}
if snap := localExec.Session().Snapshot(); len(snap) != 1 || snap[0].Role != provider.RoleSystem {
t.Fatalf("post-rotation session = %+v, want only system prompt", snap)
}
if c.Running() {
t.Fatal("rotation gate leaked: controller still marked running")
}
}
// TestSessionMutationsRefuseWhileRotating proves every session-mutating entry
// point is wired to the same rotation gate: while a rotation is in progress
// (c.rotating held), each refuses instead of swapping/rewriting the live
// session, and a turn cannot start either. This is the TOCTOU class the bare
// Running() checks left open — a mutation slipping in mid-rotation.
func TestSessionMutationsRefuseWhileRotating(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "session.jsonl")
sess := agent.NewSession("sys")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "hi"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "there"})
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := newOwnedTestController(t, Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test"})
// Simulate a rotation already in progress (as NewSession/ClearSession hold
// it across their snapshot-then-swap window).
if err := c.beginRotation(); err != nil {
t.Fatalf("beginRotation: %v", err)
}
if err := c.NewSession(); !errors.Is(err, errRotationInProgress) {
t.Fatalf("NewSession while rotating = %v, want errRotationInProgress", err)
}
if err := c.ClearSession(); !errors.Is(err, errRotationInProgress) {
t.Fatalf("ClearSession while rotating = %v, want errRotationInProgress", err)
}
if _, err := c.Branch("x"); !errors.Is(err, errRotationInProgress) {
t.Fatalf("Branch while rotating = %v, want errRotationInProgress", err)
}
if _, err := c.ForkNamed(1, "x"); !errors.Is(err, errRotationInProgress) {
t.Fatalf("ForkNamed while rotating = %v, want errRotationInProgress", err)
}
if _, err := c.SwitchBranch("x"); !errors.Is(err, errRotationInProgress) {
t.Fatalf("SwitchBranch while rotating = %v, want errRotationInProgress", err)
}
if err := c.Compact(context.Background(), ""); !errors.Is(err, errRotationInProgress) {
t.Fatalf("Compact while rotating = %v, want errRotationInProgress", err)
}
if err := c.Rewind(0, RewindConversation); !errors.Is(err, errRotationInProgress) {
t.Fatalf("Rewind while rotating = %v, want errRotationInProgress", err)
}
if err := c.SummarizeFrom(context.Background(), 1); !errors.Is(err, errRotationInProgress) {
t.Fatalf("SummarizeFrom while rotating = %v, want errRotationInProgress", err)
}
if err := c.SummarizeUpTo(context.Background(), 1); !errors.Is(err, errRotationInProgress) {
t.Fatalf("SummarizeUpTo while rotating = %v, want errRotationInProgress", err)
}
// A turn must not start while a rotation holds the gate.
if err := c.RunTurn(context.Background(), "hello"); !errors.Is(err, ErrTurnRunning) {
t.Fatalf("RunTurn while rotating = %v, want ErrTurnRunning", err)
}
// The live session was never touched by any refused mutation.
if snap := exec.Session().Snapshot(); len(snap) != 3 {
t.Fatalf("session mutated during rotation = %+v, want untouched", snap)
}
c.endRotation()
// Conversely: while a turn runs, every mutation is refused with its own
// message and the gate cannot be claimed.
c.mu.Lock()
c.turns.phase = session.RuntimeRunning
c.mu.Unlock()
if err := c.beginRotation(); !errors.Is(err, errTurnRunningRotation) {
t.Fatalf("beginRotation while running = %v, want errTurnRunningRotation", err)
}
if err := c.Compact(context.Background(), ""); err == nil || !strings.Contains(err.Error(), "cannot compact while a turn is running") {
t.Fatalf("Compact while running = %v, want 'cannot compact' message", err)
}
if err := c.Rewind(0, RewindConversation); err == nil || !strings.Contains(err.Error(), "cannot rewind while a turn is running") {
t.Fatalf("Rewind while running = %v, want 'cannot rewind' message", err)
}
if err := c.SummarizeFrom(context.Background(), 1); err == nil || !strings.Contains(err.Error(), "cannot summarize while a turn is running") {
t.Fatalf("SummarizeFrom while running = %v, want 'cannot summarize' message", err)
}
}
func TestNewSessionQueuesSessionStartHookContext(t *testing.T) {
dir := t.TempDir()
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
path := filepath.Join(dir, "session.jsonl")
hooks := hook.NewRunner([]hook.ResolvedHook{{
HookConfig: hook.HookConfig{Command: "session-start"},
Event: hook.SessionStart,
}}, dir, func(context.Context, hook.SpawnInput) hook.SpawnResult {
return hook.SpawnResult{ExitCode: 0, Stdout: "new session context"}
}, nil)
c := newOwnedTestController(t, Options{Executor: exec, SystemPrompt: "sys", SessionDir: dir, SessionPath: path, Label: "test", Hooks: hooks})
if err := c.NewSession(); err != nil {
t.Fatalf("NewSession: %v", err)
}
got := c.Compose("next")
if !strings.Contains(got, `<hook-context event="SessionStart">`) || !strings.Contains(got, "new session context") || !strings.HasSuffix(got, "next") {
t.Fatalf("new session did not queue SessionStart hook context: %q", got)
}
}
func TestNewSessionResetsTwoModelPlannerContext(t *testing.T) {
dir := t.TempDir()
planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
planTurn("OLD PLAN: inspect alpha.go"),
planTurn("NEW PLAN: inspect beta.go"),
}}
execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
textTurn("old done"),
textTurn("new done"),
}}
exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
plannerSess := agent.NewSession("planner sys")
coord := agent.NewCoordinator(planner, plannerSess, nil, agent.PlannerToolRegistry(tool.NewRegistry()), agent.Options{}, exec, 0, event.Discard, nil)
path := filepath.Join(dir, "session.jsonl")
c := newOwnedTestController(t, Options{Runner: coord, Executor: exec, SystemPrompt: "exec sys", SessionDir: dir, SessionPath: path, Label: "test"})
t.Cleanup(c.Close)
if err := c.Run(context.Background(), "old task alpha"); err != nil {
t.Fatal(err)
}
if err := c.NewSession(); err != nil {
t.Fatal(err)
}
if err := c.Run(context.Background(), "new task beta"); err != nil {
t.Fatal(err)
}
if len(planner.requests) != 2 {
t.Fatalf("planner requests = %d, want 2", len(planner.requests))
}
second := requestMessagesText(planner.requests[1].Messages)
if strings.Contains(second, "old task alpha") || strings.Contains(second, "OLD PLAN") {
t.Fatalf("new planner request leaked previous session context:\n%s", second)
}
if !strings.Contains(second, "new task beta") {
t.Fatalf("new planner request missing current task:\n%s", second)
}
}
func TestTwoModelPlannerApprovalUsesHostGate(t *testing.T) {
dir := t.TempDir()
planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
approvalPlanTurn("Plan:\n1. Edit main.go"),
}}
execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
textTurn("approved execution complete"),
}}
exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
coord := agent.NewCoordinator(planner, agent.NewSession("planner sys"), nil, agent.PlannerToolRegistry(tool.NewRegistry()), agent.Options{}, exec, 0, event.Discard, nil)
ids := make(chan string, 1)
var prompts int
c := newOwnedTestController(t, Options{
Runner: coord,
Executor: exec,
SystemPrompt: "exec sys",
SessionDir: dir,
SessionPath: filepath.Join(dir, "session.jsonl"),
Label: "test",
Sink: event.FuncSink(func(e event.Event) {
if e.Kind != event.ApprovalRequest {
return
}
prompts++
if e.Approval.Tool != planApprovalTool {
t.Errorf("approval tool = %q, want %q", e.Approval.Tool, planApprovalTool)
}
if !strings.Contains(e.Approval.Reason, "Planner requested") {
t.Errorf("approval reason = %q, want planner source", e.Approval.Reason)
}
ids <- e.Approval.ID
}),
})
c.EnableInteractiveApproval()
done := make(chan error, 1)
go func() {
done <- c.Run(context.Background(), "fix the planner approval bug")
}()
id := waitApprovalID(t, ids)
if got := len(execProv.requests); got != 0 {
t.Fatalf("executor requests before approval = %d, want 0", got)
}
c.Approve(id, true, false, false)
select {
case err := <-done:
if err != nil {
t.Fatalf("Run: %v", err)
}
case <-time.After(30 * time.Second):
t.Fatal("approved two-model turn did not finish")
}
if prompts != 1 {
t.Fatalf("approval prompts = %d, want 1", prompts)
}
if got := len(execProv.requests); got == 0 {
t.Fatal("executor did not run after approval")
}
reqText := requestMessagesText(execProv.requests[0].Messages)
if !strings.Contains(reqText, "Reasonix executor handoff") || !strings.Contains(reqText, "Edit main.go") {
t.Fatalf("approved executor request missing planner handoff:\n%s", reqText)
}
}
func TestResumeResetsTwoModelPlannerContext(t *testing.T) {
dir := t.TempDir()
planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
planTurn("OLD PLAN: inspect alpha.go"),
planTurn("RESUMED PLAN: inspect gamma.go"),
}}
execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
textTurn("old done"),
textTurn("resumed done"),
}}
exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
plannerSess := agent.NewSession("planner sys")
coord := agent.NewCoordinator(planner, plannerSess, nil, agent.PlannerToolRegistry(tool.NewRegistry()), agent.Options{}, exec, 0, event.Discard, nil)
c := newOwnedTestController(t, Options{Runner: coord, Executor: exec, SystemPrompt: "exec sys", SessionDir: dir, SessionPath: filepath.Join(dir, "old.jsonl"), Label: "test"})
t.Cleanup(c.Close)
if err := c.Run(context.Background(), "old task alpha"); err != nil {
t.Fatal(err)
}
resumed := agent.NewSession("exec sys")
resumed.Add(provider.Message{Role: provider.RoleUser, Content: "saved task gamma"})
c.Resume(resumed, filepath.Join(dir, "resumed.jsonl"))
if err := c.Run(context.Background(), "continue gamma"); err != nil {
t.Fatal(err)
}
if len(planner.requests) != 2 {
t.Fatalf("planner requests = %d, want 2", len(planner.requests))
}
second := requestMessagesText(planner.requests[1].Messages)
if strings.Contains(second, "old task alpha") || strings.Contains(second, "OLD PLAN") {
t.Fatalf("resumed planner request leaked previous session context:\n%s", second)
}
if !strings.Contains(second, "continue gamma") {
t.Fatalf("resumed planner request missing current task:\n%s", second)
}
}
func TestResetPlannerSessionClearsPlannerHistory(t *testing.T) {
dir := t.TempDir()
planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
planTurn("FIRST PLAN: inspect alpha.go"),
planTurn("SECOND PLAN: inspect beta.go"),
}}
execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
textTurn("first done"),
textTurn("second done"),
}}
exec := agent.New(execProv, tool.NewRegistry(), agent.NewSession("exec sys"), agent.Options{}, event.Discard)
plannerSess := agent.NewSession("planner sys")
coord := agent.NewCoordinator(planner, plannerSess, nil, agent.PlannerToolRegistry(tool.NewRegistry()), agent.Options{}, exec, 0, event.Discard, nil)
path := filepath.Join(dir, "session.jsonl")
c := newOwnedTestController(t, Options{Runner: coord, Executor: exec, SystemPrompt: "exec sys", SessionDir: dir, SessionPath: path, Label: "test"})
if err := c.Run(context.Background(), "first task"); err != nil {
t.Fatal(err)
}
// Explicitly reset the planner session (simulates a tab switch).
c.ResetPlannerSession()
if err := c.Run(context.Background(), "second task"); err != nil {
t.Fatal(err)
}
if len(planner.requests) != 2 {
t.Fatalf("planner requests = %d, want 2", len(planner.requests))
}
second := requestMessagesText(planner.requests[1].Messages)
if strings.Contains(second, "first task") || strings.Contains(second, "FIRST PLAN") {
t.Fatalf("planner request after reset leaked previous session context:\n%s", second)
}
if !strings.Contains(second, "second task") {
t.Fatalf("planner request after reset missing current task:\n%s", second)
}
}
func TestTwoModelShortChoiceReplySkipsPlanner(t *testing.T) {
dir := t.TempDir()
planner := &recordingProvider{name: "planner", streams: [][]provider.Chunk{
textTurn("planner should not run for a context-dependent choice reply"),
}}
execProv := &recordingProvider{name: "executor", streams: [][]provider.Chunk{
textTurn("selected option 1"),
}}
execSess := agent.NewSession("exec sys")
execSess.Add(provider.Message{Role: provider.RoleUser, Content: "先给我两个执行方案"})
execSess.Add(provider.Message{Role: provider.RoleAssistant, Content: "两个执行方式可选:\n\n1. Subagent-Driven(推荐)\n2. 当前会话执行\n\n你选哪种?"})
exec := agent.New(execProv, tool.NewRegistry(), execSess, agent.Options{}, event.Discard)
coord := agent.NewCoordinator(planner, agent.NewSession("planner sys"), nil, agent.PlannerToolRegistry(tool.NewRegistry()), agent.Options{}, exec, 0, event.Discard, NewPlannerGate())
c := newOwnedTestController(t, Options{Runner: coord, Executor: exec, SystemPrompt: "exec sys", SessionDir: dir, SessionPath: filepath.Join(dir, "session.jsonl"), Label: "test"})
if err := c.Run(context.Background(), "1"); err != nil {
t.Fatal(err)
}
if len(planner.requests) != 0 {
t.Fatalf("planner requests = %d, want 0 for a short context-dependent choice reply", len(planner.requests))
}
if len(execProv.requests) != 1 {
t.Fatalf("executor requests = %d, want 1", len(execProv.requests))
}
reqText := requestMessagesText(execProv.requests[0].Messages)
if !strings.Contains(reqText, "1. Subagent-Driven") {
t.Fatalf("executor request lost the previous assistant options:\n%s", reqText)
}
if strings.Contains(reqText, "Reasonix executor handoff") {
t.Fatalf("short choice reply should not be wrapped as a planner handoff:\n%s", reqText)
}
if got := agent.StripTransientUserBlocks(lastUserMessage(execProv.requests[0].Messages)); got != "1" {
t.Fatalf("executor last user = %q, want raw choice reply", lastUserMessage(execProv.requests[0].Messages))
}
}
func TestDisconnectMCPServerRemovesLazyPlaceholder(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeControlTool{name: "mcp__mock__connect"})
c := newOwnedTestController(t, Options{Host: plugin.NewHost(), Registry: reg})
if ok := c.DisconnectMCPServer("mock"); !ok {
t.Fatal("DisconnectMCPServer returned false for a registered lazy placeholder")
}
if _, found := reg.Get("mcp__mock__connect"); found {
t.Fatalf("lazy placeholder still registered after disconnect; names=%v", reg.Names())
}
}
func TestRegisterMCPServerOnDemandDefersConnectionUntilFirstUse(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
var requests atomic.Int32
var initializes atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if len(req.ID) == 0 || string(req.ID) == "null" {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch req.Method {
case "initialize":
initializes.Add(1)
result = map[string]any{
"protocolVersion": "2025-03-26",
"serverInfo": map[string]any{"name": "on-demand", "version": "1"},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "echo",
"description": "Echo a value.",
"inputSchema": map[string]any{"type": "object"},
}}}
default:
result = map[string]any{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": result,
})
}))
defer server.Close()
host := plugin.NewHost()
defer host.Close()
reg := tool.NewRegistry()
ctrl := newOwnedTestController(t, Options{Host: host, Registry: reg, PluginCtx: context.Background()})
entry := config.PluginEntry{Name: "on-demand", Type: "http", URL: server.URL, Source: config.MCPSourceUserConfig}
if _, err := ctrl.RegisterMCPServerOnDemand(entry); err != nil {
t.Fatalf("RegisterMCPServerOnDemand: %v", err)
}
if got := requests.Load(); got != 0 {
t.Fatalf("enable-time HTTP requests = %d, want zero", got)
}
connect, ok := reg.Get("mcp__on-demand__connect")
if !ok {
t.Fatalf("cache-miss connect stub missing; names=%v", reg.Names())
}
if _, err := connect.Execute(context.Background(), json.RawMessage(`{}`)); err == nil || !strings.Contains(err.Error(), "initializing on first use") {
t.Fatalf("first-use connect result = %v, want initializing guidance", err)
}
deadline := time.Now().Add(5 * time.Second)
for !host.HasClient("on-demand") && time.Now().Before(deadline) {
time.Sleep(10 * time.Millisecond)
}
if !host.HasClient("on-demand") {
t.Fatal("first tool use did not start the MCP connection")
}
if got := initializes.Load(); got != 1 {
t.Fatalf("initialize calls = %d, want exactly one on-demand start", got)
}
}
func TestControllerMCPHotLifecycleUpdatesCapabilityRuntime(t *testing.T) {
t.Setenv("REASONIX_CACHE_HOME", t.TempDir())
host := plugin.NewHost()
defer host.Close()
reg := tool.NewRegistry()
runtime := agent.NewMCPCapabilityRuntime(context.Background(), host, nil, reg, nil)
ctrl := newOwnedTestController(t, Options{
Host: host, Registry: reg, PluginCtx: context.Background(), CapabilityRuntime: runtime,
})
frontend := runtime.NewFrontend(nil, nil)
entry := config.PluginEntry{
Name: "hot", Type: "http", URL: "http://127.0.0.1:1", Source: config.MCPSourceUserConfig,
}
if _, err := ctrl.RegisterMCPServerOnDemand(entry); err != nil {
t.Fatalf("RegisterMCPServerOnDemand: %v", err)
}
listed, err := frontend.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
if err != nil || !strings.Contains(listed, `"name": "hot"`) {
t.Fatalf("hot add list = %q, %v", listed, err)
}
if !ctrl.UnregisterMCPServerTools("hot") {
t.Fatal("UnregisterMCPServerTools returned false")
}
listed, err = frontend.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
if err != nil || !strings.Contains(listed, `"status": "disabled"`) {
t.Fatalf("disabled list = %q, %v", listed, err)
}
if _, err := ctrl.RegisterMCPServerOnDemand(entry); err != nil {
t.Fatalf("re-enable RegisterMCPServerOnDemand: %v", err)
}
if !ctrl.DisconnectMCPServer("hot") {
t.Fatal("DisconnectMCPServer returned false for runtime-only placeholder")
}
listed, err = frontend.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
if err != nil || strings.Contains(listed, `"name": "hot"`) {
t.Fatalf("runtime-only disconnect leaked list entry = %q, %v", listed, err)
}
}
func TestAddMCPServerAuthorizesExplicitUserAddBeforeConnecting(t *testing.T) {
var configured plugin.Spec
c := newOwnedTestController(t, Options{
WorkspaceRoot: "/workspace",
MCPConfigureSpec: func(spec *plugin.Spec) { configured = *spec },
})
if _, err := c.AddMCPServer(config.PluginEntry{Name: "user-added"}); err == nil {
t.Fatal("AddMCPServer without a command unexpectedly succeeded")
}
if configured.ConfigSource != string(config.MCPSourceUserConfig) ||
!configured.Authorized || configured.RequireLaunchApproval || configured.WorkspaceRoot != "/workspace" {
t.Fatalf("configured spec = %+v, want user-authorized add-and-use policy", configured)
}
}
func TestAddMCPServerWritesGlobalConfigWithoutShadowingProject(t *testing.T) {
isolateControlConfigHome(t)
workspace := t.TempDir()
projectPath := filepath.Join(workspace, "reasonix.toml")
if err := os.WriteFile(projectPath, []byte(`
[[plugins]]
name = "project-only"
command = "project-only"
`), 0o644); err != nil {
t.Fatal(err)
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if len(req.ID) == 0 || string(req.ID) == "null" {
w.WriteHeader(http.StatusAccepted)
return
}
result := any(map[string]any{})
switch req.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2025-03-26",
"serverInfo": map[string]any{"name": "global-docs", "version": "1"},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "search",
"description": "Search documentation.",
"inputSchema": map[string]any{"type": "object"},
}}}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{"jsonrpc": "2.0", "id": req.ID, "result": result})
}))
defer server.Close()
host := plugin.NewHost()
defer host.Close()
ctrl := newOwnedTestController(t, Options{Host: host, Registry: tool.NewRegistry(), PluginCtx: context.Background(), WorkspaceRoot: workspace})
if n, err := ctrl.AddMCPServer(config.PluginEntry{Name: "global-docs", Type: "http", URL: server.URL}); err != nil || n != 1 {
t.Fatalf("AddMCPServer(global-docs) = (%d, %v), want one connected tool", n, err)
}
globalCfg := config.LoadForEdit(config.UserConfigPath())
globalEntry, found := controlTestPluginByName(globalCfg.Plugins, "global-docs")
if !found || globalEntry.URL != server.URL {
t.Fatalf("global config entry = %+v, found=%v", globalEntry, found)
}
projectCfg := config.LoadForEdit(projectPath)
if _, found := controlTestPluginByName(projectCfg.Plugins, "global-docs"); found {
t.Fatalf("global install leaked into project config: %+v", projectCfg.Plugins)
}
if _, found := controlTestPluginByName(projectCfg.Plugins, "project-only"); !found {
t.Fatalf("project config was not preserved: %+v", projectCfg.Plugins)
}
}
func TestAddMCPServerRejectsProjectNameCollision(t *testing.T) {
isolateControlConfigHome(t)
workspace := t.TempDir()
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(`
[[plugins]]
name = "shared"
command = "project-shared"
`), 0o644); err != nil {
t.Fatal(err)
}
ctrl := newOwnedTestController(t, Options{Host: plugin.NewHost(), WorkspaceRoot: workspace})
defer ctrl.Close()
if _, err := ctrl.AddMCPServer(config.PluginEntry{Name: "shared", Command: "global-shared"}); err == nil || !strings.Contains(err.Error(), "already configured") {
t.Fatalf("AddMCPServer(shared) error = %v, want project collision", err)
}
if _, found := controlTestPluginByName(config.LoadForEdit(config.UserConfigPath()).Plugins, "shared"); found {
t.Fatal("rejected project collision created a global shadow")
}
}
func TestConnectConfiguredProjectMCPIsTrustedByDefault(t *testing.T) {
isolateControlConfigHome(t)
workspace := t.TempDir()
var requests atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requests.Add(1)
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if len(req.ID) == 0 || string(req.ID) == "null" {
w.WriteHeader(http.StatusAccepted)
return
}
result := any(map[string]any{})
switch req.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2025-03-26",
"serverInfo": map[string]any{"name": "project-docs", "version": "1"},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "search",
"description": "Search project documentation.",
"inputSchema": map[string]any{"type": "object"},
}}}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": result,
})
}))
defer server.Close()
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), fmt.Appendf(nil, `
[[plugins]]
name = "project-docs"
type = "http"
url = %q
`, server.URL), 0o644); err != nil {
t.Fatal(err)
}
host := plugin.NewHost()
defer host.Close()
reg := tool.NewRegistry()
var configured plugin.Spec
ctrl := newOwnedTestController(t, Options{
Host: host,
Registry: reg,
PluginCtx: context.Background(),
WorkspaceRoot: workspace,
MCPConfigureSpec: func(spec *plugin.Spec) {
configured = *spec
},
})
n, err := ctrl.ConnectConfiguredMCPServer("project-docs")
if err != nil {
t.Fatalf("ConnectConfiguredMCPServer: %v", err)
}
if n != 1 || requests.Load() == 0 {
t.Fatalf("trusted project MCP = %d tools, %d requests; want 1 tool and a live connection", n, requests.Load())
}
if _, ok := reg.Get("mcp__project-docs__search"); !ok {
t.Fatalf("project MCP tool missing; names=%v", reg.Names())
}
if !configured.Authorized || configured.RequireLaunchApproval || configured.Dir != workspace {
t.Fatalf("project MCP spec = %+v, want trusted project-scoped runtime", configured)
}
nextHost := plugin.NewHost()
defer nextHost.Close()
nextCtrl := newOwnedTestController(t, Options{
Host: nextHost,
Registry: tool.NewRegistry(),
PluginCtx: context.Background(),
WorkspaceRoot: workspace,
})
if n, err := nextCtrl.ConnectConfiguredMCPServer("project-docs"); err != nil || n != 1 {
t.Fatalf("subsequent project MCP connection = (%d, %v), want zero-confirmation trust", n, err)
}
}
func TestConnectMCPServerAppliesConfiguredCallTimeouts(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var req struct {
ID json.RawMessage `json:"id"`
Method string `json:"method"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if len(req.ID) == 0 || string(req.ID) == "null" {
w.WriteHeader(http.StatusAccepted)
return
}
var result any
switch req.Method {
case "initialize":
result = map[string]any{
"protocolVersion": "2025-03-26",
"serverInfo": map[string]any{"name": "timeout-test", "version": "1"},
}
case "tools/list":
result = map[string]any{"tools": []map[string]any{{
"name": "slow",
"description": "Wait until the caller cancels.",
"inputSchema": map[string]any{"type": "object"},
}}}
case "tools/call":
<-r.Context().Done()
return
default:
result = map[string]any{}
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"jsonrpc": "2.0",
"id": req.ID,
"result": result,
})
}))
defer server.Close()
tests := []struct {
name string
defaultTimeout time.Duration
entry config.PluginEntry
}{
{
name: "global default",
defaultTimeout: time.Second,
},
{
name: "server override",
defaultTimeout: 10 * time.Second,
entry: config.PluginEntry{CallTimeoutSeconds: 1},
},
{
name: "tool override",
defaultTimeout: 10 * time.Second,
entry: config.PluginEntry{
CallTimeoutSeconds: 10,
ToolTimeoutSeconds: map[string]int{"slow": 1},
},
},
}
for i, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
host := plugin.NewHost()
defer host.Close()
reg := tool.NewRegistry()
ctrl := newOwnedTestController(t, Options{
Host: host,
Registry: reg,
MCPDefaultCallTimeout: tc.defaultTimeout,
})
entry := tc.entry
entry.Name = fmt.Sprintf("timeout%d", i)
entry.Type = "http"
entry.URL = server.URL
if _, err := ctrl.ConnectMCPServer(entry); err != nil {
t.Fatalf("ConnectMCPServer: %v", err)
}
connected, ok := reg.Get("mcp__" + entry.Name + "__slow")
if !ok {
t.Fatalf("connected tool missing; names=%v", reg.Names())
}
started := time.Now()
_, err := connected.Execute(context.Background(), json.RawMessage(`{}`))
elapsed := time.Since(started)
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("slow tool error = %v, want deadline exceeded", err)
}
if elapsed < 750*time.Millisecond || elapsed > 3*time.Second {
t.Fatalf("slow tool elapsed = %v, want configured 1s timeout", elapsed)
}
})
}
}
func TestUnregisterMCPServerToolsBlocksLateSharedHostSwap(t *testing.T) {
reg := tool.NewRegistry()
reg.Add(fakeControlTool{name: "mcp__mock__connect"})
c := newOwnedTestController(t, Options{Host: plugin.NewHost(), Registry: reg})
if ok := c.UnregisterMCPServerTools("mock"); !ok {
t.Fatal("UnregisterMCPServerTools returned false")
}
reg.Add(fakeControlTool{name: "mcp__mock__echo"})
if _, found := reg.Get("mcp__mock__echo"); found {
t.Fatalf("late shared-host tool swap was accepted after unregister; names=%v", reg.Names())
}
reg.Add(fakeControlTool{name: "mcp__other__echo"})
if _, found := reg.Get("mcp__other__echo"); !found {
t.Fatalf("unregister blocked unrelated MCP tools; names=%v", reg.Names())
}
}
func TestRemoveMCPServerRemovesUnconnectedLazyPlaceholder(t *testing.T) {
isolateControlConfigHome(t)
dir := t.TempDir()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData", "Roaming"))
t.Chdir(dir)
if err := os.WriteFile("reasonix.toml", []byte(`
[[plugins]]
name = "mock"
command = "mock-mcp"
tier = "lazy"
`), 0o644); err != nil {
t.Fatalf("write config: %v", err)
}
reg := tool.NewRegistry()
reg.Add(fakeControlTool{name: "mcp__mock__connect"})
host := plugin.NewHost()
defer host.Close()
spec := plugin.Spec{Name: "mock", Command: "mock-mcp", Authorized: true}
runtime := agent.NewMCPCapabilityRuntime(context.Background(), host, []plugin.Spec{spec}, reg, nil)
runtime.ConfigureServers([]config.PluginEntry{{Name: "mock", Command: "mock-mcp"}}, []plugin.Spec{spec}, map[string]bool{"mock": true})
c := newOwnedTestController(t, Options{Host: host, Registry: reg, CapabilityRuntime: runtime, WorkspaceRoot: dir})
disconnected, err := c.RemoveMCPServer("mock")
if err != nil {
t.Fatalf("RemoveMCPServer: %v", err)
}
if disconnected {
t.Fatal("RemoveMCPServer reported a live disconnect for an unconnected lazy placeholder")
}
if _, found := reg.Get("mcp__mock__connect"); found {
t.Fatalf("lazy placeholder still registered after remove; names=%v", reg.Names())
}
if names := c.ConfiguredMCPNames(); len(names) != 0 {
t.Fatalf("ConfiguredMCPNames() = %v, want empty after remove", names)
}
proxy := runtime.NewFrontend(nil, nil)
listed, listErr := proxy.Execute(context.Background(), json.RawMessage(`{"action":"list"}`))
if listErr != nil || strings.Contains(listed, `"name": "mock"`) {
t.Fatalf("removed server leaked through capability list = %q, %v", listed, listErr)
}
}
func TestConfiguredMCPNamesUseControllerWorkspaceInsteadOfProcessCWD(t *testing.T) {
isolateControlConfigHome(t)
workspace := t.TempDir()
other := t.TempDir()
t.Chdir(other)
if err := os.WriteFile(filepath.Join(workspace, "reasonix.toml"), []byte(`
[[plugins]]
name = "workspace-mcp"
command = "workspace-mcp"
`), 0o644); err != nil {
t.Fatal(err)
}
c := newOwnedTestController(t, Options{WorkspaceRoot: workspace, Host: plugin.NewHost()})
defer c.Close()
if got := c.ConfiguredMCPNames(); !reflect.DeepEqual(got, []string{"workspace-mcp"}) {
t.Fatalf("ConfiguredMCPNames() = %v, want workspace-mcp from %s", got, workspace)
}
if got := c.DisconnectedMCPNames(); !reflect.DeepEqual(got, []string{"workspace-mcp"}) {
t.Fatalf("DisconnectedMCPNames() = %v, want workspace-mcp from %s", got, workspace)
}
}
func TestRemoveMCPServerKeepsRuntimeOnlyToolsWhenPersistenceRemovalFails(t *testing.T) {
isolateControlConfigHome(t)
reg := tool.NewRegistry()
reg.Add(fakeControlTool{name: "mcp__runtime_only__echo"})
c := newOwnedTestController(t, Options{Host: plugin.NewHost(), Registry: reg})
if disconnected, err := c.RemoveMCPServer("runtime_only"); err == nil || disconnected || !strings.Contains(err.Error(), "no removable MCP server") {
t.Fatalf("RemoveMCPServer(runtime_only) = (%v, %v)", disconnected, err)
}
if _, found := reg.Get("mcp__runtime_only__echo"); !found {
t.Fatalf("runtime-only tool was removed despite failed persistence; names=%v", reg.Names())
}
}
func TestRemoveMCPServerRejectsPluginManagedTools(t *testing.T) {
home := isolateControlConfigHome(t)
reasonixHome := filepath.Join(home, ".reasonix")
t.Setenv("REASONIX_HOME", reasonixHome)
root := filepath.Join(reasonixHome, "plugins", "superpowers")
if err := os.MkdirAll(root, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, pluginpkg.NativeManifest), []byte(`{"apiVersion":"reasonix.io/plugin/v2","name":"superpowers","version":"1.0.0","mcpServers":{"helper":{"command":"bin/helper"}}}`), 0o644); err != nil {
t.Fatal(err)
}
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{
Name: "superpowers",
Root: "plugins/superpowers",
Version: "1.0.0",
ManifestKind: "reasonix",
Enabled: true,
}); err != nil {
t.Fatal(err)
}
reg := tool.NewRegistry()
reg.Add(fakeControlTool{name: "mcp__helper__echo"})
c := newOwnedTestController(t, Options{Host: plugin.NewHost(), Registry: reg})
disconnected, err := c.RemoveMCPServer("helper")
if err == nil || disconnected || !strings.Contains(err.Error(), "managed by plugin") || !strings.Contains(err.Error(), "superpowers") {
t.Fatalf("RemoveMCPServer(plugin-managed) = (%v, %v)", disconnected, err)
}
if _, found := reg.Get("mcp__helper__echo"); !found {
t.Fatalf("plugin-managed tool was removed despite rejected removal; names=%v", reg.Names())
}
if disconnected := c.DisconnectMCPServer("helper"); !disconnected {
t.Fatal("session-only disconnect should be allowed for a plugin-managed MCP")
}
if _, found := reg.Get("mcp__helper__echo"); found {
t.Fatalf("plugin-managed tool survived session-only disconnect; names=%v", reg.Names())
}
}
func TestRemoveMCPServerDeletesProjectMCPJSONSource(t *testing.T) {
isolateControlConfigHome(t)
if err := os.WriteFile(".mcp.json", []byte(`{
"mcpServers": {
"mock": { "command": "mock-mcp" },
"keep": { "command": "keep-mcp" }
}
}`), 0o644); err != nil {
t.Fatal(err)
}
reg := tool.NewRegistry()
reg.Add(fakeControlTool{name: "mcp__mock__connect"})
c := newOwnedTestController(t, Options{Host: plugin.NewHost(), Registry: reg})
disconnected, err := c.RemoveMCPServer("mock")
if err != nil {
t.Fatalf("RemoveMCPServer(.mcp.json): %v", err)
}
if disconnected {
t.Fatal("RemoveMCPServer reported a live disconnect for an idle .mcp.json server")
}
if _, found := reg.Get("mcp__mock__connect"); found {
t.Fatalf(".mcp.json placeholder survived removal; names=%v", reg.Names())
}
raw, err := os.ReadFile(".mcp.json")
if err != nil {
t.Fatal(err)
}
if strings.Contains(string(raw), `"mock"`) || !strings.Contains(string(raw), `"keep"`) {
t.Fatalf(".mcp.json removal did not preserve unrelated servers:\n%s", raw)
}
}
// approvalIDs returns a Controller whose Sink forwards each ApprovalRequest's ID
// onto the channel, plus a counter of how many requests it emitted.
func approvalIDs(t *testing.T) (*Controller, chan string, *int) {
ids := make(chan string, 8)
prompts := 0
c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
prompts++
ids <- e.Approval.ID
}
})})
return c, ids, &prompts
}
func permissionHookController(t *testing.T, match string) (*Controller, chan string, chan hook.Payload) {
t.Helper()
ids := make(chan string, 8)
payloads := make(chan hook.Payload, 8)
spawner := func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
var payload hook.Payload
if err := json.Unmarshal([]byte(in.Stdin), &payload); err != nil {
t.Errorf("permission hook payload json: %v", err)
}
payloads <- payload
return hook.SpawnResult{ExitCode: 0}
}
c := newOwnedTestController(t, Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
ids <- e.Approval.ID
}
}),
Hooks: hook.NewRunner([]hook.ResolvedHook{{
HookConfig: hook.HookConfig{Command: "notify", Match: match},
Event: hook.PermissionRequest,
Scope: hook.ScopeGlobal,
}}, "/tmp", spawner, nil),
})
return c, ids, payloads
}
// claudePermissionHookController wires a Claude-imported PermissionRequest
// hook (PayloadFormat "claude") whose mock spawner always returns stdout, so
// tests can assert the hook's decision preempts the approval prompt instead
// of only notifying — matching Claude's own PermissionRequest contract.
func claudePermissionHookController(t *testing.T, exitCode int, stdout string) (*Controller, chan string) {
t.Helper()
ids := make(chan string, 8)
spawner := func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
return hook.SpawnResult{ExitCode: exitCode, Stdout: stdout}
}
c := newOwnedTestController(t, Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
ids <- e.Approval.ID
}
}),
Hooks: hook.NewRunner([]hook.ResolvedHook{{
HookConfig: hook.HookConfig{Command: "guard", Match: "Bash", PayloadFormat: "claude"},
Event: hook.PermissionRequest,
Scope: hook.ScopeGlobal,
}}, "/tmp", spawner, nil),
})
return c, ids
}
func TestPermissionRequestClaudeHookAutoDenies(t *testing.T) {
c, ids := claudePermissionHookController(t, 2, "")
allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", "rm -rf /", json.RawMessage(`{"command":"rm -rf /"}`))
if err != nil {
t.Fatalf("Approve error = %v", err)
}
if allow {
t.Fatal("a Claude PermissionRequest hook exiting 2 should auto-deny")
}
select {
case id := <-ids:
t.Fatalf("auto-deny must preempt the approval prompt, but one was emitted: %s", id)
case <-time.After(50 * time.Millisecond):
}
}
func TestPermissionRequestClaudeHookAutoAllows(t *testing.T) {
allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
c, ids := claudePermissionHookController(t, 0, allowJSON)
allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", json.RawMessage(`{"command":"go test ./..."}`))
if err != nil {
t.Fatalf("Approve error = %v", err)
}
if !allow {
t.Fatal("a Claude PermissionRequest hook returning decision.behavior=allow should auto-allow")
}
select {
case id := <-ids:
t.Fatalf("auto-allow must preempt the approval prompt, but one was emitted: %s", id)
case <-time.After(50 * time.Millisecond):
}
}
// wildcardClaudePermissionHookController is like claudePermissionHookController
// but matches every tool, for exercising fresh-human-required tools whose
// names ("remember", "sandbox_escape", ...) aren't Claude tool names.
func wildcardClaudePermissionHookController(t *testing.T, exitCode int, stdout string) (*Controller, chan string) {
t.Helper()
ids := make(chan string, 8)
spawner := func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
return hook.SpawnResult{ExitCode: exitCode, Stdout: stdout}
}
c := newOwnedTestController(t, Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
ids <- e.Approval.ID
}
}),
Hooks: hook.NewRunner([]hook.ResolvedHook{{
HookConfig: hook.HookConfig{Command: "guard", PayloadFormat: "claude"},
Event: hook.PermissionRequest,
Scope: hook.ScopeGlobal,
}}, "/tmp", spawner, nil),
})
return c, ids
}
func TestPermissionRequestClaudeHookCannotAutoAllowFreshHumanApproval(t *testing.T) {
allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
for _, tool := range []string{memoryRememberTool, memoryForgetTool, SandboxEscapeApprovalTool, ManagedConfigWriteApprovalTool} {
t.Run(tool, func(t *testing.T) {
c, ids := wildcardClaudePermissionHookController(t, 0, allowJSON)
done := make(chan bool, 1)
go func() {
allow, _, err := gateApprover{c}.Approve(context.Background(), tool, "", json.RawMessage(`{}`))
if err != nil {
t.Errorf("Approve error = %v", err)
return
}
done <- allow
}()
id := waitApprovalID(t, ids)
c.Approve(id, true, false, false)
select {
case allow := <-done:
if !allow {
t.Fatal("manual approval should still allow")
}
case <-time.After(30 * time.Second):
t.Fatal("approval stayed blocked")
}
})
}
}
func TestPermissionRequestClaudeHookAutoDeniesFreshHumanApproval(t *testing.T) {
// A deny is always safe to auto-honor, even for fresh-human tools —
// refusing something that requires a human's blessing can't leak
// unauthorized access the way an auto-allow could.
for _, tool := range []string{memoryRememberTool, SandboxEscapeApprovalTool} {
t.Run(tool, func(t *testing.T) {
c, ids := wildcardClaudePermissionHookController(t, 2, "")
allow, _, err := gateApprover{c}.Approve(context.Background(), tool, "", json.RawMessage(`{}`))
if err != nil {
t.Fatalf("Approve error = %v", err)
}
if allow {
t.Fatal("a Claude PermissionRequest hook exiting 2 should auto-deny even a fresh-human tool")
}
select {
case id := <-ids:
t.Fatalf("auto-deny must preempt the approval prompt, but one was emitted: %s", id)
case <-time.After(50 * time.Millisecond):
}
})
}
}
// TestPermissionRequestClaudeHookCannotAutoAllowOptsFreshOnlyDecision covers
// the fresh-human protection's other branch: a tool that requestFreshApprovalDecision
// marks fresh (opts.fresh=true) without being one of
// RequiresFreshHumanApprovalTool's fixed cases. PlanModeReadOnlyCommandApprovalTool
// is exactly that — the earlier tests only exercised tools protected via
// requiresFreshApprovalTool(tool), not the opts.fresh flag alone.
func TestPermissionRequestClaudeHookCannotAutoAllowOptsFreshOnlyDecision(t *testing.T) {
if RequiresFreshHumanApprovalTool(agent.PlanModeReadOnlyCommandApprovalTool) {
t.Fatal("test assumes this tool is fresh-only via opts.fresh, not RequiresFreshHumanApprovalTool")
}
allowJSON := `{"hookSpecificOutput":{"hookEventName":"PermissionRequest","decision":{"behavior":"allow"}}}`
c, ids := wildcardClaudePermissionHookController(t, 0, allowJSON)
done := make(chan bool, 1)
go func() {
reply, err := c.requestFreshApprovalDecision(context.Background(), agent.PlanModeReadOnlyCommandApprovalTool, "ls", nil, "trust this read-only command prefix?")
if err != nil {
t.Errorf("requestFreshApprovalDecision error = %v", err)
return
}
done <- reply.allow
}()
id := waitApprovalID(t, ids)
c.Approve(id, true, false, false)
select {
case allow := <-done:
if !allow {
t.Fatal("manual approval should still allow")
}
case <-time.After(30 * time.Second):
t.Fatal("approval stayed blocked — a Claude hook allow should not have preempted this opts.fresh decision")
}
}
func waitApprovalID(t *testing.T, ids <-chan string) string {
t.Helper()
select {
case id := <-ids:
return id
case <-time.After(30 * time.Second):
t.Fatal("ApprovalRequest was not emitted")
}
return ""
}
func waitPermissionHook(t *testing.T, payloads <-chan hook.Payload) hook.Payload {
t.Helper()
select {
case payload := <-payloads:
return payload
case <-time.After(30 * time.Second):
t.Fatal("PermissionRequest hook did not fire")
}
return hook.Payload{}
}
func assertNoPermissionHook(t *testing.T, payloads <-chan hook.Payload) {
t.Helper()
select {
case payload := <-payloads:
t.Fatalf("PermissionRequest hook fired unexpectedly: %+v", payload)
case <-time.After(50 * time.Millisecond):
}
}
// TestApprovalAllowOnce drives the happy path: the gate emits an ApprovalRequest,
// the (fake) frontend answers allow, and the gate returns allow with no grant.
func TestApprovalAllowOnce(t *testing.T) {
c, ids, _ := approvalIDs(t)
go func() { c.Approve(<-ids, true, false, false) }()
allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test", nil)
if err != nil || !allow || remember {
t.Fatalf("Approve = (%v,%v,%v), want allow once", allow, remember, err)
}
}
func TestMemoryApprovalRequestShowsRememberPayload(t *testing.T) {
approvals := make(chan event.Approval, 1)
c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
approvals <- e.Approval
}
})})
args := json.RawMessage(`{
"name": "stable-retrieval-conclusion",
"description": "History retrieval should reuse stable synthesized conclusions.",
"type": "feedback",
"body": "**Why:** repeated history scans are expensive.\n\n**How to apply:** save the stable summary as a memory document."
}`)
result := make(chan string, 1)
go func() {
allow, _, err := gateApprover{c}.Approve(context.Background(), "remember", "", args)
if err != nil {
result <- err.Error()
return
}
if !allow {
result <- "memory approval denied"
return
}
result <- ""
}()
var approval event.Approval
select {
case approval = <-approvals:
case <-time.After(30 * time.Second):
t.Fatal("memory approval request was not emitted")
}
for _, want := range []string{
`Save/update memory "stable-retrieval-conclusion"`,
"[feedback]",
"History retrieval should reuse stable synthesized conclusions.",
"repeated history scans are expensive",
"save the stable summary",
} {
if !strings.Contains(approval.Subject, want) {
t.Fatalf("approval subject %q does not contain %q", approval.Subject, want)
}
}
if strings.Contains(approval.Subject, "\n") {
t.Fatalf("approval subject should be compact for TUI rendering, got %q", approval.Subject)
}
c.Approve(approval.ID, true, true, false)
select {
case msg := <-result:
if msg != "" {
t.Fatalf("Approve returned %s", msg)
}
case <-time.After(30 * time.Second):
t.Fatal("memory approval stayed blocked after Approve")
}
}
func TestFreshHumanApprovalToolsBypassGuardianAndWaitForUser(t *testing.T) {
guardianProv := &recordingProvider{
name: "guardian",
streams: [][]provider.Chunk{textTurn(`{"risk_level":"low","user_authorization":"high","outcome":"allow","rationale":"authorized memory update"}`)},
}
guardianSess := guardian.NewSession(guardianProv, tool.NewRegistry(), guardian.PolicyPrompt(), "guardian-test", 0, nil, event.Discard)
exec := agent.New(&recordingProvider{name: "executor"}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
approvals := make(chan event.Approval, 1)
c := newOwnedTestController(t, Options{
Executor: exec,
Guardian: guardianSess,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
approvals <- e.Approval
}
}),
})
args := json.RawMessage(`{"name":"prefers-vitest","description":"Preferred test framework","body":"Use vitest for frontend tests."}`)
type approveResult struct {
allow bool
remember bool
err error
}
done := make(chan approveResult, 1)
go func() {
allow, remember, err := gateApprover{c}.Approve(context.Background(), "remember", "", args)
done <- approveResult{allow: allow, remember: remember, err: err}
}()
var approval event.Approval
select {
case approval = <-approvals:
case <-time.After(30 * time.Second):
t.Fatal("memory approval request was not emitted after Guardian allow")
}
if approval.Tool != "remember" {
t.Fatalf("approval tool = %q, want remember", approval.Tool)
}
if len(guardianProv.requests) != 0 {
t.Fatalf("guardian reviews = %d, want 0; approval has one authoritative user decision path", len(guardianProv.requests))
}
select {
case got := <-done:
t.Fatalf("Guardian must not auto-allow remember, got %+v", got)
case <-time.After(50 * time.Millisecond):
}
c.Approve(approval.ID, true, true, false)
select {
case got := <-done:
if got.err != nil || !got.allow || got.remember {
t.Fatalf("Approve = (%v,%v,%v), want manual allow without remember", got.allow, got.remember, got.err)
}
case <-time.After(30 * time.Second):
t.Fatal("memory approval stayed blocked after manual Approve")
}
}
func TestLowRiskProjectMemoryCreateSkipsApprovalPrompt(t *testing.T) {
store := memory.Store{Dir: t.TempDir()}
approvals := 0
c := newOwnedTestController(t, Options{
Memory: &memory.Set{Store: store},
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
approvals++
}
}),
})
args := json.RawMessage(`{"name":"release-target","description":"Project release target","type":"project","body":"Release from main-v2."}`)
allow, remember, reason, err := gateApprover{c}.ApproveWithReason(context.Background(), memoryRememberTool, "", args)
if err != nil || !allow || remember || reason != "" || approvals != 0 {
t.Fatalf("safe project create = (%v,%v,%q,%v), approvals=%d", allow, remember, reason, err, approvals)
}
out, err := memory.NewRememberTool(store).Execute(memory.WithQueue(context.Background(), c), args)
if err != nil || !strings.Contains(out, "Saved memory") {
t.Fatalf("auto-approved remember execution = %q, %v", out, err)
}
if got := store.List(); len(got) != 1 || got[0].Name != "release-target" {
t.Fatalf("saved memories = %+v", got)
}
}
func TestExistingMemoryRevokesAbandonedAutomaticCreateClaim(t *testing.T) {
store := memory.Store{Dir: t.TempDir()}
c := newOwnedTestController(t, Options{Memory: &memory.Set{Store: store}})
args := json.RawMessage(`{"name":"release-target","description":"Project release target","type":"project","body":"Release from main-v2."}`)
if assessment := memory.AssessRememberWrite(store, args); !assessment.AutoAllow {
t.Fatalf("initial assessment = %+v", assessment)
}
c.memory.authorizeAutoRemember(args) // approval was issued, then the turn was cancelled
if _, err := store.Save(memory.Memory{Name: "release-target", Description: "concurrent", Body: "existing"}); err != nil {
t.Fatal(err)
}
if assessment := memory.AssessRememberWrite(store, args); assessment.AutoAllow {
t.Fatalf("existing assessment = %+v", assessment)
}
c.memory.revokeAutoRemember(args)
if c.ClaimAutoMemoryWrite(args) {
t.Fatal("abandoned automatic create claim survived an existing-memory reassessment")
}
}
// TestSessionGrantShortCircuitsGuardianReview: a session grant (or YOLO / the
// approved-plan window) answers an ordinary approval before any guardian review
// or prompt is attempted. Absorbed from PR #6413 by @myipanta.
func TestSessionGrantShortCircuitsGuardianReview(t *testing.T) {
guardianProv := &recordingProvider{
name: "guardian",
streams: [][]provider.Chunk{textTurn(`{"risk_level":"high","user_authorization":"unknown","outcome":"deny","rationale":"should never run"}`)},
}
guardianSess := guardian.NewSession(guardianProv, tool.NewRegistry(), guardian.PolicyPrompt(), "guardian-test", 0, nil, event.Discard)
exec := agent.New(&recordingProvider{name: "executor"}, tool.NewRegistry(), agent.NewSession("sys"), agent.Options{}, event.Discard)
prompts := 0
c := newOwnedTestController(t, Options{
Executor: exec,
Guardian: guardianSess,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
prompts++
}
}),
})
subject := approvalDisplaySubject("write_file", "main.go", nil)
c.approval.grantSession("write_file", subject)
allow, remember, _, err := gateApprover{c}.ApproveWithReason(context.Background(), "write_file", "main.go", nil)
if err != nil || !allow || remember {
t.Fatalf("session-granted approval = (%v,%v,%v), want plain allow", allow, remember, err)
}
if len(guardianProv.requests) != 0 || prompts != 0 {
t.Fatalf("session grant must bypass guardian and prompts, reviews=%d prompts=%d", len(guardianProv.requests), prompts)
}
}
func TestHeadlessGateRefusesFreshHumanApprovalTools(t *testing.T) {
gate := NewHeadlessPermissionGate(permission.New("ask", nil, nil, nil))
for _, toolName := range []string{"remember", "forget"} {
allow, reason, err := gate.Check(context.Background(), toolName, json.RawMessage(`{}`), false)
if err != nil || allow || !strings.Contains(reason, "fresh human approval") {
t.Fatalf("%s headless check = (%v,%q,%v), want fresh-human refusal", toolName, allow, reason, err)
}
}
allow, reason, err := gate.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
if err != nil || !allow || reason != "" {
t.Fatalf("legacy bootstrap ask = (%v,%q,%v), want compatibility allow", allow, reason, err)
}
}
func TestMemoryApprovalSubjectsAndNotifications(t *testing.T) {
forgetSubject := approvalDisplaySubject("forget", "", json.RawMessage(`{"name":"wrong-memory"}`))
if forgetSubject != `Archive memory "wrong-memory"` {
t.Fatalf("forget approval subject = %q", forgetSubject)
}
if got := approvalNotificationText("remember", "Save/update memory with private details"); got != "approval needed: remember" {
t.Fatalf("remember notification = %q", got)
}
if got := approvalNotificationText("forget", `Archive memory "wrong-memory"`); got != "approval needed: forget" {
t.Fatalf("forget notification = %q", got)
}
if got := approvalNotificationText("bash", "go test ./..."); got != "approval needed: bash go test ./..." {
t.Fatalf("bash notification = %q", got)
}
moveSubject := approvalDisplaySubject("move_file", "src/a.md", json.RawMessage(`{"source_path":"src/a.md","destination_path":"docs/a.md"}`))
if moveSubject != "src/a.md -> docs/a.md" {
t.Fatalf("move_file approval subject = %q", moveSubject)
}
}
func TestPermissionRequestHookFiresForToolApproval(t *testing.T) {
c, ids, payloads := permissionHookController(t, "bash")
args := json.RawMessage(`{"command":"go test ./..."}`)
type approveResult struct {
allow bool
remember bool
err error
}
done := make(chan approveResult, 1)
go func() {
allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", args)
done <- approveResult{allow: allow, remember: remember, err: err}
}()
id := waitApprovalID(t, ids)
payload := waitPermissionHook(t, payloads)
if payload.Event != hook.PermissionRequest {
t.Fatalf("payload event = %q, want PermissionRequest", payload.Event)
}
if payload.ToolName != "bash" {
t.Fatalf("payload tool = %q, want bash", payload.ToolName)
}
if payload.Subject != "go test ./..." {
t.Fatalf("payload subject = %q, want command subject", payload.Subject)
}
if string(payload.ToolArgs) != string(args) {
t.Fatalf("payload args = %s, want %s", payload.ToolArgs, args)
}
c.Approve(id, true, false, false)
select {
case got := <-done:
if got.err != nil || !got.allow || got.remember {
t.Fatalf("Approve = (%v,%v,%v), want allow once", got.allow, got.remember, got.err)
}
case <-time.After(30 * time.Second):
t.Fatal("approval stayed blocked")
}
}
func TestPermissionRequestHookDoesNotFireForPolicyAllow(t *testing.T) {
c, _, payloads := permissionHookController(t, "bash")
g := permission.NewGate(permission.New("ask", []string{"bash(go test*)"}, nil, nil), gateApprover{c})
allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
if err != nil || !allow {
t.Fatalf("allow-listed call = (%v,%v), want allowed", allow, err)
}
assertNoPermissionHook(t, payloads)
}
func TestPermissionRequestHookDoesNotFireForAutoApprovalMode(t *testing.T) {
c, _, payloads := permissionHookController(t, "bash")
c.SetToolApprovalMode(ToolApprovalAuto)
g := c.newInteractiveGate()
allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go test ./..."}`), false)
if err != nil || !allow {
t.Fatalf("auto-approved call = (%v,%v), want allowed", allow, err)
}
assertNoPermissionHook(t, payloads)
}
func TestPermissionRequestHookDoesNotFireForSessionGrant(t *testing.T) {
c, _, payloads := permissionHookController(t, "bash")
c.approval.grantSession("bash", "go test ./...")
allow, _, err := c.requestApproval(context.Background(), "bash", "go test ./...", nil)
if err != nil || !allow {
t.Fatalf("session-granted approval = (%v,%v), want allowed", allow, err)
}
assertNoPermissionHook(t, payloads)
}
// TestSessionAuthorizationsCarryAcrossRebuild pins the fix for a rebuild
// (model/effort/profile switch) dropping same-session "Allow for this
// session" tool grants and Plan-mode read-only command trust: only the
// ask/auto/yolo posture string used to survive a controller swap, so a user
// who had already granted a tool this session was asked again after any
// switch.
func TestSessionAuthorizationsCarryAcrossRebuild(t *testing.T) {
old := newOwnedTestController(t, Options{})
old.approval.grantSession("bash", "go test ./...")
old.approval.grantPlanModeReadOnlyCommand("go test ./...")
fresh := newOwnedTestController(t, Options{})
fresh.RestoreSessionAuthorizations(old.SessionAuthorizations())
allow, _, err := fresh.requestApproval(context.Background(), "bash", "go test ./...", nil)
if err != nil || !allow {
t.Fatalf("session-granted approval after restore = (%v,%v), want allowed", allow, err)
}
if !fresh.approval.planModeReadOnlyCommandTrusted("go test ./...") {
t.Fatal("plan-mode read-only command trust did not carry across rebuild")
}
}
func TestPermissionRequestHookDoesNotFireForYolo(t *testing.T) {
c, _, payloads := permissionHookController(t, "bash")
c.SetToolApprovalMode(ToolApprovalYolo)
allow, _, err := c.requestApproval(context.Background(), "bash", "go test ./...", nil)
if err != nil || !allow {
t.Fatalf("YOLO approval = (%v,%v), want allowed", allow, err)
}
assertNoPermissionHook(t, payloads)
}
func TestPermissionRequestHookDoesNotFireForPlanApproval(t *testing.T) {
c, ids, payloads := permissionHookController(t, ".*")
done := make(chan bool, 1)
errs := make(chan error, 1)
go func() {
allow, _, err := c.requestApproval(context.Background(), planApprovalTool, "", nil)
if err != nil {
errs <- err
return
}
done <- allow
}()
id := waitApprovalID(t, ids)
assertNoPermissionHook(t, payloads)
c.Approve(id, true, false, false)
select {
case err := <-errs:
t.Fatalf("plan approval: %v", err)
case allow := <-done:
if !allow {
t.Fatal("manual plan approval should allow")
}
case <-time.After(30 * time.Second):
t.Fatal("plan approval stayed blocked")
}
}
func TestPermissionRequestHookRedactsMemoryApprovalPayload(t *testing.T) {
cases := []struct {
tool string
args json.RawMessage
}{
{
tool: "remember",
args: json.RawMessage(`{"name":"private-memory","description":"private description","body":"private memory body"}`),
},
{
tool: "forget",
args: json.RawMessage(`{"name":"private-memory"}`),
},
}
for _, tc := range cases {
t.Run(tc.tool, func(t *testing.T) {
c, ids, payloads := permissionHookController(t, tc.tool)
done := make(chan string, 1)
go func() {
allow, _, err := gateApprover{c}.Approve(context.Background(), tc.tool, "", tc.args)
if err != nil {
done <- err.Error()
return
}
if !allow {
done <- tc.tool + " approval denied"
return
}
done <- ""
}()
id := waitApprovalID(t, ids)
payload := waitPermissionHook(t, payloads)
if payload.ToolName != tc.tool {
t.Fatalf("payload tool = %q, want %s", payload.ToolName, tc.tool)
}
if payload.Subject != "" {
t.Fatalf("memory PermissionRequest subject = %q, want redacted", payload.Subject)
}
if len(payload.ToolArgs) != 0 {
t.Fatalf("memory PermissionRequest args = %s, want redacted", payload.ToolArgs)
}
c.Approve(id, true, false, false)
select {
case msg := <-done:
if msg != "" {
t.Fatal(msg)
}
case <-time.After(30 * time.Second):
t.Fatal("memory approval stayed blocked")
}
})
}
}
// TestApprovalDeny confirms a declined call returns allow=false.
func TestApprovalDeny(t *testing.T) {
c, ids, _ := approvalIDs(t)
go func() { c.Approve(<-ids, false, false, false) }()
allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", "rm -rf /", nil)
if err != nil || allow {
t.Fatalf("Approve = (%v,%v), want deny", allow, err)
}
}
// TestApprovalSessionGrantScopesBashToCommand proves an "allow this session"
// answer short-circuits later prompts for the same bash command, but a different
// command still reaches the frontend.
func TestApprovalSessionGrantScopesBashToCommand(t *testing.T) {
c, ids, prompts := approvalIDs(t)
go func() {
c.Approve(<-ids, true, true, false) // grant go build for this session
c.Approve(<-ids, true, false, false)
}()
for i, subject := range []string{"go build", "go build", "go test ./..."} {
allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", subject, nil)
if err != nil || !allow {
t.Fatalf("call %d = (%v,%v), want allow", i, allow, err)
}
}
if *prompts != 2 {
t.Errorf("prompted %d times, want 2 (same command granted, different command prompts)", *prompts)
}
}
func TestApprovalSessionGrantCanScopeBashToCommandPrefix(t *testing.T) {
c, ids, prompts := approvalIDs(t)
go func() {
c.Approve(<-ids, true, true, false) // grant bash session (prefix preferred)
c.Approve(<-ids, true, false, false)
}()
for i, subject := range []string{"go test ./...", "go test ./internal/control", "go build ./..."} {
allow, _, err := gateApprover{c}.Approve(context.Background(), "bash", subject, nil)
if err != nil || !allow {
t.Fatalf("call %d = (%v,%v), want allow", i, allow, err)
}
}
if *prompts != 2 {
t.Errorf("prompted %d times, want 2 (prefix grant should cover similar command only)", *prompts)
}
}
func TestApprovalRejectsPersistentScopeAndAcceptsSessionScope(t *testing.T) {
ids := make(chan string, 1)
c := newOwnedTestController(t, Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
ids <- e.Approval.ID
}
}),
})
result := make(chan error, 1)
go func() {
allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", nil)
if err == nil && (!allow || remember) {
err = fmt.Errorf("unexpected result allow=%v remember=%v", allow, remember)
}
result <- err
}()
id := <-ids
if err := c.approveChecked(id, true, true, true); err == nil || !strings.Contains(err.Error(), "permanent approval") {
t.Fatalf("persistent approval error = %v", err)
}
if err := c.approveChecked(id, true, true, false); err != nil {
t.Fatalf("session approval: %v", err)
}
if err := <-result; err != nil {
t.Fatal(err)
}
}
func TestApprovalSessionScopeDoesNotPersistRules(t *testing.T) {
ids := make(chan string, 2)
prompts := 0
remembered := false
c := newOwnedTestController(t, Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
prompts++
ids <- e.Approval.ID
}
}),
OnRemember: func(rule string) RememberResult {
remembered = true
return RememberResult{Rule: rule, Path: "reasonix.toml", Saved: true}
},
})
go func() {
c.Approve(<-ids, true, true, false)
}()
for i := range 2 {
allow, remember, err := gateApprover{c}.Approve(context.Background(), "bash", "go test ./...", nil)
if err != nil || !allow || remember {
t.Fatalf("Approve call %d = (%v,%v,%v), want session authorization", i, allow, remember, err)
}
}
if prompts != 1 {
t.Fatalf("approval prompts = %d, want one session-scoped prompt", prompts)
}
if remembered {
t.Fatal("session authorization must not persist a permission rule")
}
}
func TestPlanModeReadOnlyTrustApprovalGrantsSessionCommandTrust(t *testing.T) {
ids := make(chan string, 2)
var approval event.Approval
prompts := 0
c := newOwnedTestController(t, Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
prompts++
approval = e.Approval
ids <- e.Approval.ID
}
}),
})
go func() {
c.Approve(<-ids, true, true, false)
}()
req := agent.PlanModeReadOnlyTrustRequest{
ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
Command: "gh issue view 5867 --json title",
Prefix: "gh issue view",
Args: json.RawMessage(`{"command":"gh issue view 5867 --json title"}`),
}
allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
if err != nil || !allow || reason != "" {
t.Fatalf("CheckPlanModeReadOnlyTrust = (%v,%q,%v), want allow", allow, reason, err)
}
if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool || !strings.Contains(approval.Subject, `Trust "gh issue view"`) || !strings.Contains(approval.Subject, "gh issue view 5867") || !strings.Contains(approval.Reason, "Permission presets") {
t.Fatalf("approval = %+v, want plan-mode bash read-only command trust prompt", approval)
}
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel()
allow, reason, err = planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(ctx, req)
if err != nil || !allow || reason != "" {
t.Fatalf("second CheckPlanModeReadOnlyTrust = (%v,%q,%v), want session grant", allow, reason, err)
}
if prompts != 1 {
t.Fatalf("approval prompts = %d, want 1", prompts)
}
}
func TestApprovalSubjectsUseChineseCatalog(t *testing.T) {
i18n.DetectLanguage("zh")
t.Cleanup(func() { i18n.DetectLanguage("en") })
rememberArgs := json.RawMessage(`{"name":"prefers-vitest","type":"user","description":"Preferred test framework","body":"Use Vitest for frontend tests."}`)
if got := approvalDisplaySubject(memoryRememberTool, "", rememberArgs); !strings.Contains(got, "保存/更新记忆") || !strings.Contains(got, "正文: Use Vitest") {
t.Fatalf("remember approval subject = %q, want Chinese labels", got)
}
forgetArgs := json.RawMessage(`{"name":"old-fact"}`)
if got := approvalDisplaySubject(memoryForgetTool, "", forgetArgs); got != `归档记忆 "old-fact"` {
t.Fatalf("forget approval subject = %q, want Chinese archive label", got)
}
}
func TestPlanModeReadOnlyTrustApprovalUsesChineseCatalog(t *testing.T) {
i18n.DetectLanguage("zh")
t.Cleanup(func() { i18n.DetectLanguage("en") })
approvalRequests := make(chan event.Approval, 1)
c := newOwnedTestController(t, Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
approvalRequests <- e.Approval
}
}),
})
done := make(chan struct {
allow bool
reason string
err error
}, 1)
req := agent.PlanModeReadOnlyTrustRequest{
ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
Command: "gh issue view 5867 --json title",
Prefix: "gh issue view",
Args: json.RawMessage(`{"command":"gh issue view 5867 --json title"}`),
}
go func() {
allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
done <- struct {
allow bool
reason string
err error
}{allow: allow, reason: reason, err: err}
}()
var approval event.Approval
select {
case approval = <-approvalRequests:
case <-time.After(30 * time.Second):
t.Fatal("plan-mode bash trust approval request was not emitted")
}
if !strings.Contains(approval.Subject, "在计划模式中信任") || !strings.Contains(approval.Subject, "gh issue view 5867") {
t.Fatalf("approval subject = %q, want Chinese plan-mode trust subject", approval.Subject)
}
if !strings.Contains(approval.Reason, "不在 Reasonix 内置只读集合中") {
t.Fatalf("approval reason = %q, want Chinese plan-mode trust reason", approval.Reason)
}
c.Approve(approval.ID, false, false, false)
select {
case got := <-done:
if got.err != nil || got.allow || !strings.Contains(got.reason, "用户拒绝") {
t.Fatalf("rejected trust result = %+v, want Chinese denial", got)
}
case <-time.After(30 * time.Second):
t.Fatal("plan-mode bash trust approval stayed blocked after rejection")
}
}
func TestPlanModeReadOnlyCommandTrustApprovalIgnoresToolAutoApproval(t *testing.T) {
approvalRequests := make(chan event.Approval, 1)
c := newOwnedTestController(t, Options{
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
approvalRequests <- e.Approval
}
}),
})
c.SetToolApprovalMode(ToolApprovalDangerFullAccess)
type trustResult struct {
allow bool
reason string
err error
}
done := make(chan trustResult, 1)
req := agent.PlanModeReadOnlyTrustRequest{
ToolName: agent.PlanModeReadOnlyCommandApprovalTool,
Command: "gh issue view 5867",
Prefix: "gh issue view",
Args: json.RawMessage(`{"command":"gh issue view 5867"}`),
}
go func() {
allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
done <- trustResult{allow: allow, reason: reason, err: err}
}()
var approval event.Approval
select {
case approval = <-approvalRequests:
case <-time.After(30 * time.Second):
t.Fatal("plan-mode bash read-only command trust prompt was not emitted under tool auto-approval")
}
if approval.Tool != agent.PlanModeReadOnlyCommandApprovalTool || !strings.Contains(approval.Subject, `Trust "gh issue view"`) {
t.Fatalf("approval = %+v, want plan-mode bash read-only command trust prompt", approval)
}
select {
case got := <-done:
t.Fatalf("tool auto-approval must not answer plan-mode bash read-only command trust, got %+v", got)
case <-time.After(50 * time.Millisecond):
}
c.Approve(approval.ID, true, true, false)
select {
case got := <-done:
if got.err != nil || !got.allow || got.reason != "" {
t.Fatalf("CheckPlanModeReadOnlyTrust after approval = %+v, want allow", got)
}
case <-time.After(30 * time.Second):
t.Fatal("plan-mode bash read-only command trust prompt stayed blocked after Approve")
}
allow, reason, err := planModeReadOnlyTrustApprover{c}.CheckPlanModeReadOnlyTrust(context.Background(), req)
if err != nil || !allow || reason != "" {
t.Fatalf("session-granted plan-mode bash read-only command trust under YOLO = (%v,%q,%v), want allow", allow, reason, err)
}
}
func TestApprovalSessionGrantGroupsFileMutationTools(t *testing.T) {
c, ids, prompts := approvalIDs(t)
go func() { c.Approve(<-ids, true, true, false) }()
for i, call := range []struct {
tool string
subject string
}{
{"edit_file", "src/a.go"},
{"write_file", "src/b.go"},
{"multi_edit", "src/c.go"},
{"move_file", "src/d.go"},
} {
allow, _, err := gateApprover{c}.Approve(context.Background(), call.tool, call.subject, nil)
if err != nil || !allow {
t.Fatalf("call %d = (%v,%v), want allow", i, allow, err)
}
}
if *prompts != 1 {
t.Errorf("prompted %d times, want 1 (file mutation session grant should short-circuit)", *prompts)
}
}
func TestApprovalSessionGrantKeepsPolicyDenyPrecedence(t *testing.T) {
c, ids, prompts := approvalIDs(t)
g := permission.NewGate(permission.New("ask", nil, nil, []string{"bash(rm*)"}), gateApprover{c})
go func() { c.Approve(<-ids, true, true, false) }()
allow, _, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go build"}`), false)
if err != nil || !allow {
t.Fatalf("first approved call = (%v,%v), want allow", allow, err)
}
allow, _, err = g.Check(context.Background(), "bash", json.RawMessage(`{"command":"go build"}`), false)
if err != nil || !allow {
t.Fatalf("same-command call after session grant = (%v,%v), want allow", allow, err)
}
allow, reason, err := g.Check(context.Background(), "bash", json.RawMessage(`{"command":"rm -rf /tmp/x"}`), false)
if err != nil || allow || reason == "" {
t.Fatalf("deny-listed call = (%v,%q,%v), want blocked with reason", allow, reason, err)
}
if *prompts != 1 {
t.Errorf("prompted %d times, want 1", *prompts)
}
}
// TestApprovalCtxCancel ensures a cancelled turn unblocks the gate with an error
// (rather than hanging) when no one answers.
func TestApprovalCtxCancel(t *testing.T) {
c := newOwnedTestController(t, Options{Sink: event.Discard})
ctx, cancel := context.WithCancel(context.Background())
cancel()
allow, _, err := gateApprover{c}.Approve(ctx, "bash", "x", nil)
if err == nil || allow {
t.Fatalf("Approve on cancelled ctx = (%v,%v), want (false, error)", allow, err)
}
}
func TestParseRewind(t *testing.T) {
cps := []checkpoint.Meta{
{Turn: 0, Prompt: "first"},
{Turn: 1, Prompt: "second"},
{Turn: 2, Prompt: "third"},
}
cases := []struct {
args string
wantT int
wantS RewindScope
wantErr bool
}{
{"", 2, RewindBoth, false}, // no args -> latest turn, both
{"1", 1, RewindBoth, false}, // turn only
{"0 code", 0, RewindCode, false}, // turn + code
{"1 conversation", 1, RewindConversation, false}, // turn + conversation
{"2 both", 2, RewindBoth, false}, // turn + both
{"abc", 0, RewindBoth, true}, // invalid turn
{"0 unknown", 0, RewindBoth, true}, // unknown scope
}
for _, tc := range cases {
t.Run(tc.args, func(t *testing.T) {
gotT, gotS, err := parseRewind(tc.args, cps)
if (err != nil) != tc.wantErr {
t.Fatalf("parseRewind(%q) err=%v, wantErr=%v", tc.args, err, tc.wantErr)
}
if err != nil {
return
}
if gotT != tc.wantT || gotS != tc.wantS {
t.Fatalf("parseRewind(%q) = (%d,%d), want (%d,%d)", tc.args, gotT, gotS, tc.wantT, tc.wantS)
}
})
}
}
func TestParseRewindEmptyCheckpoints(t *testing.T) {
_, _, err := parseRewind("", nil)
if err == nil {
t.Fatal("expected error when no checkpoints")
}
}
func TestRunGuardedPanicEmitsTurnDone(t *testing.T) {
sess := agent.NewSession("sys")
events := make(chan event.Event, 4)
c := newOwnedTestController(t, Options{
Runner: appendingRunner{session: sess},
Sink: event.FuncSink(func(e event.Event) { events <- e }),
})
go func() {
c.runGuarded(func(ctx context.Context) error {
panic("boom")
})
}()
deadline := time.After(30 * time.Second)
for {
select {
case e := <-events:
if e.Kind != event.TurnDone {
continue
}
if e.Err == nil || !strings.Contains(e.Err.Error(), "boom") {
t.Fatalf("expected TurnDone.Err to contain panic message, got %v", e.Err)
}
goto done
case <-deadline:
t.Fatal("timed out waiting for TurnDone after panic")
}
}
done:
waitIdle(t, c)
if c.Running() {
t.Fatal("controller still running after panic recovery")
}
}
// TestRunGuardedParksReplacementUntilTurnDoneReturns pins the finishing-window
// admission contract from both sides: a replacement turn arriving while
// TurnDone is being delivered must NOT start inside the window (the original
// transport-crosstalk guarantee this window exists for), and it must START —
// exactly once — when the window closes. The second half replaces the old
// silent-drop behavior, which lost real input: every caller that submits upon
// seeing turn_done (a frontend's queued auto-send, a bot, a fast Enter) raced
// this window, observed as a CI-flaky lost turn and worked around in
// Composer.tsx by gating auto-send on submitDisabled instead of turn_done.
func TestRunGuardedParksReplacementUntilTurnDoneReturns(t *testing.T) {
firstTurnDone := make(chan struct{})
releaseTurnDone := make(chan struct{})
firstBodyDone := make(chan struct{})
secondBodyRan := make(chan struct{}, 2)
var turnDones atomic.Int32
c := newOwnedTestController(t, Options{Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone {
if turnDones.Add(1) == 1 {
close(firstTurnDone)
<-releaseTurnDone
}
}
})})
if got := c.runGuarded(func(context.Context) error {
close(firstBodyDone)
return nil
}); got != turnStarted {
t.Fatalf("first admission = %v, want turnStarted", got)
}
<-firstBodyDone
select {
case <-firstTurnDone:
case <-time.After(time.Second):
t.Fatal("TurnDone delivery did not start")
}
if !c.RuntimeStatus().Running {
t.Fatal("controller reported idle while TurnDone was still being delivered")
}
if got := c.runGuarded(func(context.Context) error {
secondBodyRan <- struct{}{}
return nil
}); got != turnParked {
t.Fatalf("finishing-window admission = %v, want turnParked", got)
}
select {
case <-secondBodyRan:
t.Fatal("replacement turn started before TurnDone delivery completed")
case <-time.After(50 * time.Millisecond):
}
close(releaseTurnDone)
select {
case <-secondBodyRan:
case <-time.After(30 * time.Second):
t.Fatal("parked turn was never started after the finishing window closed")
}
deadline := time.Now().Add(30 * time.Second)
for c.Running() && time.Now().Before(deadline) {
time.Sleep(time.Millisecond)
}
if c.Running() {
t.Fatal("controller remained busy after the parked turn completed")
}
if got := turnDones.Load(); got != 2 {
t.Fatalf("TurnDone emitted %d times, want 2 (one per turn)", got)
}
select {
case <-secondBodyRan:
t.Fatal("parked turn ran more than once")
default:
}
}
func TestRunGuardedPanicDoesNotDoubleEmitTurnDone(t *testing.T) {
sess := agent.NewSession("sys")
var count atomic.Int32
events := make(chan event.Event, 8)
c := newOwnedTestController(t, Options{
Runner: appendingRunner{session: sess},
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.TurnDone {
count.Add(1)
}
events <- e
}),
})
go func() {
c.runGuarded(func(ctx context.Context) error {
panic("boom")
})
}()
deadline := time.After(30 * time.Second)
for {
select {
case <-events:
n := count.Load()
if n >= 1 {
time.Sleep(50 * time.Millisecond)
n2 := count.Load()
if n2 > 1 {
t.Fatalf("TurnDone emitted %d times, expected 1", n2)
}
return
}
case <-deadline:
t.Fatal("timed out waiting for TurnDone")
}
}
}
type blockingRunner struct {
session *agent.Session
release chan struct{}
}
func (r blockingRunner) Run(_ context.Context, input string) error {
r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
<-r.release
return nil
}
func TestRunTurnReportsErrTurnRunning(t *testing.T) {
sess := agent.NewSession("sys")
release := make(chan struct{})
c := newOwnedTestController(t, Options{Runner: blockingRunner{session: sess, release: release}})
done := make(chan error, 1)
go func() {
done <- c.RunTurn(context.Background(), "first")
}()
waitForRunning(t, c)
if err := c.RunTurn(context.Background(), "second"); !errors.Is(err, ErrTurnRunning) {
t.Fatalf("RunTurn while running error = %v, want ErrTurnRunning", err)
}
close(release)
select {
case err := <-done:
if err != nil {
t.Fatalf("first RunTurn returned %v", err)
}
case <-time.After(30 * time.Second):
t.Fatal("first RunTurn did not finish after release")
}
}
func TestSendWhileRunningDoesNotInterleaveTurns(t *testing.T) {
sess := agent.NewSession("sys")
release := make(chan struct{})
events := make(chan event.Event, 4)
c := newOwnedTestController(t, Options{
Runner: blockingRunner{session: sess, release: release},
Sink: event.FuncSink(func(e event.Event) {
events <- e
}),
})
defer c.autosaveWG.Wait()
c.Send("first")
waitForRunning(t, c)
c.Send("second")
close(release)
waitForTurnDone(t, events)
var users []string
for _, m := range sess.Messages {
if m.Role == provider.RoleUser {
users = append(users, m.Content)
}
}
if len(users) != 1 || users[0] != "first" {
t.Fatalf("user turns = %v, want only first turn recorded", users)
}
}
func waitForRunning(t *testing.T, c *Controller) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if c.Running() {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatal("controller did not enter running state")
}
func TestMidTurnAutosavePersistsDuringLongTurn(t *testing.T) {
old := midTurnSnapshotInterval.Load()
midTurnSnapshotInterval.Store(int64(10 * time.Millisecond))
defer midTurnSnapshotInterval.Store(old)
dir := t.TempDir()
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
path := filepath.Join(dir, "session.jsonl")
release := make(chan struct{})
c := newOwnedTestController(t, Options{Runner: blockingRunner{session: sess, release: release}, Executor: exec, SessionDir: dir, SessionPath: path, Label: "test"})
// Unblock the turn and wait for the autosaver to exit before TempDir
// cleanup, which fails on Windows while a snapshot tmp write is in flight.
defer c.autosaveWG.Wait()
defer close(release)
c.Send("hello mid-turn persistence")
deadline := time.Now().Add(3 * time.Second)
for time.Now().Before(deadline) {
if b, err := os.ReadFile(path); err == nil && strings.Contains(string(b), "hello mid-turn persistence") {
return
}
time.Sleep(5 * time.Millisecond)
}
t.Fatal("session file was not written while the turn was still running")
}
type scriptedRunner struct {
exec *agent.Agent
scripts []func(input string)
}
func (r *scriptedRunner) Run(_ context.Context, input string) error {
if len(r.scripts) == 0 {
return nil
}
next := r.scripts[0]
r.scripts = r.scripts[1:]
next(input)
return nil
}
func TestApprovedPlanAutoApproveEndsWithExecutionTurn(t *testing.T) {
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
runner := &scriptedRunner{exec: exec}
var c *Controller
approvalPrompts := 0
sink := event.FuncSink(func(e event.Event) {
if e.Kind != event.ApprovalRequest {
return
}
approvalPrompts++
if e.Approval.Tool == planApprovalTool {
go c.Approve(e.Approval.ID, true, false, false)
return
}
go c.Approve(e.Approval.ID, false, false, false)
})
c = newOwnedTestController(t, Options{Runner: runner, Executor: exec, Sink: sink})
c.SetPlanMode(true)
runner.scripts = append(runner.scripts,
func(input string) {
exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "1. Create the file\n2. Update the file"})
},
func(input string) {
if input != planApprovedMessage {
t.Fatalf("approved execution input = %q, want planApprovedMessage", input)
}
exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "first step done; paused for review", ToolCalls: []provider.ToolCall{{
ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"Create the file","status":"completed"},{"content":"Update the file","status":"in_progress"}]}`,
}}})
},
)
if err := c.runTurn(context.Background(), "plan this"); err != nil {
t.Fatal(err)
}
if approvalPrompts != 1 {
t.Fatalf("approval prompts after plan = %d, want 1", approvalPrompts)
}
// The plan approval auto-approves writers for the execution turn only. A later
// turn does not inherit it, and "继续" carries no special meaning — Compose must
// not inject any marker, and the next writer falls back to per-tool approval.
if got := c.Compose("继续"); StripComposePrefixes(got) != "继续" {
t.Fatalf("a paused approved plan must not marker-prefix the next turn, got %q", got)
}
allow, _, err := gateApprover{c}.Approve(context.Background(), "write_file", "/tmp/a", nil)
if err != nil {
t.Fatal(err)
}
if allow {
t.Fatal("writer after the execution turn should return to per-tool approval, not auto-allow")
}
if approvalPrompts != 2 {
t.Fatalf("writer after the execution turn should prompt, prompts=%d", approvalPrompts)
}
}
func TestApprovedPlanDoesNotAutoApproveNonContinuationTurn(t *testing.T) {
exec := agent.New(nil, nil, agent.NewSession("sys"), agent.Options{}, event.Discard)
runner := &scriptedRunner{exec: exec}
var c *Controller
approvalPrompts := 0
sink := event.FuncSink(func(e event.Event) {
if e.Kind != event.ApprovalRequest {
return
}
approvalPrompts++
if e.Approval.Tool == planApprovalTool {
go c.Approve(e.Approval.ID, true, false, false)
return
}
go c.Approve(e.Approval.ID, false, false, false)
})
c = newOwnedTestController(t, Options{Runner: runner, Executor: exec, Sink: sink})
c.SetPlanMode(true)
runner.scripts = append(runner.scripts,
func(input string) {
exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "1. Create the file\n2. Update the file"})
},
func(input string) {
exec.Session().Add(provider.Message{Role: provider.RoleAssistant, Content: "paused", ToolCalls: []provider.ToolCall{{
ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"Create the file","status":"completed"},{"content":"Update the file","status":"in_progress"}]}`,
}}})
},
)
if err := c.runTurn(context.Background(), "plan this"); err != nil {
t.Fatal(err)
}
if got := c.Compose("先别继续"); StripComposePrefixes(got) != "先别继续" {
t.Fatalf("non-continuation input should not be marker-prefixed, got %q", got)
}
allow, _, err := gateApprover{c}.Approve(context.Background(), "write_file", "/tmp/a", nil)
if err != nil {
t.Fatal(err)
}
if allow {
t.Fatal("non-continuation turn should not inherit approved-plan auto approval")
}
if approvalPrompts != 2 {
t.Fatalf("non-continuation writer should prompt after plan approval, prompts=%d", approvalPrompts)
}
}
// writeCmdFile creates a command .md file with frontmatter under dir.
func writeCmdFile(t *testing.T, dir, name, description, body string) {
t.Helper()
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
content := fmt.Sprintf("---\ndescription: %s\n---\n%s\n", description, body)
if err := os.WriteFile(filepath.Join(dir, name+".md"), []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// TestCommandsAtomicPointer verifies that commands passed via Options.Commands are
// correctly exposed through the atomic-pointer Commands() getter and that
// CustomCommand resolves and renders them. Missing commands return found=false.
func TestCommandsAtomicPointer(t *testing.T) {
cmds := []command.Command{
{Name: "review", Description: "Review code", Body: "Review $1"},
{Name: "test", Description: "Run tests", Body: "Test $1"},
}
c := newOwnedTestController(t, Options{
Commands: cmds,
Sink: &typedNilControllerSink{},
Registry: tool.NewRegistry(),
})
// Commands() returns what was passed via Options
got := c.Commands()
if len(got) != 2 {
t.Fatalf("Commands() = %d, want 2", len(got))
}
// Check retrieval via CustomCommand
sent, ok := c.CustomCommand("/review myfile.go")
if !ok {
t.Error("/review should be found")
}
if !strings.Contains(sent, "Review myfile.go") {
t.Errorf("unexpected render: %q", sent)
}
_, ok = c.CustomCommand("/missing")
if ok {
t.Error("/missing should not be found")
}
// Commands() getter uses atomic.Pointer internally
if cmds2 := c.Commands(); len(cmds2) != 2 {
t.Errorf("Commands() = %d after change, want 2", len(cmds2))
}
}
// TestReloadCommandsFromFilesystem exercises ReloadCommands against real .md
// files in a temp workspace: initial load, hot-reload with a new file, and
// hot-reload after modifying an existing file. Also verifies that skills are
// preserved across the reload.
func TestReloadCommandsFromFilesystem(t *testing.T) {
// Isolate HOME so CommandDirsForRoot does not pick up global .md command files.
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
wsRoot := t.TempDir()
cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
writeCmdFile(t, cmdDir, "review", "Review code", "Review $1")
writeCmdFile(t, cmdDir, "test", "Run tests", "Test $1")
// Create a minimal in-memory skill to verify skills are preserved across reload.
sk := skill.Skill{
Name: "myskill",
Description: "Test skill",
Body: "You are a test skill. User says: {{.Input}}",
}
reg := tool.NewRegistry()
c := newOwnedTestController(t, Options{
Sink: &typedNilControllerSink{},
Registry: reg,
WorkspaceRoot: wsRoot,
Skills: []skill.Skill{sk},
})
// ReloadCommands should pick up the two .md files and preserve the skill.
if err := c.ReloadCommands(context.Background()); err != nil {
t.Fatalf("ReloadCommands: %v", err)
}
cmds := c.Commands()
if len(cmds) != 2 {
t.Fatalf("Commands() = %d after reload, want 2", len(cmds))
}
// CustomCommand should resolve through the hot-swapped getter
sent, ok := c.CustomCommand("/review hello.go")
if !ok {
t.Fatal("/review should be found after reload")
}
if !strings.Contains(sent, "Review hello.go") {
t.Errorf("render = %q, want Review hello.go", sent)
}
// Missing command still not found
if _, ok := c.CustomCommand("/nope"); ok {
t.Error("/nope should not be found")
}
// Skill should appear in the slash_command tool's description after reload.
if tool, found := reg.Get("slash_command"); found {
if !strings.Contains(tool.Description(), "myskill") {
t.Error("skill 'myskill' should appear in slash_command tool Description after ReloadCommands")
}
} else {
t.Error("slash_command tool should be registered after ReloadCommands")
}
// Skill should still be callable via RunSkill after reload.
if _, ok := c.RunSkill("/myskill"); !ok {
t.Error("RunSkill(/myskill) should find the skill after ReloadCommands")
}
// Hot-reload: add a new command file
writeCmdFile(t, cmdDir, "count", "Count to N", "Count from 1 to $1")
if err := c.ReloadCommands(context.Background()); err != nil {
t.Fatalf("ReloadCommands (add): %v", err)
}
if cmds := c.Commands(); len(cmds) != 3 {
t.Fatalf("Commands() = %d after add, want 3", len(cmds))
}
// Hot-reload: modify an existing command
writeCmdFile(t, cmdDir, "review", "Review code (friendly)", "Kindly review $1")
if err := c.ReloadCommands(context.Background()); err != nil {
t.Fatalf("ReloadCommands (modify): %v", err)
}
if cmds := c.Commands(); len(cmds) != 3 {
t.Fatalf("Commands() = %d after modify, want 3", len(cmds))
}
sent, ok = c.CustomCommand("/review world.go")
if !ok {
t.Fatal("/review should be found after modify")
}
if !strings.Contains(sent, "Kindly review world.go") {
t.Errorf("render after modify = %q, want Kindly review world.go", sent)
}
// The slash_command tool should be registered and updated
if _, found := reg.Get("slash_command"); !found {
t.Error("slash_command tool should be registered after ReloadCommands")
}
}
// TestReloadCommandsDeleteFile verifies that removing a command .md file and
// reloading causes the command to disappear from both Commands() and
// CustomCommand(), while other commands remain intact.
func TestReloadCommandsDeleteFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
wsRoot := t.TempDir()
cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
writeCmdFile(t, cmdDir, "alpha", "Alpha cmd", "Alpha $1")
writeCmdFile(t, cmdDir, "beta", "Beta cmd", "Beta $1")
reg := tool.NewRegistry()
c := newOwnedTestController(t, Options{
Sink: &typedNilControllerSink{},
Registry: reg,
WorkspaceRoot: wsRoot,
})
if err := c.ReloadCommands(context.Background()); err != nil {
t.Fatalf("initial reload: %v", err)
}
if got := len(c.Commands()); got != 2 {
t.Fatalf("Commands() = %d, want 2", got)
}
// Delete alpha.md
if err := os.Remove(filepath.Join(cmdDir, "alpha.md")); err != nil {
t.Fatal(err)
}
if err := c.ReloadCommands(context.Background()); err != nil {
t.Fatalf("reload after delete: %v", err)
}
if got := len(c.Commands()); got != 1 {
t.Fatalf("Commands() = %d after delete, want 1", got)
}
// /alpha should no longer be found
if _, ok := c.CustomCommand("/alpha x"); ok {
t.Error("/alpha should NOT be found after deletion")
}
// /beta should still work
sent, ok := c.CustomCommand("/beta y")
if !ok {
t.Error("/beta should still be found")
}
if !strings.Contains(sent, "Beta y") {
t.Errorf("render = %q, want Beta y", sent)
}
}
// TestReloadCommandsMalformedFile verifies that a malformed .md file causes
// ReloadCommands to return an error but does not prevent other valid commands
// from loading.
func TestReloadCommandsMalformedFile(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
wsRoot := t.TempDir()
cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
writeCmdFile(t, cmdDir, "good", "Good cmd", "Good $1")
// Write a malformed file (no valid frontmatter)
if err := os.MkdirAll(cmdDir, 0o755); err != nil {
t.Fatal(err)
}
broken := filepath.Join(cmdDir, "broken.md")
if err := os.WriteFile(broken, []byte("this is not valid yaml\n---\nrandom\n"), 0o644); err != nil {
t.Fatal(err)
}
reg := tool.NewRegistry()
c := newOwnedTestController(t, Options{
Sink: &typedNilControllerSink{},
Registry: reg,
WorkspaceRoot: wsRoot,
})
err := c.ReloadCommands(context.Background())
// We expect some error from the malformed file
if err == nil {
t.Log("ReloadCommands returned nil error despite malformed file — command.Load may tolerate it")
} else {
t.Logf("ReloadCommands returned error (expected): %v", err)
}
// The valid command should still be loadable
cmds := c.Commands()
foundGood := false
for _, cmd := range cmds {
if cmd.Name == "good" {
foundGood = true
}
}
if !foundGood {
t.Errorf("valid command 'good' should be present, got commands: %v", cmdNames(cmds))
}
}
// TestReloadCommandsSameNameAcrossDirs verifies that when the same command
// name exists in multiple convention directories, the later-scanned directory
// (higher priority) wins. ConventionDirs = [".reasonix", ".agents", ".agent",
// ".claude"], scanned in reverse, so .reasonix is highest priority.
func TestReloadCommandsSameNameAcrossDirs(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
wsRoot := t.TempDir()
// Lower priority: .claude/commands
claudeDir := filepath.Join(wsRoot, ".claude", "commands")
writeCmdFile(t, claudeDir, "greet", "Claude greet", "Hello from Claude: $1")
// Higher priority: .reasonix/commands
reasonixDir := filepath.Join(wsRoot, ".reasonix", "commands")
writeCmdFile(t, reasonixDir, "greet", "Reasonix greet", "Hello from Reasonix: $1")
reg := tool.NewRegistry()
c := newOwnedTestController(t, Options{
Sink: &typedNilControllerSink{},
Registry: reg,
WorkspaceRoot: wsRoot,
})
if err := c.ReloadCommands(context.Background()); err != nil {
t.Fatalf("reload: %v", err)
}
// There should be exactly 1 command named "greet"
cmds := c.Commands()
count := 0
for _, cmd := range cmds {
if cmd.Name == "greet" {
count++
}
}
if count != 1 {
t.Fatalf("expected exactly 1 'greet' command, got %d", count)
}
// The winning version should be from .reasonix (highest priority)
sent, ok := c.CustomCommand("/greet world")
if !ok {
t.Fatal("/greet should be found")
}
if !strings.Contains(sent, "Hello from Reasonix") {
t.Errorf("expected .reasonix version to win, got render: %q", sent)
}
}
func TestReloadCommandsUsesCanonicalPluginNameAlongsideProjectShortName(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
reasonixHome := filepath.Join(home, ".reasonix")
t.Setenv("REASONIX_HOME", reasonixHome)
pluginRoot := filepath.Join(reasonixHome, "plugins", "pwf")
if err := os.MkdirAll(filepath.Join(pluginRoot, ".claude-plugin"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(pluginRoot, pluginpkg.ClaudeManifest), []byte(`{"name":"pwf"}`), 0o644); err != nil {
t.Fatal(err)
}
writeCmdFile(t, filepath.Join(pluginRoot, "commands"), "plan", "Plugin plan", "PLUGIN $1")
writeCmdFile(t, filepath.Join(pluginRoot, "commands"), "status", "Plugin status", "STATUS $1")
if err := pluginpkg.Upsert(reasonixHome, pluginpkg.InstalledPlugin{Name: "pwf", Root: "plugins/pwf", ManifestKind: "claude", Enabled: true}); err != nil {
t.Fatal(err)
}
workspace := t.TempDir()
writeCmdFile(t, filepath.Join(workspace, ".reasonix", "commands"), "plan", "Project plan", "PROJECT $1")
c := newOwnedTestController(t, Options{Sink: &typedNilControllerSink{}, Registry: tool.NewRegistry(), WorkspaceRoot: workspace})
if err := c.ReloadCommands(context.Background()); err != nil {
t.Fatalf("ReloadCommands: %v", err)
}
if got, ok := c.CustomCommand("/plan task"); !ok || got != "PROJECT task" {
t.Fatalf("short command = %q, %v; want project winner", got, ok)
}
if got, ok := c.CustomCommand("/pwf:plan task"); !ok || got != "PLUGIN task" {
t.Fatalf("qualified plugin command = %q, %v", got, ok)
}
if got, ok := c.CustomCommand("/status now"); !ok || got != "STATUS now" {
t.Fatalf("hidden compatible short command = %q, %v", got, ok)
}
if got, ok := c.CustomCommand("/pwf:status now"); !ok || got != "STATUS now" {
t.Fatalf("canonical plugin status command = %q, %v", got, ok)
}
cmds := c.Commands()
canonicalFound := false
hiddenFound := false
for _, cmd := range cmds {
if cmd.Name == "pwf:plan" && cmd.Plugin == "pwf" && cmd.ShortName == "plan" && !cmd.Hidden {
canonicalFound = true
}
if cmd.Name == "status" && cmd.Plugin == "pwf" && cmd.ShortName == "status" && cmd.Hidden {
hiddenFound = true
}
}
if !canonicalFound || !hiddenFound {
t.Fatalf("plugin command metadata missing: %+v", cmds)
}
}
// TestReloadCommandsEmptySet verifies that deleting all command files and
// reloading results in an empty Commands() slice, while the slash_command tool
// still exists in the Registry (containing only Skills).
func TestReloadCommandsEmptySet(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
wsRoot := t.TempDir()
cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
writeCmdFile(t, cmdDir, "temp", "Temp cmd", "Temp $1")
sk := skill.Skill{
Name: "preserved",
Description: "A skill to keep",
Body: "Skill body: {{.Input}}",
}
reg := tool.NewRegistry()
c := newOwnedTestController(t, Options{
Sink: &typedNilControllerSink{},
Registry: reg,
WorkspaceRoot: wsRoot,
Skills: []skill.Skill{sk},
})
if err := c.ReloadCommands(context.Background()); err != nil {
t.Fatalf("initial reload: %v", err)
}
if got := len(c.Commands()); got != 1 {
t.Fatalf("Commands() = %d, want 1", got)
}
// Delete all command files
if err := os.Remove(filepath.Join(cmdDir, "temp.md")); err != nil {
t.Fatal(err)
}
if err := c.ReloadCommands(context.Background()); err != nil {
t.Fatalf("reload after delete all: %v", err)
}
if got := len(c.Commands()); got != 0 {
t.Fatalf("Commands() = %d after delete all, want 0", got)
}
// /temp should no longer be found
if _, ok := c.CustomCommand("/temp x"); ok {
t.Error("/temp should NOT be found after deletion")
}
// slash_command tool should still exist (for Skills)
slashTool, found := reg.Get("slash_command")
if !found {
t.Fatal("slash_command tool should still exist even with 0 commands")
}
// It should still contain the skill
if !strings.Contains(slashTool.Description(), "preserved") {
t.Error("skill 'preserved' should still appear in slash_command tool Description")
}
}
// TestReloadCommandsDesktopManagementNotice verifies the desktop/HTTP path:
// when the frontend submits "/reload-cmd" as raw input, Submit → managementNotice
// handles it and emits a Notice event with the correct count.
func TestReloadCommandsDesktopManagementNotice(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("USERPROFILE", home)
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
t.Setenv("AppData", filepath.Join(home, "AppData"))
wsRoot := t.TempDir()
cmdDir := filepath.Join(wsRoot, ".reasonix", "commands")
writeCmdFile(t, cmdDir, "hello", "Greet", "Hello $1")
writeCmdFile(t, cmdDir, "review", "Review code", "Review $1")
var notices []string
sink := event.FuncSink(func(e event.Event) {
if e.Kind == event.Notice {
notices = append(notices, e.Text)
}
})
reg := tool.NewRegistry()
c := newOwnedTestController(t, Options{
Sink: sink,
Registry: reg,
WorkspaceRoot: wsRoot,
})
// Initial load so Commands() is populated.
if err := c.ReloadCommands(context.Background()); err != nil {
t.Fatalf("initial reload: %v", err)
}
notices = nil // reset
// Desktop path: managementNotice("/reload-cmd") should emit a notice.
handled := c.managementNotice("/reload-cmd")
if !handled {
t.Fatal("managementNotice(/reload-cmd) should return true")
}
if len(notices) != 1 {
t.Fatalf("expected 1 notice, got %d: %v", len(notices), notices)
}
if !strings.Contains(notices[0], "commands reloaded") {
t.Errorf("notice = %q, want 'commands reloaded'", notices[0])
}
if !strings.Contains(notices[0], "2 available") {
t.Errorf("notice = %q, want '2 available'", notices[0])
}
// Delete one command file and reload again.
if err := os.Remove(filepath.Join(cmdDir, "hello.md")); err != nil {
t.Fatal(err)
}
notices = nil
handled = c.managementNotice("/reload-cmd")
if !handled {
t.Fatal("managementNotice(/reload-cmd) after delete should return true")
}
if len(notices) != 1 {
t.Fatalf("expected 1 notice after delete, got %d: %v", len(notices), notices)
}
if !strings.Contains(notices[0], "1 available") {
t.Errorf("notice after delete = %q, want '1 available'", notices[0])
}
// Delete all and verify empty-set notice.
if err := os.Remove(filepath.Join(cmdDir, "review.md")); err != nil {
t.Fatal(err)
}
notices = nil
handled = c.managementNotice("/reload-cmd")
if !handled {
t.Fatal("managementNotice(/reload-cmd) empty set should return true")
}
if len(notices) != 1 {
t.Fatalf("expected 1 notice for empty set, got %d: %v", len(notices), notices)
}
if !strings.Contains(notices[0], "0 available") {
t.Errorf("notice for empty set = %q, want '0 available'", notices[0])
}
}
// cmdNames is a test helper that extracts command names from a slice.
func cmdNames(cmds []command.Command) []string {
names := make([]string, len(cmds))
for i, c := range cmds {
names[i] = c.Name
}
return names
}
// TestCacheColdAfterFailureFallsBackTo24h:配置加载失败/模型解析失败时
// 保守回退 24h(评审 #7168 第 4 点)——不得用 10m 提前触发 prune。
func TestCacheColdAfterFailureFallsBackTo24h(t *testing.T) {
c := newOwnedTestController(t, Options{})
orig := c.workspaceRoot
c.workspaceRoot = "/nonexistent/definitely-missing-root"
defer func() { c.workspaceRoot = orig }()
if got := c.cacheColdAfter(); got != 24*time.Hour {
t.Fatalf("load failure must fall back to 24h, got %v", got)
}
// 未知模型同样 24h
c2 := newOwnedTestController(t, Options{})
c2.selection.ref = "definitely-not-a-real-model-xyz"
if got := c2.cacheColdAfter(); got != 24*time.Hour {
t.Fatalf("ResolveModel failure must fall back to 24h, got %v", got)
}
}