Files
DeepSeek-Reasonix/internal/agent/coordinator_test.go
T
YHH 4fbbca6a99 refactor(go): adopt modern stdlib idioms and gate them (#7831)
* refactor(go): adopt modern stdlib idioms across kernel and desktop

Mechanical sweep with golangci-lint's modernize/intrange/copyloopvar/
usestdlibvars fixers plus hand-fixes for what they could not rewrite:
slices.Contains, min/max, slices.Backward, WaitGroup.Go, reflect.TypeFor,
maps.Copy, strings.Cut/CutPrefix/SplitSeq, strings.Builder, range-over-int.
Net -549 lines with no behavior change.

Three loops in session.go stay index-based: slices.Backward yields element
copies, so the fixer's rewrite silently dropped writes to DecisionReceipts
and ToolCalls (caught by TestDelivery* and TestDependentSameBatch*). All 49
slices.Backward sites were audited for that shape; only these three write
through the index, and they carry a nolint with the reason.

CaptureAfter's scan loop had an empty body and never ran any logic, so the
fixer could not express it. Drop it and the accessor it was the only caller
of. omitzero is deliberately untouched: turning omitempty into omitzero
changes the JSON written for nested structs, which is a wire-format change
to session files and the extension protocol.

* build(lint): gate the modern-idiom linters

Nothing stopped the codebase from drifting back to pre-1.22 idioms. Both
modules are now clean for modernize, intrange, copyloopvar, and
usestdlibvars, so enable them rather than leaving the sweep as a one-off.

omitzero stays disabled with the reason inline; usetesting, perfsprint, and
errorlint still have residue and land separately.
2026-08-07 15:30:33 +08:00

2045 lines
83 KiB
Go

package agent
import (
"context"
"encoding/json"
"fmt"
"reasonix/internal/event"
"slices"
"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 _, v := range slices.Backward(req.Messages) {
if v.Role == provider.RoleUser {
return v.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)
}
}
func TestCoordinatorStructuredPolicyUsesStableDepthMetadata(t *testing.T) {
planner := &mockProvider{name: "planner", streams: [][]provider.Chunk{
{{Type: provider.ChunkText, Text: "Light plan."}, {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "Full plan."}, {Type: provider.ChunkDone}},
}}
exec := &mockProvider{name: "executor", streams: [][]provider.Chunk{
{{Type: provider.ChunkText, Text: "Light done."}, {Type: provider.ChunkDone}},
{{Type: provider.ChunkText, Text: "Full done."}, {Type: provider.ChunkDone}},
}}
policy := func(_ context.Context, input string) PlannerDecision {
if strings.Contains(input, "light") {
return PlannerDecision{
Route: PlannerRoutePlanAndExecute, Depth: PlannerDepthLight,
Reason: "test_light", MaxResearchRounds: 2,
}
}
return PlannerDecision{
Route: PlannerRoutePlanAndExecute, Depth: PlannerDepthFull,
Reason: "test_full", MaxResearchRounds: 6,
}
}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinatorWithPlannerPolicy(
planner, NewSession("stable planner system"), nil, nil, Options{},
executor, 0, event.Discard, policy,
)
if err := coord.Run(context.Background(), "light task"); err != nil {
t.Fatalf("light Run: %v", err)
}
if err := coord.Run(context.Background(), "full task"); err != nil {
t.Fatalf("full Run: %v", err)
}
if got := lastUser(planner.requests[0]); !strings.Contains(got, "depth: light") || !strings.Contains(got, "route: plan_and_execute") {
t.Fatalf("light planner input missing route metadata: %q", got)
}
if got := lastUser(planner.requests[1]); !strings.Contains(got, "depth: full") || !strings.Contains(got, "route: plan_and_execute") {
t.Fatalf("full planner input missing route metadata: %q", got)
}
for i, req := range planner.requests {
if len(req.Messages) == 0 || req.Messages[0].Role != provider.RoleSystem || req.Messages[0].Content != "stable planner system" {
t.Fatalf("planner request %d changed stable system prefix: %+v", i, req.Messages)
}
}
var handoffs []string
for _, req := range exec.requests {
if got := lastUser(req); strings.Contains(got, executorHandoffMarker) {
handoffs = append(handoffs, got)
}
}
if len(handoffs) != 2 {
t.Fatalf("executor handoffs = %d, want one light and one full handoff", len(handoffs))
}
if !strings.Contains(handoffs[0], "Planning depth: light") {
t.Fatalf("light handoff missing depth: %q", handoffs[0])
}
if !strings.Contains(handoffs[1], "Planning depth: full") {
t.Fatalf("full handoff missing depth: %q", handoffs[1])
}
}
func TestCoordinatorPlanForApprovalDoesNotDependOnPlannerMarker(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "1. inspect auth\n2. migrate tokens"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "must not run"},
{Type: provider.ChunkDone},
}}
policy := func(context.Context, string) PlannerDecision {
return PlannerDecision{
Route: PlannerRoutePlanForApproval, Depth: PlannerDepthFull,
Reason: "user_plan_for_approval", MaxResearchRounds: 6,
}
}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinatorWithPlannerPolicy(
planner, NewSession("planner-sys"), nil, nil, Options{},
executor, 0, event.Discard, policy,
)
approval := &coordinatorApprovalGate{allow: false}
coord.SetPlannerPlanApprover(approval)
if err := coord.Run(context.Background(), "plan auth migration first"); err != nil {
t.Fatalf("Run: %v", err)
}
if approval.calls != 1 {
t.Fatalf("approval calls = %d, want 1 without planner marker", approval.calls)
}
if len(exec.requests) != 0 {
t.Fatal("executor ran before structured plan approval")
}
}
func TestCoordinatorPlanForApprovalHandsOffAfterApproval(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "1. inspect auth\n2. migrate tokens"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Done."},
{Type: provider.ChunkDone},
}}
policy := func(context.Context, string) PlannerDecision {
return PlannerDecision{Route: PlannerRoutePlanForApproval, Depth: PlannerDepthFull, Reason: "user_plan_for_approval"}
}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinatorWithPlannerPolicy(
planner, NewSession("planner-sys"), nil, nil, Options{},
executor, 0, event.Discard, policy,
)
approval := &coordinatorApprovalGate{allow: true}
coord.SetPlannerPlanApprover(approval)
if err := coord.Run(context.Background(), "plan auth migration, then wait for my approval"); err != nil {
t.Fatalf("Run: %v", err)
}
if approval.calls != 1 {
t.Fatalf("approval calls = %d, want 1", approval.calls)
}
if len(exec.requests) == 0 {
t.Fatal("executor did not run after approval")
}
if got := lastUser(exec.requests[0]); !strings.Contains(got, "migrate tokens") {
t.Fatalf("executor handoff = %q, want approved planner output", got)
}
}
func TestCoordinatorHeadlessPlanForApprovalPersistsForContinuation(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "1. inspect auth\n2. migrate tokens"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "must not run"},
{Type: provider.ChunkDone},
}}
policy := func(context.Context, string) PlannerDecision {
return PlannerDecision{Route: PlannerRoutePlanForApproval, Depth: PlannerDepthFull, Reason: "user_plan_for_approval"}
}
sink := &recordSink{}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, sink)
coord := NewCoordinatorWithPlannerPolicy(
planner, NewSession("planner-sys"), nil, nil, Options{},
executor, 0, sink, policy,
)
if err := coord.Run(context.Background(), "plan auth migration first"); err != nil {
t.Fatalf("Run: %v", err)
}
if len(exec.requests) != 0 {
t.Fatal("headless executor ran without a plan approval channel")
}
msgs := executor.Session().Messages
if len(msgs) < 2 || !strings.Contains(msgs[len(msgs)-1].Content, plannerPlanAwaitingApprovalNote) {
t.Fatalf("headless approval turn was not persisted for continuation: %+v", msgs)
}
}
func TestCoordinatorPlanOnlyDoesNotRunExecutor(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "1. inspect auth\n2. migrate tokens"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "must not run"},
{Type: provider.ChunkDone},
}}
policy := func(context.Context, string) PlannerDecision {
return PlannerDecision{Route: PlannerRoutePlanOnly, Depth: PlannerDepthFull, Reason: "user_plan_only"}
}
sink := &recordSink{}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, sink)
coord := NewCoordinatorWithPlannerPolicy(
planner, NewSession("planner-sys"), nil, nil, Options{},
executor, 0, sink, policy,
)
approval := &coordinatorApprovalGate{allow: true}
coord.SetPlannerPlanApprover(approval)
if err := coord.Run(context.Background(), "只规划认证迁移,不要执行"); err != nil {
t.Fatalf("Run: %v", err)
}
if approval.calls != 0 {
t.Fatalf("approval calls = %d, want 0 for an explicit no-execution request", approval.calls)
}
if len(exec.requests) != 0 {
t.Fatal("executor ran for an explicit plan-only request")
}
msgs := executor.Session().Messages
if len(msgs) < 2 || !strings.Contains(msgs[len(msgs)-1].Content, plannerPlanOnlyNote) {
t.Fatalf("plan-only turn was not persisted for a later user continuation: %+v", msgs)
}
}
func TestCoordinatorPlanOnlyContinuesWithExecutorOnNextTurn(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "1. inspect auth\n2. migrate tokens"},
{Type: provider.ChunkDone},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "Migration complete."},
{Type: provider.ChunkDone},
}}
policy := func(_ context.Context, input string) PlannerDecision {
if strings.Contains(input, "只规划") {
return PlannerDecision{Route: PlannerRoutePlanOnly, Depth: PlannerDepthFull, Reason: "user_plan_only"}
}
return PlannerDecision{Route: PlannerRouteExecutorOnly, Depth: PlannerDepthNone, Reason: "short_reply"}
}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinatorWithPlannerPolicy(
planner, NewSession("planner-sys"), nil, nil, Options{},
executor, 0, event.Discard, policy,
)
if err := coord.Run(context.Background(), "只规划认证迁移,不要执行"); err != nil {
t.Fatalf("plan-only Run: %v", err)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests after plan-only turn = %d, want none", got)
}
if err := coord.Run(context.Background(), "执行"); err != nil {
t.Fatalf("continuation Run: %v", err)
}
if got := len(exec.requests); got != 1 {
t.Fatalf("executor requests after continuation = %d, want one", got)
}
req := exec.requests[0]
if got := lastUser(req); !strings.Contains(got, "执行") {
t.Fatalf("executor continuation input = %q, want the user's execution request", got)
}
foundSavedPlan := false
for _, msg := range req.Messages {
if msg.Role == provider.RoleAssistant &&
strings.Contains(msg.Content, "migrate tokens") &&
strings.Contains(msg.Content, plannerPlanOnlyNote) {
foundSavedPlan = true
break
}
}
if !foundSavedPlan {
t.Fatalf("executor continuation did not receive the saved plan-only turn: %+v", req.Messages)
}
if got := len(planner.requests); got != 1 {
t.Fatalf("planner requests = %d, want only the original plan-only turn", got)
}
}
func TestCoordinatorPlannerFailurePreservesExecutionBoundary(t *testing.T) {
cases := []struct {
name string
route PlannerRoute
reason string
input string
}{
{
name: "plan only",
route: PlannerRoutePlanOnly,
reason: "user_plan_only",
input: "只规划认证迁移,不要执行",
},
{
name: "plan for approval",
route: PlannerRoutePlanForApproval,
reason: "user_plan_for_approval",
input: "先规划认证迁移,等我确认后再执行",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
planner := &mockProvider{name: "planner", chunks: []provider.Chunk{
{Type: provider.ChunkError, Err: fmt.Errorf("rate limited")},
}}
exec := &mockProvider{name: "executor", chunks: []provider.Chunk{
{Type: provider.ChunkText, Text: "must not run"},
{Type: provider.ChunkDone},
}}
policy := func(context.Context, string) PlannerDecision {
return PlannerDecision{Route: tc.route, Depth: PlannerDepthFull, Reason: tc.reason}
}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinatorWithPlannerPolicy(
planner, NewSession("planner-sys"), nil, nil, Options{},
executor, 0, event.Discard, policy,
)
err := coord.Run(context.Background(), tc.input)
if err == nil || !strings.Contains(err.Error(), "planner:") {
t.Fatalf("Run = %v, want planner failure", err)
}
if len(exec.requests) != 0 {
t.Fatal("executor fallback violated the requested execution boundary")
}
})
}
}
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 TestCoordinatorPlannerMaxStepsUsesExplicitRuntimeKey(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"})
sink := &recordSink{}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, sink)
plannerSess := NewSession("planner-sys")
coord := NewCoordinator(planner, plannerSess, nil, PlannerToolRegistry(parentReg), Options{
MaxSteps: 2,
MaxStepsKey: "planner max_steps",
}, executor, 0, sink, nil)
err := coord.Run(context.Background(), "plan a change")
if err != nil {
t.Fatalf("Run should fall back to the executor when the planner cannot finalize: %v", err)
}
if got := len(planner.requests); got != 3 {
t.Fatalf("planner requests = %d, want 2 research rounds plus finalization", got)
}
if got := len(exec.requests); got != 1 {
t.Fatalf("executor requests = %d, want one fallback run", got)
}
if got := lastUser(exec.requests[0]); !strings.Contains(got, "plan a change") || strings.Contains(got, executorHandoffMarker) {
t.Fatalf("executor fallback input = %q, want the original task without a fabricated handoff", got)
}
if got := len(plannerSess.Messages); got != 1 {
t.Fatalf("planner session messages = %d, want the incomplete turn rolled back", got)
}
notices := sink.kinds(event.Notice)
if len(notices) == 0 || notices[len(notices)-1].Text != plannerResearchFallbackNotice {
t.Fatalf("notices = %+v, want planner research fallback notice", notices)
}
if detail := notices[len(notices)-1].Detail; !strings.Contains(detail, "planner max_steps") ||
strings.Contains(detail, "set planner max_steps") {
t.Fatalf("fallback detail = %q, want the bounded diagnostic without configuration advice", detail)
}
}
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: "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 TestCoordinatorPlannerDepthAppliesPerTurnResearchBudget(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. apply the narrow change\n2. run the focused test"}, {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"})
policy := func(context.Context, string) PlannerDecision {
return PlannerDecision{
Route: PlannerRoutePlanAndExecute, Depth: PlannerDepthLight,
Reason: "bounded_work", MaxResearchRounds: 2,
}
}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
coord := NewCoordinatorWithPlannerPolicy(
planner, NewSession("planner-sys"), nil, PlannerToolRegistry(parentReg), Options{MaxSteps: 0},
executor, 0, event.Discard, policy,
)
if err := coord.Run(context.Background(), "make the bounded change"); err != nil {
t.Fatalf("Run: %v", err)
}
if got := len(planner.requests); got != 3 {
t.Fatalf("planner requests = %d, want two research rounds plus one finalization round", got)
}
if got := lastUser(planner.requests[2]); !strings.Contains(got, "planner research rounds") ||
!strings.Contains(got, "Do not call any more tools") ||
!strings.Contains(got, "label remaining uncertainty") ||
strings.Contains(got, "increase planner research rounds") {
t.Fatalf("planner did not receive the depth budget finalization nudge: %q", got)
}
var sawHandoff bool
for _, req := range exec.requests {
if strings.Contains(lastUser(req), executorHandoffMarker) {
sawHandoff = true
}
}
if !sawHandoff {
t.Fatalf("executor requests = %d, none received the bounded plan handoff", len(exec.requests))
}
}
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 {
return slices.Contains(items, want)
}
func BenchmarkPlannerToolRegistry(b *testing.B) {
parentReg := tool.NewRegistry()
for i := range 200 {
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 range b.N {
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)
}
}
func TestDefaultPlannerPromptDefinesLightAndFullEvidenceContracts(t *testing.T) {
for _, want := range []string{
"depth=light",
"depth=full",
"verified touchpoints",
"candidate touchpoints",
"command-level verification",
"Label assumptions",
} {
if !strings.Contains(DefaultPlannerPrompt, want) {
t.Fatalf("DefaultPlannerPrompt missing %q planning contract", want)
}
}
}
// 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)
}
})
}
}
func TestCoordinatorPlannerResearchPausePreservesExecutionBoundaries(t *testing.T) {
for _, route := range []PlannerRoute{PlannerRoutePlanOnly, PlannerRoutePlanForApproval} {
t.Run(string(route), func(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"})
policy := func(context.Context, string) PlannerDecision {
return PlannerDecision{Route: route, Depth: PlannerDepthFull, Reason: "explicit_boundary", MaxResearchRounds: 1}
}
executor := New(exec, tool.NewRegistry(), NewSession("exec-sys"), Options{}, event.Discard)
plannerSess := NewSession("planner-sys")
coord := NewCoordinatorWithPlannerPolicy(
planner, plannerSess, nil, plannerReg, Options{MaxSteps: 0},
executor, 0, event.Discard, policy,
)
err := coord.Run(context.Background(), "plan the migration")
if err == nil || err.Error() != plannerResearchBoundaryError {
t.Fatalf("Run = %v, want the safe planner boundary error", err)
}
if strings.Contains(err.Error(), "set planner research rounds") {
t.Fatalf("pause exposed a non-configurable setting: %q", err)
}
if got := len(exec.requests); got != 0 {
t.Fatalf("executor requests = %d, want none across %s", got, route)
}
if got := len(plannerSess.Messages); got != 1 {
t.Fatalf("planner session messages = %d, want the incomplete turn rolled back", got)
}
})
}
}
func TestCoordinatorRollbackAfterRewriteDropsPausedPlannerToolCall(t *testing.T) {
plannerSess := NewSession("planner-sys")
before := plannerSess.Snapshot()
rewriteBefore := plannerSess.RewriteVersion()
plannerSess.Replace([]provider.Message{
{Role: provider.RoleSystem, Content: "planner-sys"},
{Role: provider.RoleUser, Content: summaryTagOpen + "\ncompacted research\n</summary>"},
{Role: provider.RoleAssistant, Content: "Completed evidence from the bounded research rounds."},
})
plannerSess.IncrementRewrite()
plannerSess.Add(provider.Message{Role: provider.RoleUser, Content: "Do not call any more tools; finalize."})
plannerSess.Add(provider.Message{
Role: provider.RoleAssistant,
ToolCalls: []provider.ToolCall{{
ID: "ignored-finalization-call", Name: "read_file", Arguments: `{"path":"more.go"}`,
}},
})
coord := &Coordinator{plannerSess: plannerSess}
coord.rollbackPlannerTurn(before, rewriteBefore)
msgs := plannerSess.Snapshot()
if len(msgs) != 3 {
t.Fatalf("planner session messages = %d, want compacted prefix plus completed evidence", len(msgs))
}
if last := msgs[len(msgs)-1]; last.Role != provider.RoleAssistant ||
len(last.ToolCalls) != 0 || last.Content == "" {
t.Fatalf("planner session has an unusable pause tail: %+v", last)
}
if normalized := provider.NormalizeMessages(msgs); len(normalized) != len(msgs) {
t.Fatalf("planner session still needs tool-pair repair after rollback: %+v", normalized)
}
}
// 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 range 3 {
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 range 3 {
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")
}
}