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

1633 lines
67 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"reasonix/internal/event"
"strings"
"testing"
"reasonix/internal/provider"
"reasonix/internal/tool"
)
// mockProvider replays preset chunks and records the last request it received.
type mockProvider struct {
name string
chunks []provider.Chunk
streams [][]provider.Chunk
lastReq provider.Request
requests []provider.Request
}
func (m *mockProvider) Name() string { return m.name }
func (m *mockProvider) Stream(ctx context.Context, req provider.Request) (<-chan provider.Chunk, error) {
m.lastReq = req
call := len(m.requests)
m.requests = append(m.requests, req)
chunks := m.chunks
if len(m.streams) > 0 {
if call >= len(m.streams) {
call = len(m.streams) - 1
}
chunks = m.streams[call]
}
ch := make(chan provider.Chunk, len(chunks))
for _, c := range chunks {
ch <- c
}
close(ch)
return ch, nil
}
func lastUser(req provider.Request) string {
for i := len(req.Messages) - 1; i >= 0; i-- {
if req.Messages[i].Role == provider.RoleUser {
return req.Messages[i].Content
}
}
return ""
}
// TestCoordinatorHandsPlanToExecutor checks the two-session handoff: the planner
// sees the raw task in its own session, and the executor receives the plan.
func TestCoordinatorHandsPlanToExecutor(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "1. read main.go\n2. fix the loop"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession("planner-sys")
coord := NewCoordinator(planner, plannerSess, nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := lastUser(planner.lastReq); !strings.Contains(got, "fix the bug") {
t.Errorf("planner saw user %q, want it to contain the task", got)
}
if got := lastUser(exec.requests[0]); !strings.Contains(got, "read main.go") || !strings.Contains(got, "fix the bug") || !strings.Contains(got, "You are the executor now") {
t.Errorf("executor saw user %q, want task + plan", got)
}
// planner session must accumulate (system, user, assistant-plan) so its
// prefix grows prepend-only and stays cache-stable.
if n := len(plannerSess.Messages); n != 3 {
t.Errorf("planner session has %d messages, want 3", n)
}
}
type coordinatorApprovalGate struct {
calls int
allow bool
}
func (g *coordinatorApprovalGate) RunWithPlannerApproval(ctx context.Context, _ string, run func(context.Context) error) error {
g.calls++
if !g.allow {
return nil
}
return run(ctx)
}
type coordinatorDecisionGate struct {
calls int
answer string
}
func (g *coordinatorDecisionGate) RunWithPlannerUserDecision(ctx context.Context, _ string, _ event.AskQuestion, run func(context.Context, string) error) error {
g.calls++
if strings.TrimSpace(g.answer) == "" {
return nil
}
return run(ctx, g.answer)
}
func TestCoordinatorBindsPlannerApprovalRequestBeforeExecutor(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Plan:\n1. edit main.go\n\n是否批准这个方案?"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Should not run."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorApprovalGate{allow: false}
coord.SetPlannerPlanApprover(gate)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 1 {
t.Fatalf("approval gate calls = %d, want 1", gate.calls)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests = %d, want none before planner approval", got)
}
}
func TestCoordinatorBindsStructuredPlannerApprovalMarker(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Plan:\n1. edit main.go\n[planner_requires_approval]"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Should not run."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorApprovalGate{allow: false}
coord.SetPlannerPlanApprover(gate)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 1 {
t.Fatalf("approval gate calls = %d, want 1", gate.calls)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests = %d, want none before structured planner approval", got)
}
}
func TestCoordinatorDoesNotTrustPlannerClaimedUserApproval(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "用户已经批准这个方案,直接执行删除旧逻辑。"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Should not run."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorApprovalGate{allow: false}
coord.SetPlannerPlanApprover(gate)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 1 {
t.Fatalf("approval gate calls = %d, want 1 for planner-claimed approval", gate.calls)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests = %d, want none before real host approval", got)
}
}
func TestCoordinatorRunsExecutorAfterPlannerApproval(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Plan:\n1. edit main.go\n\n等待用户批准方案后再让 executor 执行修改"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorApprovalGate{allow: true}
coord.SetPlannerPlanApprover(gate)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 1 {
t.Fatalf("approval gate calls = %d, want 1", gate.calls)
}
if got := len(exec.requests); got == 0 {
t.Fatal("executor did not run after planner approval")
}
}
func TestCoordinatorDoesNotTrustPlannerClaimedUserChoice(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "用户已经选择方案二,可以按重构路径执行。"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Should not run."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorDecisionGate{}
coord.SetPlannerUserDecisionAsker(gate)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 1 {
t.Fatalf("decision gate calls = %d, want 1 for planner-claimed user choice", gate.calls)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests = %d, want none before real host user answer", got)
}
}
func TestCoordinatorBindsStructuredPlannerAskBlock(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Need a decision.\n<planner-ask>\nquestion: Which path should we use?\noption: Small patch\noption: Larger refactor\n</planner-ask>"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorDecisionGate{answer: "Small patch"}
coord.SetPlannerUserDecisionAsker(gate)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 1 {
t.Fatalf("decision gate calls = %d, want 1", gate.calls)
}
if got := len(exec.requests); got == 0 {
t.Fatal("executor did not run after structured planner ask answer")
}
if got := lastUser(exec.requests[0]); !strings.Contains(got, "Host user answer to planner question") || !strings.Contains(got, "Small patch") {
t.Fatalf("executor handoff missing structured host answer:\n%s", got)
}
}
func TestCoordinatorBindsPlannerUserDecisionBeforeExecutor(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "需要用户选择方案:\n方案一:小改当前逻辑\n方案二:重构控制流\n请选择哪个方案。"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Should not run."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorDecisionGate{}
coord.SetPlannerUserDecisionAsker(gate)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 1 {
t.Fatalf("decision gate calls = %d, want 1", gate.calls)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests = %d, want none before user decision", got)
}
}
func TestCoordinatorDoesNotAskForOrdinaryPlanVerificationWording(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Plan:\n1. 确认文件存在\n2. 修改 main.go\n3. 运行测试"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorDecisionGate{answer: "should not be used"}
coord.SetPlannerUserDecisionAsker(gate)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 0 {
t.Fatalf("decision gate calls = %d, want no AskRequest for ordinary verification wording", gate.calls)
}
if got := len(exec.requests); got == 0 {
t.Fatal("executor should run for ordinary plan wording")
}
}
func TestCoordinatorPassesHostUserDecisionToExecutor(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "需要用户选择方案:\n方案一:小改当前逻辑\n方案二:重构控制流\n请选择哪个方案。"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorDecisionGate{answer: "方案二:重构控制流"}
coord.SetPlannerUserDecisionAsker(gate)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 1 {
t.Fatalf("decision gate calls = %d, want 1", gate.calls)
}
if got := len(exec.requests); got == 0 {
t.Fatal("executor did not run after user decision")
}
if got := lastUser(exec.requests[0]); !strings.Contains(got, "Host user answer to planner question") || !strings.Contains(got, "方案二") {
t.Fatalf("executor handoff missing host user answer:\n%s", got)
}
}
// TestHandoffTaskRecoversOriginalInput guards the dual-model auto-title path
// (#3860): previews must surface the user's words, not handoff boilerplate.
func TestHandoffTaskRecoversOriginalInput(t *testing.T) {
if got := HandoffTask(formatHandoff("修复登录页的 bug", "1. read login.go")); got != "修复登录页的 bug" {
t.Errorf("HandoffTask(handoff) = %q, want the original task", got)
}
multi := "fix the bug\n\nsteps:\n- a\n- b"
if got := HandoffTask(formatHandoff(multi, "plan")); got != multi {
t.Errorf("HandoffTask(multi-line) = %q, want %q", got, multi)
}
for _, plain := range []string{"ordinary input", "", "# Reasonix executor handoff with no sections"} {
if got := HandoffTask(plain); got != plain {
t.Errorf("HandoffTask(%q) = %q, want unchanged", plain, got)
}
}
}
// TestCoordinatorSkipsPlannerForTrivialTurn checks the gate: when shouldPlan
// rejects the turn, the planner is never called and the executor gets the raw
// input (no plan handoff).
func TestCoordinatorSkipsPlannerForTrivialTurn(t *testing.T) {
planner := &mockProvider{name: "planner"}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "It does X."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession("planner-sys")
coord := NewCoordinator(planner, plannerSess, nil, nil, Options{}, executor, 0, event.Discard, func(context.Context, string) bool { return false })
if err := coord.Run(context.Background(), "what does this function do?"); err != nil {
t.Fatalf("Run: %v", err)
}
if planner.lastReq.Messages != nil {
t.Error("planner should not be called for a skipped turn")
}
if got := lastUser(exec.lastReq); got != "what does this function do?" {
t.Errorf("executor saw %q, want the raw input with no plan handoff", got)
}
if n := len(plannerSess.Messages); n != 1 { // just the system message
t.Errorf("planner session has %d messages, want 1 (untouched)", n)
}
}
type coordinatorTestTool struct {
name string
readOnly bool
output string
}
func (t coordinatorTestTool) Name() string { return t.name }
func (t coordinatorTestTool) Description() string { return t.name + " test tool" }
func (t coordinatorTestTool) Schema() json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{"path":{"type":"string"}}}`)
}
func (t coordinatorTestTool) Execute(context.Context, json.RawMessage) (string, error) {
return t.output, nil
}
func (t coordinatorTestTool) ReadOnly() bool { return t.readOnly }
func TestCoordinatorPlannerUsesReadOnlyResearchTools(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"REASONIX.md"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "1. follow the loaded rule\n2. edit the narrow file"},
{Type: provider.ChunkDone},
},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "Rule: keep changes narrow."})
parentReg.Add(coordinatorTestTool{name: "write_file", readOnly: false})
parentReg.Add(coordinatorTestTool{name: "todo_write", readOnly: true})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession(PlannerPromptWithContext("Rule: keep changes narrow."))
coord := NewCoordinator(planner, plannerSess, nil, PlannerToolRegistry(parentReg), Options{MaxSteps: 4}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if len(planner.requests) < 2 {
t.Fatalf("planner made %d provider request(s), want a tool round and a final plan", len(planner.requests))
}
tools := toolSchemaNames(planner.requests[0].Tools)
if !contains(tools, "read_file") {
t.Fatalf("planner tools = %v, want read_file", tools)
}
for _, forbidden := range []string{"write_file", "todo_write"} {
if contains(tools, forbidden) {
t.Fatalf("planner tools = %v, must not include %s", tools, forbidden)
}
}
if got := lastUser(exec.requests[0]); !strings.Contains(got, "follow the loaded rule") || !strings.Contains(got, "fix the bug") {
t.Errorf("executor saw user %q, want task + planner plan", got)
}
if got := plannerSess.Messages[0].Content; !strings.Contains(got, "Rule: keep changes narrow.") {
t.Errorf("planner system prompt missing planning context: %q", got)
}
}
func TestCoordinatorSetReasoningLanguageClearsPlannerAgent(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "1. inspect the narrow path"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{ReasoningLanguage: "zh"}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, tool.NewRegistry(), Options{ReasoningLanguage: "zh"}, executor, 0, event.Discard, nil)
coord.SetReasoningLanguage("auto")
if err := coord.Run(context.Background(), "plan a change"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := lastUser(planner.requests[0]); strings.Contains(got, "<reasoning-language>") {
t.Fatalf("planner should clear stale reasoning language after live auto update, got %q", got)
}
if got := lastUser(exec.requests[0]); strings.Contains(got, "<reasoning-language>") {
t.Fatalf("executor should clear stale reasoning language after live auto update, got %q", got)
}
}
func TestCoordinatorPlannerMaxStepsUsesPlannerConfigKey(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"REASONIX.md"}`}},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "keep reading"})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, PlannerToolRegistry(parentReg), Options{
MaxSteps: 2,
MaxStepsKey: "agent.planner_max_steps",
}, executor, 0, event.Discard, nil)
err := coord.Run(context.Background(), "plan a change")
if err == nil {
t.Fatal("Run should pause when the planner reaches its configured step limit")
}
msg := err.Error()
if !strings.Contains(msg, "planner: paused after 2 tool-call rounds (agent.planner_max_steps)") {
t.Fatalf("pause error = %q, want planner_max_steps message", msg)
}
if strings.Contains(msg, "agent.max_steps") {
t.Fatalf("planner pause should not point at agent.max_steps: %q", msg)
}
if got := len(planner.requests); got != 3 {
t.Fatalf("planner requests = %d, want exactly the configured 2 rounds", got)
}
if len(exec.requests) != 0 {
t.Fatal("executor should not run when planner pauses before producing a plan")
}
}
func TestCoordinatorPlannerMaxStepsZeroIsUnlimited(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"a"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-2", Name: "read_file", Arguments: `{"path":"b"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "1. use both files"},
{Type: provider.ChunkDone},
},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
parentReg := tool.NewRegistry()
parentReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "ok"})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, PlannerToolRegistry(parentReg), Options{
MaxSteps: 0,
MaxStepsKey: "agent.planner_max_steps",
}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "plan a change"); err != nil {
t.Fatalf("Run with planner max steps 0 should not pause: %v", err)
}
if got := len(planner.requests); got != 3 {
t.Fatalf("planner requests = %d, want all 3 scripted planner turns", got)
}
if got := lastUser(exec.requests[0]); !strings.Contains(got, "use both files") {
t.Fatalf("executor did not receive planner output: %q", got)
}
}
func TestCoordinatorNudgesExecutorThatAnswersWithoutActing(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Write the requested skill file."},
{Type: provider.ChunkDone},
}}
// The first turn is a plain final answer with no tool call and no
// planner-vocabulary — the nudge must fire on the missing action, not on words.
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "这个计划看起来没问题,应该很好实现。"},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"kan-tu.md"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
},
}}
execReg := tool.NewRegistry()
execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "install the skill"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 3 {
t.Fatalf("executor requests = %d, want answer-without-acting, nudge tool call, final answer", got)
}
if got := lastUser(exec.requests[1]); !strings.Contains(got, "Use your available tools now to carry out the task") {
t.Fatalf("second executor request missing handoff nudge message: %q", got)
}
}
func TestExecutorHandoffRetryMessageKeepsUserChoicesInteractive(t *testing.T) {
msg := executorHandoffRetryMessage()
lower := strings.ToLower(msg)
for _, want := range []string{
"ask tool",
"wait for its tool result",
"do not ask in prose",
"do not claim the user answered",
} {
if !strings.Contains(lower, want) {
t.Fatalf("executorHandoffRetryMessage() missing %q:\n%s", want, msg)
}
}
}
func TestCoordinatorAllowsGuidanceOnlyExecutorHandoff(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Tell the user to open the audio app, enable the Peace checkbox, and play a song to compare the difference."},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "Open the audio app, enable the Peace checkbox, then play a familiar song and compare the sound with the switch on and off."},
{Type: provider.ChunkDone},
},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "I just installed EqualizerAPO, now what?"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 1 {
t.Fatalf("executor requests = %d, want one guidance-only final answer with no handoff nudge", got)
}
}
func TestCoordinatorAllowsGuidanceOnlyPlanWithExecutorToolContext(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Tell the user to open the audio app, enable the checkbox, and listen to compare the difference."},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "Open the app, enable the checkbox, then listen and compare."},
{Type: provider.ChunkDone},
},
}}
execReg := tool.NewRegistry()
execReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "file"})
execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "Please advise on the manual audio check."); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 1 {
t.Fatalf("executor requests = %d, want guidance final answer without nudge despite tool context", got)
}
}
func TestCoordinatorNudgesWorkTaskEvenIfPlannerMentionsUserGuidance(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Tell the user to edit main.go and add the missing branch."},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "Open main.go and add the missing branch in the handler."},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"main.go"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
},
}}
execReg := tool.NewRegistry()
execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 3 {
t.Fatalf("executor requests = %d, want text answer, nudge tool call, final answer", got)
}
if got := lastUser(exec.requests[1]); !strings.Contains(got, "Use your available tools now to carry out the task") {
t.Fatalf("second executor request missing handoff nudge message: %q", got)
}
}
func TestCoordinatorNudgesMixedGuidanceAndWorkTask(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Tell the user to summarize the behavior and update README."},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "Here is the current behavior summary."},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"README.md"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
},
}}
execReg := tool.NewRegistry()
execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "summarize the current behavior and update the README"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 3 {
t.Fatalf("executor requests = %d, want mixed guidance/work task to nudge before tool call", got)
}
if got := lastUser(exec.requests[1]); !strings.Contains(got, "Use your available tools now to carry out the task") {
t.Fatalf("second executor request missing handoff nudge message: %q", got)
}
}
func TestCoordinatorSkipsExecutorWhenPlannerConcludesNoChanges(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "No changes are needed; the current implementation already handles this.\n[no_changes]"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Should not run."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "check whether the fix is already present"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests = %d, want skip after no-op planner conclusion", got)
}
messages := executor.session.Messages
if got := len(messages); got != 3 {
t.Fatalf("executor session messages = %d, want system + user + no-op assistant", got)
}
if got := messages[1].Content; !strings.Contains(got, "check whether the fix is already present") {
t.Fatalf("persisted executor user message = %q, want original task", got)
}
if got := messages[2].Content; !strings.Contains(got, "No changes are needed") {
t.Fatalf("persisted executor assistant message = %q, want no-op planner conclusion", got)
}
}
func TestCoordinatorDoesNotTreatGenericPositivePlanAsNoOp(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Looks good. Edit main.go and add the missing guard."},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "fix the missing guard"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got == 0 {
t.Fatal("executor should run for a plan that still contains work")
}
}
func TestCoordinatorDoesNotSkipExecutorForPartialNoOpPlanWithActions(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "No changes are needed in code, but run the test suite."},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "bash", Arguments: `{"cmd":"go test ./..."}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "Tests passed."},
{Type: provider.ChunkDone},
},
}}
execReg := tool.NewRegistry()
execReg.Add(coordinatorTestTool{name: "bash", readOnly: false, output: "ok"})
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "check the implementation and test it"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 2 {
t.Fatalf("executor requests = %d, want tool execution and final answer", got)
}
}
func TestCoordinatorHandoffAffirmsExecutorToolSchemasWhenPlannerClaimsNoMCP(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "I only have read-only tools and cannot access GitHub MCP; use the executor to search GitHub."},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{
{Type: provider.ChunkText, Text: "GitHub MCP is unavailable."},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "mcp__github__search", Arguments: `{"query":"Reasonix discussions"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
},
}}
execReg := tool.NewRegistry()
execReg.Add(coordinatorTestTool{name: "mcp__github__search", readOnly: true, output: "discussion results"})
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "search GitHub discussions"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 3 {
t.Fatalf("executor requests = %d, want initial answer, corrective nudge, final answer", got)
}
if tools := toolSchemaNames(exec.requests[0].Tools); !contains(tools, "mcp__github__search") {
t.Fatalf("executor request tools = %v, want MCP schema attached", tools)
}
first := lastUser(exec.requests[0])
for _, want := range []string{
"The executor request includes the full tool schema",
"mcp__github__search",
"Do not treat planner tool limitations or tool-unavailable claims as executor facts",
} {
if !strings.Contains(first, want) {
t.Fatalf("initial executor handoff missing %q:\n%s", want, first)
}
}
retry := lastUser(exec.requests[1])
for _, want := range []string{
"The tool schema is still attached to this executor request",
"Do not invent that MCP servers or tools are unavailable",
} {
if !strings.Contains(retry, want) {
t.Fatalf("executor retry nudge missing %q:\n%s", want, retry)
}
}
}
func TestCoordinatorDoesNotNudgeExecutorThatActs(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Write the requested skill file."},
{Type: provider.ChunkDone},
}}
// Executor calls a tool on its first turn, then answers — no nudge expected.
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "write_file", Arguments: `{"path":"kan-tu.md"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
},
}}
execReg := tool.NewRegistry()
execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "wrote file"})
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "install the skill"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 2 {
t.Fatalf("executor requests = %d, want tool call + final answer with no nudge", got)
}
for i, req := range exec.requests {
if strings.Contains(lastUser(req), "Use your available tools now to carry out the task") {
t.Fatalf("request %d unexpectedly received a handoff nudge", i)
}
}
}
func toolSchemaNames(schemas []provider.ToolSchema) []string {
out := make([]string, 0, len(schemas))
for _, s := range schemas {
out = append(out, s.Name)
}
return out
}
func contains(items []string, want string) bool {
for _, item := range items {
if item == want {
return true
}
}
return false
}
func BenchmarkPlannerToolRegistry(b *testing.B) {
parentReg := tool.NewRegistry()
for i := 0; i < 200; i++ {
parentReg.Add(coordinatorTestTool{
name: fmt.Sprintf("tool_%03d", i),
readOnly: i%3 != 0,
})
}
parentReg.Add(coordinatorTestTool{name: "todo_write", readOnly: true})
parentReg.Add(coordinatorTestTool{name: "write_file", readOnly: false})
b.ReportAllocs()
for i := 0; i < b.N; i++ {
reg := PlannerToolRegistry(parentReg)
if reg.Len() == 0 {
b.Fatal("planner registry should retain read-only research tools")
}
}
}
func TestCoordinatorSetPlanModePropagates(t *testing.T) {
prov := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "plan"},
{Type: provider.ChunkDone},
}}
plannerSess := NewSession("planner-sys")
plannerReg := tool.NewRegistry()
plannerReg.Add(coordinatorTestTool{name: "read_file", readOnly: true})
plannerTools := PlannerToolRegistry(plannerReg)
exec := New(nil, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(prov, plannerSess, nil, plannerTools, Options{MaxSteps: 2}, exec, 0, event.Discard, nil)
// Both should start with planMode=false
if coord.plannerAgent.planMode.Load() {
t.Error("planner should start with planMode=false")
}
if coord.executor.planMode.Load() {
t.Error("executor should start with planMode=false")
}
// SetPlanMode(true) should propagate to both
coord.SetPlanMode(true)
if !coord.plannerAgent.planMode.Load() {
t.Error("planner should have planMode=true after SetPlanMode(true)")
}
if !coord.executor.planMode.Load() {
t.Error("executor should have planMode=true after SetPlanMode(true)")
}
// SetPlanMode(false) should propagate to both
coord.SetPlanMode(false)
if coord.plannerAgent.planMode.Load() {
t.Error("planner should have planMode=false after SetPlanMode(false)")
}
if coord.executor.planMode.Load() {
t.Error("executor should have planMode=false after SetPlanMode(false)")
}
}
func TestCoordinatorSetPlanModeNilSafety(t *testing.T) {
var c *Coordinator
c.SetPlanMode(true) // should not panic
c.SetPlanMode(false) // should not panic
}
// errorProvider fails every Stream call, standing in for a down/misconfigured
// planner provider.
type errorProvider struct{ name string }
func (e *errorProvider) Name() string { return e.name }
func (e *errorProvider) Stream(context.Context, provider.Request) (<-chan provider.Chunk, error) {
return nil, fmt.Errorf("provider unavailable")
}
// TestIsNoOpPlan pins the no-op conclusion contract: only a final non-empty
// line that is exactly the [no_changes] marker skips the executor. Phrase
// conclusions without the marker deliberately do not — a wrong skip silently
// drops the task, a missed one costs a single executor round.
func TestIsNoOpPlan(t *testing.T) {
cases := []struct {
name string
plan string
want bool
}{
{"empty", "", false},
{"conclusion phrase without marker", "No changes are needed; the current implementation already handles this.", false},
{"already implemented with follow-up work", "The auth flow is already implemented; extend it to cover refresh tokens.", false},
{"mid-plan aside is not a conclusion", "Findings:\nThis part is already handled by the retry helper.\nConfirm the desired direction with the user.", false},
{"explicit marker on final line", "The retry logic exists in client.go and the tests already run this path.\n[no_changes]", true},
{"marker with surrounding whitespace", "Notes on the guard.\n [no_changes] ", true},
{"marker mentioned before remaining work", "[no_changes] does not apply here.\nEdit main.go to add the missing guard.", false},
{"final line mentions marker in prose", "The guard exists but the tests are missing.\nDo not emit [no_changes] because work remains.", false},
{"marker with trailing prose on final line", "[no_changes] — but confirm the flag default first.", false},
{"negated conclusion", "It is not already implemented.", false},
{"no-op phrase with action verb", "No changes are needed in code, but run the test suite.", false},
{"chinese conclusion without marker", "无需改动,当前逻辑已经覆盖该场景。", false},
{"chinese follow-up work", "重试逻辑已经实现,但需要扩展覆盖刷新令牌。", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := isNoOpPlan(tc.plan); got != tc.want {
t.Errorf("isNoOpPlan(%q) = %v, want %v", tc.plan, got, tc.want)
}
})
}
}
// TestDefaultPlannerPromptRequestsNoChangesMarker keeps the producer and parser
// of the no-op contract in sync: isNoOpPlan trusts the marker because the
// planner prompt asks for it.
func TestDefaultPlannerPromptRequestsNoChangesMarker(t *testing.T) {
if !strings.Contains(DefaultPlannerPrompt, noChangesMarker) {
t.Fatalf("DefaultPlannerPrompt does not request the %s marker isNoOpPlan parses", noChangesMarker)
}
}
// TestCoordinatorDoesNotSkipExecutorForAlreadyImplementedPlanWithFollowUp is
// the motivating regression: a plan acknowledging existing code while asking
// for follow-up work must not be treated as a no-op conclusion.
func TestCoordinatorDoesNotSkipExecutorForAlreadyImplementedPlanWithFollowUp(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "The auth flow is already implemented; extend it to cover refresh tokens."},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "add refresh token support"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got == 0 {
t.Fatal("executor skipped: an already-implemented plan with follow-up work was treated as no-op")
}
if got := lastUser(exec.requests[0]); !strings.Contains(got, "extend it to cover refresh tokens") {
t.Fatalf("executor handoff missing the plan: %q", got)
}
}
// TestCoordinatorSkipsExecutorOnExplicitNoChangesMarker checks the marker
// contract end to end: research prose above the marker may mention runs/tests
// of existing code without vetoing the explicit conclusion.
func TestCoordinatorSkipsExecutorOnExplicitNoChangesMarker(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "The retry logic exists in client.go and the tests already run this path.\n[no_changes]"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Should not run."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "check whether retries are covered"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests = %d, want skip on explicit [no_changes] marker", got)
}
messages := executor.session.Messages
if got := len(messages); got != 3 {
t.Fatalf("executor session messages = %d, want system + user + no-op assistant", got)
}
if got := messages[2].Content; !strings.Contains(got, "[no_changes]") {
t.Fatalf("persisted executor assistant message = %q, want the planner conclusion", got)
}
}
// TestCoordinatorFallsBackToExecutorWhenPlannerFails checks that a planner
// failure degrades the turn to executor-only instead of failing it: the
// executor gets the raw input (no handoff boilerplate), a warning notice is
// emitted, and the planner session is rolled back so the next plan does not
// start with consecutive user messages.
func TestCoordinatorFallsBackToExecutorWhenPlannerFails(t *testing.T) {
cases := []struct {
name string
planner provider.Provider
}{
{"stream call fails", &errorProvider{name: "planner"}},
{"stream emits error chunk", &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkError, Err: fmt.Errorf("rate limited")},
}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
var events []event.Event
sink := event.FuncSink(func(e event.Event) { events = append(events, e) })
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession("planner-sys")
coord := NewCoordinator(tc.planner, plannerSess, nil, nil, Options{}, executor, 0, sink, nil)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run should fall back to the executor, got: %v", err)
}
if got := len(exec.requests); got != 1 {
t.Fatalf("executor requests = %d, want 1 fallback run", got)
}
got := lastUser(exec.requests[0])
if got != "fix the bug" || strings.Contains(got, "You are the executor now") {
t.Fatalf("fallback executor input = %q, want the raw task without handoff boilerplate", got)
}
if n := len(plannerSess.Messages); n != 1 {
t.Fatalf("planner session messages = %d, want rollback to system only", n)
}
var warned bool
for _, e := range events {
if e.Kind == event.Notice && e.Level == event.LevelWarn && strings.Contains(e.Text, "Planner failed") {
warned = true
}
}
if !warned {
t.Fatal("missing warn notice about the planner fallback")
}
})
}
}
// TestCoordinatorPropagatesPlannerErrorWhenTurnCancelled keeps cancellation
// semantics: a turn the user aborted must not silently restart on the executor.
func TestCoordinatorPropagatesPlannerErrorWhenTurnCancelled(t *testing.T) {
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Should not run."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(&errorProvider{name: "planner"}, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
ctx, cancel := context.WithCancel(context.Background())
cancel()
err := coord.Run(ctx, "fix the bug")
if err == nil || !strings.Contains(err.Error(), "planner:") {
t.Fatalf("Run = %v, want propagated planner error on cancelled turn", err)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests = %d, want none after user cancellation", got)
}
}
// TestCoordinatorRollsBackPlannerSessionOnToolPlannerFailure covers the
// production two-model wiring (boot passes PlannerToolRegistry, so planning
// runs through planWithTools): when the tool-enabled planner fails, the
// executor fallback must not leave the planner session with a dangling user
// message or partial tool rounds — the next plan would otherwise start with
// consecutive user roles, which some providers reject.
func TestCoordinatorRollsBackPlannerSessionOnToolPlannerFailure(t *testing.T) {
cases := []struct {
name string
planner provider.Provider
}{
{"stream call fails", &errorProvider{name: "planner"}},
{"fails after a tool round", &mockProvider{name: "planner", streams: [][]provider.Chunk{
{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}},
{Type: provider.ChunkDone},
},
{
{Type: provider.ChunkError, Err: fmt.Errorf("rate limited")},
},
}}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
plannerReg := tool.NewRegistry()
plannerReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "package main"})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession("planner-sys")
coord := NewCoordinator(tc.planner, plannerSess, nil, plannerReg, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run should fall back to the executor, got: %v", err)
}
if got := len(exec.requests); got != 1 {
t.Fatalf("executor requests = %d, want 1 fallback run", got)
}
if n := len(plannerSess.Messages); n != 1 {
t.Fatalf("planner session messages = %d, want rollback to system only", n)
}
})
}
}
// TestCoordinatorKeepsPlannerSessionOnMaxStepsPause pins the rollback
// exemption: a max-steps pause is control flow whose saved work the user is
// asked to continue from, so planWithTools must not roll the planner session
// back to the pre-turn snapshot.
func TestCoordinatorKeepsPlannerSessionOnMaxStepsPause(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor"}
plannerReg := tool.NewRegistry()
plannerReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "package main"})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession("planner-sys")
coord := NewCoordinator(planner, plannerSess, nil, plannerReg, Options{MaxSteps: 1}, executor, 0, event.Discard, nil)
err := coord.Run(context.Background(), "fix the bug")
if err == nil || !strings.Contains(err.Error(), "planner:") {
t.Fatalf("Run = %v, want the propagated max-steps pause", err)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests = %d, want none on a paused planner turn", got)
}
if n := len(plannerSess.Messages); n <= 1 {
t.Fatalf("planner session messages = %d, want the saved work preserved for continue", n)
}
}
// TestCoordinatorRunsExecutorWhenMarkerNotAlone is the F2 regression: a final
// line that mentions [no_changes] in prose is not the no-op conclusion, so the
// executor must still run.
func TestCoordinatorRunsExecutorWhenMarkerNotAlone(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "The guard exists but the tests are missing.\nDo not emit [no_changes] because work remains."},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "add the missing tests"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(exec.requests); got == 0 {
t.Fatal("executor skipped: a final line mentioning the marker in prose was treated as a no-op conclusion")
}
}
// TestCoordinatorHandoffSurvivesPlannerCompaction pins the plan-scan boundary
// against session rewrites: when the tool-enabled planner's final answer pushes
// usage past the compaction trigger, Agent.Run rewrites and shortens the
// planner session right after producing the plan. The pre-turn message count
// then no longer bounds "this turn's messages" — scanning from it must not
// hide the plan, or Coordinator.Run degrades to a raw executor turn despite a
// successful plan.
func TestCoordinatorHandoffSurvivesPlannerCompaction(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{ // the plan, with usage past the force-compaction watermark
{Type: provider.ChunkText, Text: "Edit main.go and add the missing guard."},
{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 1900, TotalTokens: 1950}},
{Type: provider.ChunkDone},
},
{ // the compaction summarizer call
{Type: provider.ChunkText, Text: "- goal: guard work\n- pending: none"},
{Type: provider.ChunkDone},
},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
plannerReg := tool.NewRegistry()
plannerReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "ok"})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession("planner-sys")
// Preset enough planner history that the fold shrinks the session to (or
// below) its pre-turn length, which is what strands a boundary based on
// the pre-turn message count.
filler := strings.Repeat("planner history filler. ", 150)
for i := 0; i < 3; i++ {
plannerSess.Add(provider.Message{Role: provider.RoleUser, Content: filler})
plannerSess.Add(provider.Message{Role: provider.RoleAssistant, Content: filler})
}
coord := NewCoordinator(planner, plannerSess, nil, plannerReg, Options{ContextWindow: 2000}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run: %v", err)
}
if plannerSess.RewriteVersion() == 0 {
t.Fatal("test setup: planner compaction did not fire, the rewrite boundary is not exercised")
}
if got := len(exec.requests); got == 0 {
t.Fatal("executor never ran")
}
got := lastUser(exec.requests[0])
if !strings.Contains(got, "Edit main.go and add the missing guard.") || !strings.Contains(got, executorHandoffMarker) {
t.Fatalf("executor input lost the plan handoff after planner compaction:\n%s", got)
}
}
// TestCoordinatorNoOpConclusionAttributedToPlanner pins the event source on the
// relayed no-op conclusion: it is planner text and must not be attributed to
// the executor by sinks that key styling/usage off Source.
func TestCoordinatorNoOpConclusionAttributedToPlanner(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "The guard already exists in parser.go.\n[no_changes]"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor"}
var events []event.Event
sink := event.FuncSink(func(e event.Event) { events = append(events, e) })
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, sink, nil)
if err := coord.Run(context.Background(), "check the parser guard"); err != nil {
t.Fatalf("Run: %v", err)
}
var conclusion *event.Event
for i := range events {
if events[i].Kind == event.Text && strings.Contains(events[i].Text, "[no_changes]") {
conclusion = &events[i]
}
}
if conclusion == nil {
t.Fatal("no-op conclusion text event not emitted")
}
if conclusion.Source != event.UsageSourcePlanner {
t.Fatalf("no-op conclusion Source = %q, want planner attribution", conclusion.Source)
}
}
// TestCoordinatorHandoffOmitsToolContextWithoutMCPTools checks that the handoff
// does not restate the built-in tool schema: the tool-context block exists to
// counter planner claims about MCP availability and is dropped entirely when
// the executor carries no MCP tools.
func TestCoordinatorHandoffOmitsToolContextWithoutMCPTools(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Edit main.go and add the missing guard."},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
execReg := tool.NewRegistry()
execReg.Add(coordinatorTestTool{name: "write_file", readOnly: false, output: "ok"})
executor := New(exec, execReg, NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "fix the missing guard"); err != nil {
t.Fatalf("Run: %v", err)
}
got := lastUser(exec.requests[0])
for _, unwanted := range []string{"Executor tool context", "Tool names include"} {
if strings.Contains(got, unwanted) {
t.Fatalf("handoff restates built-in tool schema (%q):\n%s", unwanted, got)
}
}
if !strings.Contains(got, "Edit main.go") {
t.Fatalf("handoff missing the plan: %q", got)
}
}
// TestCoordinatorPassesTurnContextToPlannerGate pins the C2 contract: the gate
// receives the live turn context, so a classifier-backed gate is cancelled
// with the turn instead of running out its own timeout.
func TestCoordinatorPassesTurnContextToPlannerGate(t *testing.T) {
type gateCtxKey struct{}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "It does X."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
var sawTurnValue bool
gate := func(ctx context.Context, _ string) bool {
sawTurnValue = ctx.Value(gateCtxKey{}) != nil
return false
}
coord := NewCoordinator(&mockProvider{name: "planner"}, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, gate)
ctx := context.WithValue(context.Background(), gateCtxKey{}, "turn")
if err := coord.Run(ctx, "what does this do?"); err != nil {
t.Fatalf("Run: %v", err)
}
if !sawTurnValue {
t.Fatal("planner gate did not receive the turn context")
}
}
// TestCoordinatorFailedTurnRollbackKeepsCompaction pins the rewrite-aware
// rollback economics: when auto-compaction fires mid-turn (after a tool round)
// and the planner THEN fails, restoring the pre-turn snapshot would revert the
// compaction — wasting its summarizer call and re-growing the prompt. The
// rollback must instead keep the compacted log and only drop trailing plain
// user messages, so the next plan still starts from the folded history without
// consecutive user roles.
func TestCoordinatorFailedTurnRollbackKeepsCompaction(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{ // tool round whose usage crosses the force-compaction watermark
{Type: provider.ChunkToolCall, ToolCall: &provider.ToolCall{ID: "call-1", Name: "read_file", Arguments: `{"path":"main.go"}`}},
{Type: provider.ChunkUsage, Usage: &provider.Usage{PromptTokens: 1900, TotalTokens: 1950}},
{Type: provider.ChunkDone},
},
{ // the compaction summarizer call
{Type: provider.ChunkText, Text: "- goal: guard work\n- pending: continue"},
{Type: provider.ChunkDone},
},
{ // the next planner round fails
{Type: provider.ChunkError, Err: fmt.Errorf("rate limited")},
},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
plannerReg := tool.NewRegistry()
plannerReg.Add(coordinatorTestTool{name: "read_file", readOnly: true, output: "package main"})
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession("planner-sys")
filler := strings.Repeat("planner history filler. ", 150)
for i := 0; i < 3; i++ {
plannerSess.Add(provider.Message{Role: provider.RoleUser, Content: filler})
plannerSess.Add(provider.Message{Role: provider.RoleAssistant, Content: filler})
}
coord := NewCoordinator(planner, plannerSess, nil, plannerReg, Options{ContextWindow: 2000}, executor, 0, event.Discard, nil)
if err := coord.Run(context.Background(), "fix the bug"); err != nil {
t.Fatalf("Run should fall back to the executor, got: %v", err)
}
if got := len(exec.requests); got != 1 {
t.Fatalf("executor requests = %d, want 1 fallback run", got)
}
if plannerSess.RewriteVersion() == 0 {
t.Fatal("test setup: planner compaction did not fire, the rewrite-aware rollback is not exercised")
}
msgs := plannerSess.Snapshot()
var hasSummary bool
for _, m := range msgs {
if isCompactionSummary(m) {
hasSummary = true
}
}
if !hasSummary {
t.Fatal("rollback reverted the compaction: no compaction summary left in the planner session")
}
if last := msgs[len(msgs)-1]; last.Role == provider.RoleUser && !isCompactionSummary(last) {
t.Fatalf("planner session ends in a plain user message after rollback: %q", last.Content)
}
}
// TestCoordinatorPersistsDeniedPlanTurnToExecutorSession pins the denial
// bookkeeping: a plan the user declines must still land in the executor
// session (like the no-op path) so the turn survives save/reload, with a note
// telling the next executor turn that nothing ran, plus a user-facing notice.
func TestCoordinatorPersistsDeniedPlanTurnToExecutorSession(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Plan: rewrite auth.\n[planner_requires_approval]"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "should not run"},
{Type: provider.ChunkDone},
}}
sink := &recordSink{}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, sink)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, sink, nil)
gate := &coordinatorApprovalGate{allow: false}
coord.SetPlannerPlanApprover(gate)
if err := coord.Run(context.Background(), "rewrite auth"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 1 {
t.Fatalf("approval gate calls = %d, want 1", gate.calls)
}
if len(exec.requests) != 0 {
t.Fatal("executor must not run when the plan is denied")
}
msgs := executor.session.Messages
if len(msgs) < 2 {
t.Fatalf("executor session messages = %d, want the denied turn persisted", len(msgs))
}
last := msgs[len(msgs)-1]
if last.Role != provider.RoleAssistant || !strings.Contains(last.Content, plannerPlanNotApprovedNote) {
t.Fatalf("last executor message = %q (%s), want plan with not-approved note", last.Content, last.Role)
}
prev := msgs[len(msgs)-2]
if prev.Role != provider.RoleUser || !strings.Contains(prev.Content, "rewrite auth") {
t.Fatalf("persisted user turn = %q (%s), want original input", prev.Content, prev.Role)
}
foundNotice := false
for _, e := range sink.kinds(event.Notice) {
if strings.Contains(e.Text, "not approved") {
foundNotice = true
}
}
if !foundNotice {
t.Fatal("denied plan should emit a user-facing notice")
}
}
// TestCoordinatorPersistsUnansweredDecisionTurnToExecutorSession is the same
// contract for the ask path: a cancelled/unanswered planner question must not
// erase the turn from the persisted executor session.
func TestCoordinatorPersistsUnansweredDecisionTurnToExecutorSession(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Plan draft.\n<planner-ask>\nquestion: Which database?\noption: sqlite\noption: postgres\n</planner-ask>"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "should not run"},
{Type: provider.ChunkDone},
}}
sink := &recordSink{}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, sink)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, sink, nil)
gate := &coordinatorDecisionGate{answer: ""}
coord.SetPlannerUserDecisionAsker(gate)
if err := coord.Run(context.Background(), "set up storage"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 1 {
t.Fatalf("decision gate calls = %d, want 1", gate.calls)
}
if len(exec.requests) != 0 {
t.Fatal("executor must not run without a user answer")
}
msgs := executor.session.Messages
if len(msgs) < 2 {
t.Fatalf("executor session messages = %d, want the unanswered turn persisted", len(msgs))
}
last := msgs[len(msgs)-1]
if last.Role != provider.RoleAssistant || !strings.Contains(last.Content, plannerDecisionUnansweredNote) {
t.Fatalf("last executor message = %q, want plan with unanswered-decision note", last.Content)
}
}
// TestCoordinatorSkipsApprovalGateForNegatedApprovalWording pins the negation
// veto: a plan that explicitly rules out an approval round must hand off
// directly instead of raising a needless approval prompt.
func TestCoordinatorSkipsApprovalGateForNegatedApprovalWording(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Plan:\n1. 修改 config.go\n2. 无需等待用户批准,直接执行修改"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorApprovalGate{allow: false}
coord.SetPlannerPlanApprover(gate)
if err := coord.Run(context.Background(), "tweak config"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 0 {
t.Fatalf("approval gate calls = %d, want 0 for negated approval wording", gate.calls)
}
if len(exec.requests) == 0 {
t.Fatal("executor should run directly for negated approval wording")
}
}
// TestCoordinatorDoesNotAskForTargetConfirmationWording pins the pruned
// decision phrases: ordinary verification wording such as "确认目标行为不变"
// must not conjure an ask dialog.
func TestCoordinatorDoesNotAskForTargetConfirmationWording(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Plan:\n1. 修改 handler.go\n2. 运行测试确认目标行为不变\n3. 更新用户选择器组件"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinator(planner, NewSession("planner-sys"), nil, nil, Options{}, executor, 0, event.Discard, nil)
gate := &coordinatorDecisionGate{answer: "should not be used"}
coord.SetPlannerUserDecisionAsker(gate)
if err := coord.Run(context.Background(), "refactor handler"); err != nil {
t.Fatalf("Run: %v", err)
}
if gate.calls != 0 {
t.Fatalf("decision gate calls = %d, want 0 for ordinary verification wording", gate.calls)
}
if len(exec.requests) == 0 {
t.Fatal("executor should run for ordinary plan wording")
}
}