192 lines
5.7 KiB
Go
192 lines
5.7 KiB
Go
package control
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
|
|
"reasonix/internal/agent"
|
|
"reasonix/internal/event"
|
|
"reasonix/internal/provider"
|
|
"reasonix/internal/tool"
|
|
)
|
|
|
|
// scriptedTurns is a provider that replays a distinct chunk set per Stream call,
|
|
// so a controller turn that re-enters the agent (plan turn, then approved
|
|
// execution turn) sees a different model response each time.
|
|
type scriptedTurns struct {
|
|
turns [][]provider.Chunk
|
|
call int
|
|
}
|
|
|
|
func (s *scriptedTurns) Name() string { return "scripted" }
|
|
|
|
func (s *scriptedTurns) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
|
|
i := s.call
|
|
if i >= len(s.turns) {
|
|
i = len(s.turns) - 1
|
|
}
|
|
s.call++
|
|
ch := make(chan provider.Chunk, len(s.turns[i]))
|
|
for _, c := range s.turns[i] {
|
|
ch <- c
|
|
}
|
|
close(ch)
|
|
return ch, nil
|
|
}
|
|
|
|
func firstUserMessage(msgs []provider.Message) string {
|
|
for _, m := range msgs {
|
|
if m.Role == provider.RoleUser {
|
|
return m.Content
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func textTurn(text string) []provider.Chunk {
|
|
return []provider.Chunk{{Type: provider.ChunkText, Text: text}, {Type: provider.ChunkDone}}
|
|
}
|
|
|
|
// TestAutoPlanGateEndToEnd drives the whole gate through a real agent: a complex
|
|
// request auto-enters plan mode (marker reaches the model), the agent answers
|
|
// with a plan, the controller asks for approval, and on approval it exits plan
|
|
// mode, seeds the task list, and runs the execution turn.
|
|
func TestAutoPlanGateEndToEnd(t *testing.T) {
|
|
prov := &scriptedTurns{turns: [][]provider.Chunk{
|
|
textTurn("Plan:\n1. Add the config field\n2. Wire it into boot\n3. Add tests"),
|
|
textTurn("Done — implemented the plan."),
|
|
}}
|
|
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
|
|
|
|
approvalID := make(chan string, 1)
|
|
var seeded bool
|
|
c := New(Options{
|
|
AutoPlan: "on",
|
|
Runner: ag,
|
|
Executor: ag,
|
|
Sink: event.FuncSink(func(e event.Event) {
|
|
switch e.Kind {
|
|
case event.ApprovalRequest:
|
|
approvalID <- e.Approval.ID
|
|
case event.ToolDispatch:
|
|
if e.Tool.ID == "plan-seed" {
|
|
seeded = true
|
|
}
|
|
}
|
|
}),
|
|
})
|
|
|
|
go func() { c.Approve(<-approvalID, true, false, false) }()
|
|
|
|
input := "实现 issue #2395:新增配置项、自动判断复杂任务、补测试和文档"
|
|
if err := c.runTurnWithRaw(context.Background(), input, input); err != nil {
|
|
t.Fatalf("runTurnWithRaw: %v", err)
|
|
}
|
|
|
|
msgs := ag.Session().Messages
|
|
if got := agent.StripTransientUserBlocks(firstUserMessage(msgs)); !strings.HasPrefix(got, PlanModeMarker) {
|
|
t.Fatalf("first model input = %q, want the auto-plan marker prefixed", got)
|
|
}
|
|
if c.PlanMode() {
|
|
t.Fatal("plan mode should be off after approval")
|
|
}
|
|
if !seeded {
|
|
t.Fatal("approved plan should seed the task list")
|
|
}
|
|
if got := lastAssistantText(msgs); got != "Done — implemented the plan." {
|
|
t.Fatalf("last assistant text = %q, want the execution turn's answer", got)
|
|
}
|
|
if prov.call != 2 {
|
|
t.Fatalf("provider called %d times, want 2 (plan + execution)", prov.call)
|
|
}
|
|
}
|
|
|
|
func TestApprovedPlanSeedClearsAfterExecutionWithoutModelTodoWrite(t *testing.T) {
|
|
prov := &scriptedTurns{turns: [][]provider.Chunk{
|
|
textTurn("Plan:\n1. Add the config field\n2. Wire it into boot"),
|
|
textTurn("Done."),
|
|
}}
|
|
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
|
|
|
|
approvalID := make(chan string, 1)
|
|
var planSeedResults []string
|
|
c := New(Options{
|
|
AutoPlan: "on",
|
|
Runner: ag,
|
|
Executor: ag,
|
|
Sink: event.FuncSink(func(e event.Event) {
|
|
switch e.Kind {
|
|
case event.ApprovalRequest:
|
|
approvalID <- e.Approval.ID
|
|
case event.ToolResult:
|
|
if e.Tool.ID == "plan-seed" && e.Tool.Name == "todo_write" && e.Tool.Err == "" {
|
|
planSeedResults = append(planSeedResults, e.Tool.Args)
|
|
}
|
|
}
|
|
}),
|
|
})
|
|
|
|
go func() { c.Approve(<-approvalID, true, false, false) }()
|
|
|
|
input := "Implement issue #2395: add config, wire boot, add tests and docs"
|
|
if err := c.runTurnWithRaw(context.Background(), input, input); err != nil {
|
|
t.Fatalf("runTurnWithRaw: %v", err)
|
|
}
|
|
|
|
if len(planSeedResults) != 2 {
|
|
t.Fatalf("plan-seed todo results = %d, want seed then completion: %#v", len(planSeedResults), planSeedResults)
|
|
}
|
|
last := planSeedResults[len(planSeedResults)-1]
|
|
if strings.Contains(last, `"in_progress"`) || strings.Contains(last, `"pending"`) {
|
|
t.Fatalf("final plan-seed todos should be completed so the panel hides: %s", last)
|
|
}
|
|
if !strings.Contains(last, `"completed"`) {
|
|
t.Fatalf("final plan-seed todos should contain completed items: %s", last)
|
|
}
|
|
}
|
|
|
|
// TestAutoPlanGateRejectionStaysInPlan proves a rejected plan keeps plan mode on
|
|
// and never runs the execution turn: only the plan turn reached the model.
|
|
func TestAutoPlanGateRejectionStaysInPlan(t *testing.T) {
|
|
prov := &scriptedTurns{turns: [][]provider.Chunk{
|
|
textTurn("Plan:\n1. Add the config field\n2. Add tests"),
|
|
}}
|
|
ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
|
|
|
|
approvalID := make(chan string, 1)
|
|
var seeded bool
|
|
c := New(Options{
|
|
AutoPlan: "on",
|
|
Runner: ag,
|
|
Executor: ag,
|
|
Sink: event.FuncSink(func(e event.Event) {
|
|
switch e.Kind {
|
|
case event.ApprovalRequest:
|
|
approvalID <- e.Approval.ID
|
|
case event.ToolDispatch:
|
|
if e.Tool.ID == "plan-seed" {
|
|
seeded = true
|
|
}
|
|
}
|
|
}),
|
|
})
|
|
|
|
go func() { c.Approve(<-approvalID, false, false, false) }()
|
|
|
|
input := "实现 issue #2395:新增配置项、自动判断复杂任务、补测试和文档"
|
|
if err := c.runTurnWithRaw(context.Background(), input, input); err != nil {
|
|
t.Fatalf("runTurnWithRaw: %v", err)
|
|
}
|
|
|
|
if !c.PlanMode() {
|
|
t.Fatal("rejected plan should keep plan mode on")
|
|
}
|
|
if seeded {
|
|
t.Fatal("rejected plan must not seed the task list")
|
|
}
|
|
if prov.call != 1 {
|
|
t.Fatalf("provider called %d times, want 1 (plan only, no execution)", prov.call)
|
|
}
|
|
}
|