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