返回 DeepSeek-Reasonix
auto_plan_e2e_test.go
根目录 / internal / control / auto_plan_e2e_test.go
1 package control
2
3 import (
4 "context"
5 "strings"
6 "testing"
7
8 "reasonix/internal/agent"
9 "reasonix/internal/event"
10 "reasonix/internal/provider"
11 "reasonix/internal/tool"
12 )
13
14 // scriptedTurns is a provider that replays a distinct chunk set per Stream call,
15 // so a controller turn that re-enters the agent (plan turn, then approved
16 // execution turn) sees a different model response each time.
17 type scriptedTurns struct {
18 turns [][]provider.Chunk
19 call int
20 }
21
22 func (s *scriptedTurns) Name() string { return "scripted" }
23
24 func (s *scriptedTurns) Stream(_ context.Context, _ provider.Request) (<-chan provider.Chunk, error) {
25 i := s.call
26 if i >= len(s.turns) {
27 i = len(s.turns) - 1
28 }
29 s.call++
30 ch := make(chan provider.Chunk, len(s.turns[i]))
31 for _, c := range s.turns[i] {
32 ch <- c
33 }
34 close(ch)
35 return ch, nil
36 }
37
38 func firstUserMessage(msgs []provider.Message) string {
39 for _, m := range msgs {
40 if m.Role == provider.RoleUser {
41 if m.ProviderContent != "" {
42 return m.ProviderContent
43 }
44 return m.Content
45 }
46 }
47 return ""
48 }
49
50 func textTurn(text string) []provider.Chunk {
51 return []provider.Chunk{{Type: provider.ChunkText, Text: text}, {Type: provider.ChunkDone}}
52 }
53
54 // TestPlanGateEndToEnd drives explicit Plan Mode through a real agent: the plan
55 // marker reaches the model, the controller asks for approval, and approval exits
56 // Plan Mode, seeds the task list, and runs the execution turn.
57 func TestPlanGateEndToEnd(t *testing.T) {
58 prov := &scriptedTurns{turns: [][]provider.Chunk{
59 textTurn("Plan:\n1. Add the config field\n2. Wire it into boot\n3. Add tests"),
60 textTurn("Done — implemented the plan."),
61 }}
62 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
63
64 approvalID := make(chan string, 1)
65 var seeded bool
66 c := New(Options{
67 Runner: ag,
68 Executor: ag,
69 Sink: event.FuncSink(func(e event.Event) {
70 switch e.Kind {
71 case event.ApprovalRequest:
72 approvalID <- e.Approval.ID
73 case event.ToolDispatch:
74 if e.Tool.ID == "plan-seed" {
75 seeded = true
76 }
77 }
78 }),
79 })
80 c.SetPlanMode(true)
81
82 go func() { c.Approve(<-approvalID, true, false, false) }()
83
84 input := "实现 issue #2395:新增配置项、自动判断复杂任务、补测试和文档"
85 if err := c.runTurnWithRaw(context.Background(), input, input); err != nil {
86 t.Fatalf("runTurnWithRaw: %v", err)
87 }
88
89 msgs := ag.Session().Messages
90 if got := agent.StripTransientUserBlocks(firstUserMessage(msgs)); !strings.HasPrefix(got, PlanModeMarker) {
91 t.Fatalf("first model input = %q, want the plan marker prefixed", got)
92 }
93 if c.PlanMode() {
94 t.Fatal("plan mode should be off after approval")
95 }
96 if !seeded {
97 t.Fatal("approved plan should seed the task list")
98 }
99 if got := lastAssistantText(msgs); got != "Done — implemented the plan." {
100 t.Fatalf("last assistant text = %q, want the execution turn's answer", got)
101 }
102 if prov.call != 2 {
103 t.Fatalf("provider called %d times, want 2 (plan + execution)", prov.call)
104 }
105 }
106
107 func TestApprovedPlanSeedClearsAfterExecutionWithoutModelTodoWrite(t *testing.T) {
108 prov := &scriptedTurns{turns: [][]provider.Chunk{
109 textTurn("Plan:\n1. Add the config field\n2. Wire it into boot"),
110 textTurn("Done."),
111 }}
112 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
113
114 approvalID := make(chan string, 1)
115 var planSeedResults []string
116 c := New(Options{
117 Runner: ag,
118 Executor: ag,
119 Sink: event.FuncSink(func(e event.Event) {
120 switch e.Kind {
121 case event.ApprovalRequest:
122 approvalID <- e.Approval.ID
123 case event.ToolResult:
124 if e.Tool.ID == "plan-seed" && e.Tool.Name == "todo_write" && e.Tool.Err == "" {
125 planSeedResults = append(planSeedResults, e.Tool.Args)
126 }
127 }
128 }),
129 })
130 c.SetPlanMode(true)
131
132 go func() { c.Approve(<-approvalID, true, false, false) }()
133
134 input := "Implement issue #2395: add config, wire boot, add tests and docs"
135 if err := c.runTurnWithRaw(context.Background(), input, input); err != nil {
136 t.Fatalf("runTurnWithRaw: %v", err)
137 }
138
139 if len(planSeedResults) != 2 {
140 t.Fatalf("plan-seed todo results = %d, want seed then completion: %#v", len(planSeedResults), planSeedResults)
141 }
142 last := planSeedResults[len(planSeedResults)-1]
143 if strings.Contains(last, `"in_progress"`) || strings.Contains(last, `"pending"`) {
144 t.Fatalf("final plan-seed todos should be completed so the panel hides: %s", last)
145 }
146 if !strings.Contains(last, `"completed"`) {
147 t.Fatalf("final plan-seed todos should contain completed items: %s", last)
148 }
149 }
150
151 // TestPlanGateRejectionStaysInPlan proves a rejected plan keeps plan mode on
152 // and never runs the execution turn: only the plan turn reached the model.
153 func TestPlanGateRejectionStaysInPlan(t *testing.T) {
154 prov := &scriptedTurns{turns: [][]provider.Chunk{
155 textTurn("Plan:\n1. Add the config field\n2. Add tests"),
156 }}
157 ag := agent.New(prov, tool.NewRegistry(), agent.NewSession(""), agent.Options{}, event.Discard)
158
159 approvalID := make(chan string, 1)
160 var seeded bool
161 c := New(Options{
162 Runner: ag,
163 Executor: ag,
164 Sink: event.FuncSink(func(e event.Event) {
165 switch e.Kind {
166 case event.ApprovalRequest:
167 approvalID <- e.Approval.ID
168 case event.ToolDispatch:
169 if e.Tool.ID == "plan-seed" {
170 seeded = true
171 }
172 }
173 }),
174 })
175 c.SetPlanMode(true)
176
177 go func() { c.Approve(<-approvalID, false, false, false) }()
178
179 input := "实现 issue #2395:新增配置项、自动判断复杂任务、补测试和文档"
180 if err := c.runTurnWithRaw(context.Background(), input, input); err != nil {
181 t.Fatalf("runTurnWithRaw: %v", err)
182 }
183
184 if !c.PlanMode() {
185 t.Fatal("rejected plan should keep plan mode on")
186 }
187 if seeded {
188 t.Fatal("rejected plan must not seed the task list")
189 }
190 if prov.call != 1 {
191 t.Fatalf("provider called %d times, want 1 (plan only, no execution)", prov.call)
192 }
193 }
194
194 lines GO