Files
2026-07-13 13:00:08 +08:00

694 lines
24 KiB
Go

package control
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"reasonix/internal/agent"
"reasonix/internal/event"
"reasonix/internal/evidence"
"reasonix/internal/hook"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
func TestTurnOrchestratorRunsForegroundUnit(t *testing.T) {
runner := &fakeTurnRunner{}
c := New(Options{Runner: runner})
c.SetPlanMode(true)
o := newTurnOrchestrator(c)
if err := o.runTurnWithRawDisplay(context.Background(), "draft the plan", "draft the plan", ""); err != nil {
t.Fatal(err)
}
if len(runner.inputs) != 1 {
t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
}
if !strings.HasPrefix(runner.inputs[0], PlanModeMarker) {
t.Fatalf("orchestrator should compose plan marker before running, got %q", runner.inputs[0])
}
}
func TestTurnOrchestratorSkipsMemoryCompilerForSyntheticTurns(t *testing.T) {
// A genuine user turn supplies a Memory v5 source and is not skipped; a
// synthetic controller-injected turn (the goal-loop continuation) is marked
// to bypass compilation so its contract can't be re-injected and loop
// (#5342, #5329).
runner := &fakeTurnRunner{}
c := New(Options{Runner: runner})
o := newTurnOrchestrator(c)
real := "fix the login bug"
if err := o.runTurnWithRawDisplay(context.Background(), real, real, ""); err != nil {
t.Fatal(err)
}
if err := o.runTurnWithRawDisplay(context.Background(), goalContinueTurn, goalContinueTurn, ""); err != nil {
t.Fatal(err)
}
if len(runner.memoryCompilerSkips) != 2 {
t.Fatalf("runs = %d, want 2", len(runner.memoryCompilerSkips))
}
if runner.memoryCompilerSkips[0] {
t.Fatalf("genuine user turn was marked skip-compile")
}
if !runner.memoryCompilerSkips[1] {
t.Fatalf("synthetic goal-continuation turn was NOT marked skip-compile")
}
// The genuine turn supplies a source; the synthetic one must not.
if len(runner.memoryCompilerInputs) != 1 || runner.memoryCompilerInputs[0] != real {
t.Fatalf("memory compiler sources = %v, want exactly [%q]", runner.memoryCompilerInputs, real)
}
}
func TestTurnOrchestratorTypedSyntheticTurnDoesNotDependOnPrefix(t *testing.T) {
runner := &fakeTurnRunner{}
c := New(Options{AutoPlan: "on", Runner: runner})
o := newTurnOrchestrator(c)
turn := "Controller-created follow-up with a brand-new synthetic wording:\n- inspect\n- edit\n- verify"
if IsSyntheticUserMessage(turn) {
t.Fatalf("test setup: %q unexpectedly matched the legacy synthetic prefix list", turn)
}
if err := o.runSyntheticTurnWithRawDisplay(context.Background(), turn, turn, ""); err != nil {
t.Fatal(err)
}
if len(runner.inputs) != 1 {
t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
}
if strings.HasPrefix(runner.inputs[0], PlanModeMarker) {
t.Fatalf("typed synthetic turn should not be auto-planned, got %q", runner.inputs[0])
}
if len(runner.memoryCompilerSkips) != 1 || !runner.memoryCompilerSkips[0] {
t.Fatalf("typed synthetic turn was not marked skip-compile: %+v", runner.memoryCompilerSkips)
}
if len(runner.memoryCompilerInputs) != 0 {
t.Fatalf("typed synthetic turn supplied Memory v5 source input: %+v", runner.memoryCompilerInputs)
}
}
func TestTurnOrchestratorLegacySyntheticPrefixStillSkipsMemoryCompiler(t *testing.T) {
runner := &fakeTurnRunner{}
c := New(Options{Runner: runner})
o := newTurnOrchestrator(c)
if err := o.runTurnWithRawDisplay(context.Background(), goalContinueTurn, goalContinueTurn, ""); err != nil {
t.Fatal(err)
}
if len(runner.memoryCompilerSkips) != 1 || !runner.memoryCompilerSkips[0] {
t.Fatalf("legacy synthetic prefix was not marked skip-compile: %+v", runner.memoryCompilerSkips)
}
if len(runner.memoryCompilerInputs) != 0 {
t.Fatalf("legacy synthetic prefix supplied Memory v5 source input: %+v", runner.memoryCompilerInputs)
}
}
func TestTurnOrchestratorStopHookIgnoresCanceledTurnContext(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 := New(Options{
Runner: cancelingRunner{cancel: cancel},
Hooks: hooks,
})
o := newTurnOrchestrator(c)
if err := o.runTurnWithRawDisplay(runCtx, "hello", "hello", ""); err != nil {
t.Fatal(err)
}
if runCtx.Err() != context.Canceled {
t.Fatalf("turn 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)
}
}
type recordingSessionRunner struct {
session *agent.Session
inputs []string
memoryCompilerInputs []string
}
func (r *recordingSessionRunner) Run(ctx context.Context, input string) error {
r.inputs = append(r.inputs, input)
if source, ok := agent.MemoryCompilerSourceInputFromContext(ctx); ok {
r.memoryCompilerInputs = append(r.memoryCompilerInputs, source)
}
r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
return nil
}
func TestTurnOrchestratorGoalContinuationRunsStopPerUnit(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Started.\n\n[goal:continue]"),
textTurn("Finished.\n\n[goal:complete]"),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
var stopEvents int
hooks := hook.NewRunner([]hook.ResolvedHook{{
HookConfig: hook.HookConfig{Command: "record-stop"},
Event: hook.Stop,
Scope: hook.ScopeProject,
}}, "", func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
var p hook.Payload
if err := json.Unmarshal([]byte(in.Stdin), &p); err != nil {
t.Fatalf("hook payload: %v", err)
}
if p.Event == hook.Stop {
stopEvents++
}
return hook.SpawnResult{ExitCode: 0}
}, nil)
c := New(Options{Runner: ag, Executor: ag, Hooks: hooks})
c.SetGoal("ship the refactor")
o := newTurnOrchestrator(c)
if err := o.runGoalLoopWithRawDisplay(context.Background(), "Start pursuing the active goal now.", "ship the refactor", ""); err != nil {
t.Fatal(err)
}
if prov.call != 2 {
t.Fatalf("provider calls = %d, want initial + continuation", prov.call)
}
if stopEvents != 2 {
t.Fatalf("Stop hook events = %d, want one per goal-loop turn unit", stopEvents)
}
}
func TestTurnOrchestratorApprovedPlanSharesOneStopHook(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{
textTurn("Plan:\n1. Make the change\n2. Verify it"),
textTurn("Done."),
}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
approvalID := make(chan string, 1)
var promptSubmitEvents, stopEvents int
hooks := hook.NewRunner([]hook.ResolvedHook{
{
HookConfig: hook.HookConfig{Command: "record-submit"},
Event: hook.UserPromptSubmit,
Scope: hook.ScopeProject,
},
{
HookConfig: hook.HookConfig{Command: "record-stop"},
Event: hook.Stop,
Scope: hook.ScopeProject,
},
}, "", func(_ context.Context, in hook.SpawnInput) hook.SpawnResult {
var p hook.Payload
if err := json.Unmarshal([]byte(in.Stdin), &p); err != nil {
t.Fatalf("hook payload: %v", err)
}
switch p.Event {
case hook.UserPromptSubmit:
promptSubmitEvents++
case hook.Stop:
stopEvents++
}
return hook.SpawnResult{ExitCode: 0}
}, nil)
c := New(Options{
Runner: ag,
Executor: ag,
Hooks: hooks,
Sink: event.FuncSink(func(e event.Event) {
if e.Kind == event.ApprovalRequest {
approvalID <- e.Approval.ID
}
}),
})
c.SetPlanMode(true)
go func() { c.Approve(<-approvalID, true, false, false) }()
o := newTurnOrchestrator(c)
if err := o.runTurnWithRawDisplay(context.Background(), "plan this change", "plan this change", ""); err != nil {
t.Fatal(err)
}
if prov.call != 2 {
t.Fatalf("provider calls = %d, want plan + approved execution", prov.call)
}
if promptSubmitEvents != 1 {
t.Fatalf("UserPromptSubmit events = %d, want one for plan + approved execution unit", promptSubmitEvents)
}
if stopEvents != 1 {
t.Fatalf("Stop hook events = %d, want one for plan + approved execution unit", stopEvents)
}
}
func TestTurnOrchestratorRefTurnRecordsVisibleDisplay(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("referenced evidence"), 0o644); err != nil {
t.Fatal(err)
}
sess := agent.NewSession("sys")
exec := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
runner := &recordingSessionRunner{session: sess}
events := make(chan event.Event, 4)
c := New(Options{
WorkspaceRoot: root,
Runner: runner,
Executor: exec,
Sink: event.FuncSink(func(e event.Event) {
events <- e
}),
})
var gotContent, gotDisplay string
c.SetDisplayRecorder(func(content, display string) {
gotContent = content
gotDisplay = display
})
const visible = "explain @notes.txt"
c.runRefTurn(visible, visible)
waitForTurnDone(t, events)
if len(runner.inputs) != 1 {
t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
}
if !strings.Contains(runner.inputs[0], "Referenced context:") || !strings.Contains(runner.inputs[0], "referenced evidence") {
t.Fatalf("model input should include resolved reference context, got %q", runner.inputs[0])
}
if len(runner.memoryCompilerInputs) != 1 || runner.memoryCompilerInputs[0] != visible {
t.Fatalf("memory compiler source input = %+v, want %q", runner.memoryCompilerInputs, visible)
}
if gotDisplay != visible {
t.Fatalf("display recorder display = %q, want visible prompt %q", gotDisplay, visible)
}
if gotContent != runner.inputs[0] {
t.Fatalf("display recorder content = %q, want persisted model input %q", gotContent, runner.inputs[0])
}
}
func TestTurnOrchestratorAutoReasoningLanguageUsesRawPromptForRefTurns(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "auth.go"), []byte("package main\nfunc AuthHandler() error { return errors.New(\"not authorized\") }\n"), 0o644); err != nil {
t.Fatal(err)
}
runner := &fakeTurnRunner{}
events := make(chan event.Event, 4)
c := New(Options{
WorkspaceRoot: root,
Runner: runner,
AutoPlan: "off",
Sink: event.FuncSink(func(e event.Event) {
events <- e
}),
})
const visible = "解释 @auth.go 的报错"
c.runRefTurn(visible, visible)
waitForTurnDone(t, events)
if len(runner.inputs) != 1 {
t.Fatalf("runner inputs = %d, want 1", len(runner.inputs))
}
got := runner.inputs[0]
if !strings.HasPrefix(got, "<reasoning-language>") || !strings.Contains(got, "简体中文") {
t.Fatalf("auto reasoning language should anchor Chinese before referenced context, got %q", got)
}
if !strings.Contains(got, "Referenced context:") || !strings.Contains(got, "AuthHandler") {
t.Fatalf("ref context missing from model input: %q", got)
}
if strings.Contains(got, "use English") {
t.Fatalf("English referenced file content should not win over raw Chinese prompt:\n%s", got)
}
}
func TestTurnOrchestratorCheckpointBoundaryPrecedesUserMessage(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)
runner := &recordingSessionRunner{session: sess}
c := New(Options{
Runner: runner,
Executor: exec,
SessionDir: dir,
SessionPath: path,
Label: "test",
})
o := newTurnOrchestrator(c)
if err := o.runTurnWithRawDisplay(context.Background(), "write the test", "write the test", ""); err != nil {
t.Fatal(err)
}
if !c.CheckpointHasBoundary(0) {
t.Fatal("checkpoint boundary should be available for the orchestrated turn")
}
if len(sess.Messages) != 2 || sess.Messages[1].Content != "write the test" {
t.Fatalf("session messages after turn = %+v, want system + user", sess.Messages)
}
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 branch meta ok=%v err=%v", ok, err)
}
if meta.UpdatedAt.IsZero() {
t.Fatal("activity meta should be marked after transcript changes")
}
if err := c.Rewind(0, RewindConversation); err != nil {
t.Fatal(err)
}
if len(sess.Messages) != 1 {
t.Fatalf("session messages after rewind = %d, want boundary before user message", len(sess.Messages))
}
}
func TestTurnOrchestratorSyntheticTurnDoesNotCreateCheckpoint(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)
runner := &recordingSessionRunner{session: sess}
c := New(Options{
Runner: runner,
Executor: exec,
SessionDir: dir,
SessionPath: path,
Label: "test",
})
o := newTurnOrchestrator(c)
if err := o.runTurnWithRawDisplay(context.Background(), "real prompt", "real prompt", ""); err != nil {
t.Fatal(err)
}
if err := o.runSyntheticTurnWithRawDisplay(context.Background(), "hidden follow-up", "hidden follow-up", ""); err != nil {
t.Fatal(err)
}
cps := c.Checkpoints()
if len(cps) != 1 {
t.Fatalf("checkpoints = %+v, want exactly the visible user turn", cps)
}
if cps[0].Turn != 0 || cps[0].Prompt != "real prompt" {
t.Fatalf("checkpoint = %+v, want turn 0 real prompt", cps[0])
}
turns := c.CheckpointTurnsByMessageIndex()
if len(turns) != 1 || turns[1] != 0 {
t.Fatalf("checkpoint turns by message index = %v, want {1:0}", turns)
}
}
func TestTurnOrchestratorStopHookCancelledContext(t *testing.T) {
prov := &scriptedTurns{turns: [][]provider.Chunk{textTurn("done")}}
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
var stopCalls int
hooks := hook.NewRunner([]hook.ResolvedHook{{
HookConfig: hook.HookConfig{Command: "stop"},
Event: hook.Stop,
Scope: hook.ScopeProject,
}}, "", func(ctx context.Context, in hook.SpawnInput) hook.SpawnResult {
if ctx.Err() != nil {
t.Errorf("Stop hook spawner ctx.Err()=%v; want nil", ctx.Err())
}
var p hook.Payload
json.Unmarshal([]byte(in.Stdin), &p)
if p.Event == hook.Stop {
stopCalls++
}
return hook.SpawnResult{ExitCode: 0}
}, nil)
c := New(Options{Runner: ag, Executor: ag, Hooks: hooks})
ctx, cancel := context.WithCancel(context.Background())
cancel()
o := newTurnOrchestrator(c)
if err := o.runTurnWithRawDisplay(ctx, "test", "test", ""); err != nil && err != context.Canceled {
t.Fatal(err)
}
if stopCalls != 1 {
t.Fatalf("Stop hooks called = %d; want 1", stopCalls)
}
}
// TestTurnOrchestratorCancelPreservesVisibleUserPrompt verifies that when the
// user explicitly cancels a visible turn (Ctrl+C), the real user prompt remains
// in the session while incomplete assistant/tool remnants are stripped. Without
// this, the next user message can lose the just-submitted context (#5499); if
// the remnants remain, the model can re-execute interrupted work (#5286).
func TestTurnOrchestratorCancelPreservesVisibleUserPrompt(t *testing.T) {
sess := agent.NewSession("you are a helpful agent")
// Pre-populate with a few messages from an earlier turn.
sess.Add(provider.Message{Role: provider.RoleUser, Content: "previous work"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
preCount := len(sess.Messages)
// runner that simulates a cancelled turn: it adds the user message plus
// some tool-call garbage the real agent would leave behind, then returns
// context.Canceled.
runner := &cancelStrippingRunner{
session: sess,
add: []provider.Message{
{Role: provider.RoleAssistant, Content: "let me do that", ToolCalls: []provider.ToolCall{
{ID: "c1", Name: "todo_write", Arguments: `{"todos":[{"content":"add abc","status":"in_progress"}]}`},
}},
{Role: provider.RoleTool, Content: "Todos updated: 1 total — 0 completed, 1 in_progress, 0 pending.", ToolCallID: "c1", Name: "todo_write"},
},
err: context.Canceled,
}
ex := agent.New(nil, nil, sess, agent.Options{}, event.Discard)
c := New(Options{Runner: runner, Executor: ex})
// Simulate a user-initiated cancel: set the cancelling flag.
c.mu.Lock()
c.canceling = true
c.mu.Unlock()
// Pre-seed todoState as if a successful todo_write from the cancelled turn
// had already updated it — this is the state the runner leaves behind before
// returning context.Canceled, and what RebuildTodoState must clear.
ex.ReplaceTodoState([]evidence.TodoItem{{Content: "add abc", Status: "in_progress"}})
o := newTurnOrchestrator(c)
err := o.runTurnWithRawDisplay(context.Background(), "add config file abc", "add config file abc", "")
if !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
// The visible user prompt must stay, while assistant/tool remnants from the
// cancelled turn must be stripped.
msgs := sess.Messages
if len(msgs) != preCount+1 {
t.Fatalf("session messages after cancel = %d, want pre-turn + user prompt %d: %+v", len(msgs), preCount+1, msgs)
}
last := msgs[len(msgs)-1]
if last.Role != provider.RoleUser || last.Content != "add config file abc" {
t.Fatalf("last message after cancel = %+v, want preserved visible user prompt", last)
}
for _, m := range msgs[preCount+1:] {
if m.Role == provider.RoleAssistant || m.Role == provider.RoleTool {
t.Fatalf("cancelled turn remnant survived: %+v", m)
}
}
// todoState must also be reset: the in_progress todo written by the
// cancelled turn must not survive the strip.
if todos := c.Todos(); len(todos) != 0 {
t.Fatalf("Todos() after cancel = %v, want empty — cancelled todo_write leaked into canonical state", todos)
}
}
// TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk verifies that after a
// user-cancel strip the cleaned transcript is written to disk, so a restart or
// session resume does not reload the partial turn from a stale mid-turn
// autosave. See #5286.
func TestTurnOrchestratorCancelFlushesCleanTranscriptToDisk(t *testing.T) {
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "earlier turn"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
// Count only non-system messages; the system prompt is not written to the
// .jsonl by Session.Save (it is reconstructed from the session options).
wantNonSystem := 0
for _, m := range sess.Messages {
if m.Role != provider.RoleSystem {
wantNonSystem++
}
}
wantNonSystem++ // the cancelled visible turn's user prompt is preserved
runner := &cancelStrippingRunner{
session: sess,
add: []provider.Message{
{Role: provider.RoleAssistant, Content: "working…", ToolCalls: []provider.ToolCall{
{ID: "d1", Name: "todo_write", Arguments: `{"todos":[{"content":"task","status":"in_progress"}]}`},
}},
{Role: provider.RoleTool, Content: "Todos updated.", ToolCallID: "d1", Name: "todo_write"},
},
err: context.Canceled,
}
sessionPath := agent.NewSessionPath(t.TempDir(), "test-model")
c := New(Options{
Runner: runner,
Executor: agent.New(nil, nil, sess, agent.Options{}, event.Discard),
SessionPath: sessionPath,
})
c.mu.Lock()
c.canceling = true
c.mu.Unlock()
o := newTurnOrchestrator(c)
if err := o.runTurnWithRawDisplay(context.Background(), "do something", "do something", ""); !errors.Is(err, context.Canceled) {
t.Fatalf("expected context.Canceled, got %v", err)
}
// Load the session file written after the strip and verify it contains the
// pre-cancel messages plus the visible user prompt — not the partial
// assistant/tool messages.
loaded, err := agent.LoadSession(sessionPath)
if err != nil {
t.Fatalf("LoadSession: %v", err)
}
nonSystem := 0
var last provider.Message
for _, m := range loaded.Messages {
if m.Role != provider.RoleSystem {
nonSystem++
last = m
}
}
if nonSystem != wantNonSystem {
t.Fatalf("on-disk message count (non-system) = %d, want %d — stale partial turn still on disk", nonSystem, wantNonSystem)
}
if last.Role != provider.RoleUser || last.Content != "do something" {
t.Fatalf("last on-disk message = %+v, want preserved visible user prompt", last)
}
}
func TestResumeClearsStaleVisibleInFlightTurn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "stale-visible.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "previous work"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "done"})
start := len(sess.Messages)
sess.Add(provider.Message{Role: provider.RoleUser, Content: "continue work"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "working", ToolCalls: []provider.ToolCall{
{ID: "todo-1", Name: "todo_write", Arguments: `{"todos":[{"content":"continue work","status":"in_progress"}]}`},
}})
sess.Add(provider.Message{Role: provider.RoleTool, Content: "Todos updated.", ToolCallID: "todo-1", Name: "todo_write"})
if err := sess.Save(path); err != nil {
t.Fatal(err)
}
if err := agent.MarkSessionInFlightTurn(path, start, true); err != nil {
t.Fatal(err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path})
c.Resume(loaded, path)
msgs := exec.Session().Snapshot()
if len(msgs) != start+1 {
t.Fatalf("resumed messages = %d, want pre-turn + user prompt %d: %+v", len(msgs), start+1, msgs)
}
last := msgs[len(msgs)-1]
if last.Role != provider.RoleUser || last.Content != "continue work" {
t.Fatalf("last resumed message = %+v, want preserved visible user prompt", last)
}
if todos := c.Todos(); len(todos) != 0 {
t.Fatalf("Todos() after stale in-flight recovery = %+v, want empty", todos)
}
reloaded, err := agent.LoadSession(path)
if err != nil {
t.Fatal(err)
}
if len(reloaded.Messages) != start+1 {
t.Fatalf("persisted messages = %d, want cleaned count %d: %+v", len(reloaded.Messages), start+1, reloaded.Messages)
}
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 in-flight marker survived resume: %+v", meta.InFlightTurn)
}
}
func TestResumeClearsStaleSyntheticInFlightTurn(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "stale-synthetic.jsonl")
sess := agent.NewSession("system")
sess.Add(provider.Message{Role: provider.RoleUser, Content: "ship it"})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "Started.\n\n[goal:continue]"})
start := len(sess.Messages)
sess.Add(provider.Message{Role: provider.RoleUser, Content: goalContinueTurn})
sess.Add(provider.Message{Role: provider.RoleAssistant, Content: "hidden continuation partial"})
if err := sess.Save(path); err != nil {
t.Fatal(err)
}
if err := agent.MarkSessionInFlightTurn(path, start, false); err != nil {
t.Fatal(err)
}
loaded, err := agent.LoadSession(path)
if err != nil {
t.Fatal(err)
}
exec := agent.New(nil, nil, agent.NewSession("system"), agent.Options{}, event.Discard)
c := New(Options{Executor: exec, SessionDir: dir, SessionPath: path})
c.Resume(loaded, path)
msgs := exec.Session().Snapshot()
if len(msgs) != start {
t.Fatalf("resumed messages = %d, want synthetic turn stripped to %d: %+v", len(msgs), start, msgs)
}
if last := msgs[len(msgs)-1]; last.Role != provider.RoleAssistant || !strings.Contains(last.Content, "[goal:continue]") {
t.Fatalf("last resumed message = %+v, want completed visible turn preserved", last)
}
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 in-flight marker survived resume: %+v", meta.InFlightTurn)
}
}
// cancelStrippingRunner adds messages to a session then returns a fixed error,
// simulating an agent that was interrupted mid-turn.
type cancelStrippingRunner struct {
session *agent.Session
add []provider.Message
err error
}
func (r *cancelStrippingRunner) Run(ctx context.Context, input string) error {
r.session.Add(provider.Message{Role: provider.RoleUser, Content: input})
for _, m := range r.add {
r.session.Add(m)
}
return r.err
}