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

170 lines
6.7 KiB
Go

package control
import (
"context"
"errors"
"testing"
)
func TestTaskWarrantsPlanner(t *testing.T) {
cases := []struct {
input string
want bool
}{
{"", false},
{" ", false},
{"/init", false},
{"1", false},
{"2.", false},
{"A", false},
{"好的", false},
{"继续", false},
{"选 1", false},
{"what does this function do?", false}, // low-risk question → executor only
{"why did the test fail", false},
{"解释一下这段代码", false},
{reasoningLanguageBlock("zh") + "\n\nwhat does this function do?", false},
{reasoningLanguageBlock("en") + "\n\n" + PlanModeMarker + "\n\nfix the bug", false},
{reasoningLanguageBlock("en") + "\n\nfix the bug", true},
{"fix the bug", true}, // terse, but a work request → still planned
{"add a login button", true}, // ditto
{"执行修复", true},
{"开始迁移", true},
{"继续重构", true},
{"continue fixing tests", true},
{"implement the new caching layer across the backend", true},
{"who wrote this file?", false},
{"where is the config file?", false},
{"when does this run?", false},
{"which file has the error?", false},
{"explain this code", false},
{"describe the architecture", false},
{"tell me about this function", false},
{"is this safe?", false},
{"are we done?", false},
{"can you help?", false},
{"can you fix the failing tests across the backend", true},
{"could you update the README", true},
{"should we remove the stale config option", true},
{"would you add a regression test", true},
{"do you fix flaky tests here", true},
{"does it work?", false},
{"did the test pass?", false},
{"should I use mutex here?", false},
{"would this approach work?", false},
{"list all the endpoints", false},
{"summarize the changes", false},
{"compare these two approaches", false},
{"what's the status?", false},
{"介绍一下这个项目", false},
{"说一下这个函数的作用", false},
{"帮我看一下这个报错", false},
{"是什么意思", false},
{"有没有现成的方案", false},
{"能不能这样做", false},
{"请问这个怎么用", false},
{"how do I implement a new caching layer", true},
{"what's the best way to refactor this module", true},
{"explain how to migrate from v1 to v2", true},
{goalContinueTurn, false},
{goalSelfCheckTurn, false},
{"No tool calls in recent turns. Either make progress with tools or signal [goal:blocked:<reason>].", false},
{"Goal signaled complete but issues remain:\n- the following tasks are still incomplete:\n - Fix login (in_progress)\nFix or use todo_write/complete_step to mark done, then [goal:complete] again.", false},
{activeGoalBlock("execute plan: fix the parser", GoalResearchAuto) + "\n\n" + goalContinueTurn, false},
{activeGoalBlock("execute plan: fix the parser", GoalResearchAuto) + "\n\n" + goalSelfCheckTurn, false},
{activeGoalBlock("implement the new caching layer", GoalResearchAuto) + "\n\nimplement the new caching layer across the backend", true},
}
for _, c := range cases {
if got := TaskWarrantsPlanner(c.input); got != c.want {
t.Errorf("TaskWarrantsPlanner(%q) = %v, want %v", c.input, got, c.want)
}
}
}
func TestAutoPlanScoreSkipsSyntheticMessages(t *testing.T) {
syntheticInputs := []string{
"Plan approved — plan mode is off",
"Host final-answer readiness check failed. Before giving a final answer, address the missing host-observable receipts: missing evidence.",
"You are already in the executor phase. The planner's read-only limitations do not apply to you.",
"The previous assistant response was interrupted while a tool call was streaming. Continue the same task now.",
"The previous assistant response was interrupted during streaming. Continue the same task from immediately after the partial assistant message above.",
"The previous assistant response was interrupted during streaming before visible answer text was completed. Continue the same task now.",
"The previous assistant response finished without any visible answer text. Continue the same task now and provide a concise visible answer.",
goalContinueTurn,
goalSelfCheckTurn,
"No tool calls in recent turns. Either make progress with tools or signal [goal:blocked:<reason>].",
"Goal signaled complete but issues remain:\n- the following tasks are still incomplete:\n - Fix login (in_progress)\nFix or use todo_write/complete_step to mark done, then [goal:complete] again.",
}
for _, input := range syntheticInputs {
if got := autoPlanScore(input); got != 0 {
t.Errorf("autoPlanScore(%q) = %d, want 0", input, got)
}
}
}
type mockAutoPlanClassifier struct {
needsPlan bool
err error
}
func (m *mockAutoPlanClassifier) NeedsPlan(context.Context, string, int) (bool, string, error) {
if m.err != nil {
return false, "", m.err
}
return m.needsPlan, "mock", nil
}
func TestNewPlannerGateNilClassifierFallback(t *testing.T) {
gate := NewPlannerGate(nil)
if gate == nil {
t.Fatal("NewPlannerGate(nil) returned nil")
}
if got := gate(context.Background(), "what is this?"); got {
t.Error("nil classifier gate should skip low-risk questions")
}
if got := gate(context.Background(), "fix the bug"); !got {
t.Error("nil classifier gate should plan work requests")
}
}
func TestNewPlannerGateWithClassifier(t *testing.T) {
gate := NewPlannerGate(&mockAutoPlanClassifier{needsPlan: false})
if got := gate(context.Background(), "fix the bug"); got {
t.Error("classifier said no plan, gate should return false")
}
gate = NewPlannerGate(&mockAutoPlanClassifier{needsPlan: true})
if got := gate(context.Background(), "fix the bug"); !got {
t.Error("classifier said plan, gate should return true")
}
}
func TestNewPlannerGateClassifierFailureFallsBackToPlanning(t *testing.T) {
gate := NewPlannerGate(&mockAutoPlanClassifier{err: errors.New("bad json")})
if got := gate(context.Background(), "fix the bug"); !got {
t.Error("classifier failure should fall back to planning for work requests")
}
}
type ctxCapturingClassifier struct{ sawTurnValue bool }
func (c *ctxCapturingClassifier) NeedsPlan(ctx context.Context, _ string, _ int) (bool, string, error) {
c.sawTurnValue = ctx.Value(plannerGateTestCtxKey{}) != nil
return false, "", nil
}
type plannerGateTestCtxKey struct{}
// TestNewPlannerGateThreadsTurnContextIntoClassifier pins that the borderline
// classifier call derives from the turn context (so user cancellation stops it)
// rather than from context.Background().
func TestNewPlannerGateThreadsTurnContextIntoClassifier(t *testing.T) {
cls := &ctxCapturingClassifier{}
gate := NewPlannerGate(cls)
ctx := context.WithValue(context.Background(), plannerGateTestCtxKey{}, "turn")
gate(ctx, "fix the bug")
if !cls.sawTurnValue {
t.Fatal("classifier context does not derive from the turn context")
}
}