mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-11 17:10:38 -10:00
5361 lines
192 KiB
Go
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 := ¬iceSink{}
|
|
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 := ¬iceSink{}
|
|
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 := ¬iceSink{}
|
|
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 := ¬iceSink{}
|
|
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 := ¬iceSink{}
|
|
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)
|
|
}
|
|
}
|