返回 DeepSeek-Reasonix
planmode_test.go
根目录 / internal / agent / planmode_test.go
1 package agent
2
3 import (
4 "context"
5 "encoding/json"
6 "strings"
7 "testing"
8
9 "reasonix/internal/event"
10 "reasonix/internal/evidence"
11 "reasonix/internal/planmode"
12 "reasonix/internal/provider"
13 "reasonix/internal/tool"
14 )
15
16 type planSafeTool struct {
17 fakeTool
18 planSafe bool
19 }
20
21 func (p planSafeTool) PlanModeSafe() bool { return p.planSafe }
22
23 type permissionCall struct {
24 name string
25 readOnly bool
26 }
27
28 type recordingPermissionGate struct {
29 allow bool
30 reason string
31 calls []permissionCall
32 denied bool
33 denyCalls []string
34 }
35
36 func (g *recordingPermissionGate) ExplicitlyDenies(name string, _ json.RawMessage) bool {
37 g.denyCalls = append(g.denyCalls, name)
38 return g.denied
39 }
40
41 func (g *recordingPermissionGate) Check(_ context.Context, name string, _ json.RawMessage, readOnly bool) (bool, string, error) {
42 g.calls = append(g.calls, permissionCall{name: name, readOnly: readOnly})
43 return g.allow, g.reason, nil
44 }
45
46 type legacyPlanTrustGate struct{ calls int }
47
48 func (g *legacyPlanTrustGate) CheckPlanModeReadOnlyTrust(context.Context, PlanModeReadOnlyTrustRequest) (bool, string, error) {
49 g.calls++
50 return true, "", nil
51 }
52
53 type annotatedMCPTool struct {
54 fakeTool
55 server string
56 raw string
57 destructive bool
58 serverAuthorized bool
59 }
60
61 func (t annotatedMCPTool) MCPServerName() string { return t.server }
62 func (t annotatedMCPTool) MCPRawToolName() string { return t.raw }
63 func (t annotatedMCPTool) MCPDestructiveHint() bool { return t.destructive }
64 func (t annotatedMCPTool) MCPServerAuthorized() bool { return t.serverAuthorized }
65
66 type mcpPermissionRecordingGate struct {
67 normalCalls int
68 readOnly []bool
69 allowNormal bool
70 reason string
71 }
72
73 func (g *mcpPermissionRecordingGate) Check(_ context.Context, _ string, _ json.RawMessage, readOnly bool) (bool, string, error) {
74 g.normalCalls++
75 g.readOnly = append(g.readOnly, readOnly)
76 return g.allowNormal, g.reason, nil
77 }
78
79 func TestPlanModeRoutesOrdinaryToolsThroughPermissionGate(t *testing.T) {
80 tests := []struct {
81 name string
82 tool tool.Tool
83 args string
84 readOnly bool
85 }{
86 {name: "built-in writer", tool: fakeTool{name: "write_file"}},
87 {name: "shell writer", tool: fakeTool{name: "bash"}, args: `{"command":"rm -rf build"}`},
88 {name: "reader", tool: fakeTool{name: "read_file", readOnly: true}, readOnly: true},
89 {
90 name: "authorized MCP reader",
91 tool: annotatedMCPTool{
92 fakeTool: fakeTool{name: "mcp__srv__query", readOnly: true},
93 server: "srv",
94 raw: "query",
95 serverAuthorized: true,
96 },
97 readOnly: true,
98 },
99 }
100 for _, tc := range tests {
101 t.Run(tc.name, func(t *testing.T) {
102 reg := tool.NewRegistry()
103 reg.Add(tc.tool)
104 gate := &recordingPermissionGate{allow: true}
105 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
106 a.SetPlanMode(true)
107
108 out := a.executeOne(context.Background(), provider.ToolCall{Name: tc.tool.Name(), Arguments: tc.args})
109 if out.blocked || out.errMsg != "" || !strings.Contains(out.output, "done") {
110 t.Fatalf("ordinary Plan call did not execute after permission approval: %+v", out)
111 }
112 if isInstalledMCPTool(tc.tool) {
113 if len(gate.calls) != 0 || len(gate.denyCalls) != 1 || gate.denyCalls[0] != tc.tool.Name() {
114 t.Fatalf("authorized MCP permission calls=%+v deny checks=%+v", gate.calls, gate.denyCalls)
115 }
116 } else if len(gate.calls) != 1 || gate.calls[0].name != tc.tool.Name() || gate.calls[0].readOnly != tc.readOnly {
117 t.Fatalf("permission calls = %+v, want %q readOnly=%v", gate.calls, tc.tool.Name(), tc.readOnly)
118 }
119 })
120 }
121 }
122
123 func TestPlanModePermissionDenialStopsWriterBeforeExecution(t *testing.T) {
124 var executions int32
125 reg := tool.NewRegistry()
126 reg.Add(fakeTool{name: "write_file", calls: &executions})
127 gate := &recordingPermissionGate{reason: "denied by permission rule"}
128 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
129 a.SetPlanMode(true)
130
131 out := a.executeOne(context.Background(), provider.ToolCall{Name: "write_file"})
132 if !out.blocked || !strings.Contains(out.output, gate.reason) || out.errMsg == "" {
133 t.Fatalf("permission denial outcome = %+v", out)
134 }
135 if executions != 0 {
136 t.Fatalf("denied writer executed %d times", executions)
137 }
138 }
139
140 func TestAuthorizedMCPUsesInstallAuthorizationAndExplicitDenyOnly(t *testing.T) {
141 var executions int32
142 reg := tool.NewRegistry()
143 reg.Add(annotatedMCPTool{
144 fakeTool: fakeTool{name: "mcp__srv__write", calls: &executions},
145 server: "srv",
146 raw: "write",
147 serverAuthorized: true,
148 })
149
150 // The ordinary writer fallback would deny, but an authorized MCP server must
151 // not re-enter that per-call approval path.
152 gate := &recordingPermissionGate{allow: false, reason: "ordinary ask declined"}
153 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
154 out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__write"})
155 if out.blocked || out.errMsg != "" || executions != 1 || len(gate.calls) != 0 || len(gate.denyCalls) != 1 {
156 t.Fatalf("authorized MCP outcome=%+v gate=%+v executions=%d", out, gate, executions)
157 }
158
159 gate.denied = true
160 out = a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__write"})
161 if !out.blocked || !strings.Contains(out.output, "deny list") || executions != 1 {
162 t.Fatalf("explicitly denied MCP outcome=%+v executions=%d", out, executions)
163 }
164 }
165
166 func TestPlanModeUnsafePhaseToolStopsBeforePermission(t *testing.T) {
167 var executions int32
168 reg := tool.NewRegistry()
169 reg.Add(planSafeTool{fakeTool: fakeTool{name: "complete_step", readOnly: true, calls: &executions}, planSafe: false})
170 gate := &recordingPermissionGate{allow: true}
171 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
172 a.SetPlanMode(true)
173
174 out := a.executeOne(context.Background(), provider.ToolCall{Name: "complete_step"})
175 if !out.blocked || !strings.Contains(out.output, "only available after plan approval") {
176 t.Fatalf("phase opt-out outcome = %+v", out)
177 }
178 if len(gate.calls) != 0 || executions != 0 {
179 t.Fatalf("phase-blocked call reached permission/execution: gate=%+v executions=%d", gate.calls, executions)
180 }
181 }
182
183 func TestPlanModeSafeWriterStillUsesWriterPermission(t *testing.T) {
184 reg := tool.NewRegistry()
185 reg.Add(planSafeTool{fakeTool: fakeTool{name: "phase_safe_writer"}, planSafe: true})
186 gate := &recordingPermissionGate{allow: true}
187 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
188 a.SetPlanMode(true)
189
190 out := a.executeOne(context.Background(), provider.ToolCall{Name: "phase_safe_writer"})
191 if out.blocked || out.errMsg != "" {
192 t.Fatalf("phase-safe writer outcome = %+v", out)
193 }
194 if len(gate.calls) != 1 || gate.calls[0].readOnly {
195 t.Fatalf("phase-safe writer permission calls = %+v", gate.calls)
196 }
197 }
198
199 func TestPlanModeDoesNotInvokeLegacyBashTrustPrompt(t *testing.T) {
200 reg := tool.NewRegistry()
201 reg.Add(fakeTool{name: "bash"})
202 gate := &recordingPermissionGate{allow: true}
203 legacy := &legacyPlanTrustGate{}
204 a := New(nil, reg, NewSession(""), Options{
205 Gate: gate,
206 PlanModeReadOnlyTrustGate: legacy,
207 }, event.Discard)
208 a.SetPlanMode(true)
209
210 out := a.executeOne(context.Background(), provider.ToolCall{
211 Name: "bash",
212 Arguments: `{"command":"gh issue view 6482"}`,
213 })
214 if out.blocked || out.errMsg != "" {
215 t.Fatalf("permission-approved bash outcome = %+v", out)
216 }
217 if legacy.calls != 0 {
218 t.Fatalf("obsolete Plan bash trust prompt was invoked %d times", legacy.calls)
219 }
220 if len(gate.calls) != 1 || gate.calls[0].readOnly {
221 t.Fatalf("bash must reach ordinary permission as declared writer, calls=%+v", gate.calls)
222 }
223 }
224
225 func TestPlanModeLegacyOverridesDoNotBypassPermissions(t *testing.T) {
226 reg := tool.NewRegistry()
227 reg.Add(fakeTool{name: "write_file"})
228 gate := &recordingPermissionGate{reason: "denied"}
229 a := New(nil, reg, NewSession(""), Options{
230 Gate: gate,
231 PlanModeReadOnlyCommands: []string{"gh issue view"},
232 }, event.Discard)
233 a.SetPlanMode(true)
234
235 out := a.executeOne(context.Background(), provider.ToolCall{Name: "write_file"})
236 if !out.blocked || len(gate.calls) != 1 {
237 t.Fatalf("legacy Plan config bypassed permissions: outcome=%+v calls=%+v", out, gate.calls)
238 }
239 }
240
241 func TestPlanModeCanReplacePriorExecutionTodoState(t *testing.T) {
242 reg := tool.NewRegistry()
243 reg.Add(mustBuiltinTool(t, "todo_write"))
244 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
245 recoveryGate := &recordingRecoveryGate{decision: RecoveryDecision{Allow: true}}
246 a.SetRecoveryGate(recoveryGate)
247 a.SeedTodoState([]evidence.TodoItem{{Content: "old execution step", Status: "in_progress"}})
248 a.SetPlanMode(true)
249
250 out := a.executeOne(context.Background(), provider.ToolCall{
251 ID: "new-plan",
252 Name: "todo_write",
253 Arguments: `{"todos":[
254 {"content":"inspect the new request","status":"in_progress"},
255 {"content":"draft a revised plan","status":"pending"}
256 ]}`,
257 })
258 if out.errMsg != "" {
259 t.Fatalf("plan-mode todo replacement was blocked: %s", out.errMsg)
260 }
261 got := a.CanonicalTodoState()
262 if len(got) != 2 || got[0].Content != "inspect the new request" {
263 t.Fatalf("plan-mode todo state = %+v, want revised plan", got)
264 }
265 if len(recoveryGate.proposals) != 0 {
266 t.Fatalf("Plan mode sent duplicate Auto plan review proposals: %+v", recoveryGate.proposals)
267 }
268 }
269
270 // TestPlanModeDoesNotMutateSystemOrTools is the cache-stability test. Toggling
271 // plan mode between two stream calls must not change the system prompt or the
272 // tool list seen by the provider — those are the cache-key prefix, and any
273 // change there forces an expensive cache miss.
274 func TestPlanModeDoesNotMutateSystemOrTools(t *testing.T) {
275 prov := &mockProvider{name: "p", chunks: []provider.Chunk{
276 {Type: provider.ChunkText, Text: "ok"},
277 {Type: provider.ChunkDone},
278 }}
279 reg := tool.NewRegistry()
280 reg.Add(fakeTool{name: "read_file", readOnly: true})
281 reg.Add(fakeTool{name: "write_file"})
282 a := New(prov, reg, NewSession("STABLE-SYS"), Options{}, event.Discard)
283
284 if err := a.Run(context.Background(), "explore"); err != nil {
285 t.Fatalf("standard Run: %v", err)
286 }
287 standardSystem := prov.lastReq.Messages[0]
288 standardTools := serializeToolSchemas(t, prov.lastReq.Tools)
289
290 prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "ok"}, {Type: provider.ChunkDone}}
291 a.SetPlanMode(true)
292 if err := a.Run(context.Background(), "now in plan mode"); err != nil {
293 t.Fatalf("Plan Run: %v", err)
294 }
295 planSystem := prov.lastReq.Messages[0]
296 planTools := serializeToolSchemas(t, prov.lastReq.Tools)
297
298 if planSystem.Role != standardSystem.Role || planSystem.Content != standardSystem.Content {
299 t.Fatalf("system message changed across Plan toggle:\nstandard=%+v\nplan=%+v", standardSystem, planSystem)
300 }
301 if planTools != standardTools {
302 t.Fatalf("tool schemas changed across Plan toggle:\nstandard=%s\nplan=%s", standardTools, planTools)
303 }
304 }
305
306 func serializeToolSchemas(t *testing.T, schemas []provider.ToolSchema) string {
307 t.Helper()
308 b, err := json.Marshal(schemas)
309 if err != nil {
310 t.Fatalf("serialize tool schemas: %v", err)
311 }
312 return string(b)
313 }
314
315 func TestUnauthorizedMCPReaderBlockedInMainPlanAndExcludedFromReadOnlyAgents(t *testing.T) {
316 parent := tool.NewRegistry()
317 parent.Add(fakeTool{name: "read_file", readOnly: true})
318 parent.Add(annotatedMCPTool{
319 fakeTool: fakeTool{name: "mcp__srv__query", readOnly: true},
320 server: "srv",
321 raw: "query",
322 serverAuthorized: false,
323 })
324 gate := &recordingPermissionGate{allow: true}
325 a := New(nil, parent, NewSession(""), Options{Gate: gate}, event.Discard)
326 a.SetPlanMode(true)
327
328 out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__query"})
329 if !out.blocked || len(gate.calls) != 0 {
330 t.Fatalf("main Plan MCP reader outcome=%+v calls=%+v", out, gate.calls)
331 }
332
333 for name, filtered := range map[string]*tool.Registry{
334 "planner": FilterReadOnlyRegistry(parent),
335 "subagent": ReadOnlySubagentToolRegistry(parent, nil),
336 } {
337 if _, ok := filtered.Get("read_file"); !ok {
338 t.Fatalf("%s registry lost local reader", name)
339 }
340 if _, ok := filtered.Get("mcp__srv__query"); ok {
341 t.Fatalf("%s registry admitted reader from unauthorized server", name)
342 }
343 }
344 }
345
346 func TestPlanModeMCPWriterIsHardBlockedBeforePermission(t *testing.T) {
347 reg := tool.NewRegistry()
348 reg.Add(annotatedMCPTool{fakeTool: fakeTool{name: "mcp__srv__write"}, server: "srv", raw: "write"})
349 gate := &mcpPermissionRecordingGate{allowNormal: true}
350 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
351 a.SetPlanMode(true)
352
353 out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__write"})
354 if !out.blocked || gate.normalCalls != 0 {
355 t.Fatalf("MCP writer outcome=%+v gate=%+v", out, gate)
356 }
357 }
358
359 func TestPlanModeMCPWriterHonorsPermissionDenial(t *testing.T) {
360 var executions int32
361 reg := tool.NewRegistry()
362 reg.Add(annotatedMCPTool{
363 fakeTool: fakeTool{name: "mcp__srv__write", calls: &executions},
364 server: "srv",
365 raw: "write",
366 })
367 gate := &mcpPermissionRecordingGate{reason: "denied by policy"}
368 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
369 a.SetPlanMode(true)
370
371 out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__write"})
372 if !out.blocked || !strings.Contains(out.output, "Plan mode") || gate.normalCalls != 0 || executions != 0 {
373 t.Fatalf("denied MCP writer outcome=%+v gate=%+v executions=%d", out, gate, executions)
374 }
375 }
376
377 func TestDestructiveMCPUsesFreshApprovalInPlanEvenWhenReadOnly(t *testing.T) {
378 reg := tool.NewRegistry()
379 reg.Add(annotatedMCPTool{
380 fakeTool: fakeTool{name: "mcp__srv__danger", readOnly: true},
381 server: "srv",
382 raw: "danger/raw",
383 destructive: true,
384 })
385 gate := &mcpPermissionRecordingGate{allowNormal: true}
386 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
387 a.SetPlanMode(true)
388
389 out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__danger"})
390 if !out.blocked || gate.normalCalls != 0 {
391 t.Fatalf("destructive MCP outcome=%+v gate=%+v", out, gate)
392 }
393 }
394
395 func TestDestructiveMCPFailsClosedWithoutFreshApprovalGate(t *testing.T) {
396 reg := tool.NewRegistry()
397 reg.Add(annotatedMCPTool{
398 fakeTool: fakeTool{name: "mcp__srv__danger"},
399 server: "srv",
400 raw: "danger",
401 destructive: true,
402 })
403 ordinary := &recordingPermissionGate{allow: true}
404 a := New(nil, reg, NewSession(""), Options{Gate: ordinary}, event.Discard)
405 a.SetPlanMode(true)
406
407 out := a.executeOne(context.Background(), provider.ToolCall{Name: "mcp__srv__danger"})
408 if !out.blocked || !strings.Contains(out.output, "Plan mode") {
409 t.Fatalf("destructive MCP fail-closed outcome = %+v", out)
410 }
411 if len(ordinary.calls) != 0 {
412 t.Fatalf("destructive MCP fell back to ordinary gate: %+v", ordinary.calls)
413 }
414 }
415
416 func TestPlanModeOffStillUsesSamePermissionGate(t *testing.T) {
417 reg := tool.NewRegistry()
418 reg.Add(fakeTool{name: "write_file"})
419 gate := &recordingPermissionGate{allow: true}
420 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
421
422 out := a.executeOne(context.Background(), provider.ToolCall{Name: "write_file"})
423 if out.blocked || len(gate.calls) != 1 {
424 t.Fatalf("standard mode outcome=%+v calls=%+v", out, gate.calls)
425 }
426 }
427
428 func TestRunSubAgentWithSessionInheritsPlanWorkflow(t *testing.T) {
429 completeStep, ok := tool.LookupBuiltin("complete_step")
430 if !ok {
431 t.Fatal("complete_step builtin not registered")
432 }
433 reg := tool.NewRegistry()
434 reg.Add(completeStep)
435 prov := &scriptedProvider{name: "plan-child", turns: [][]provider.Chunk{
436 {toolCallChunk("phase", "complete_step", `{}`), {Type: provider.ChunkDone}},
437 {{Type: provider.ChunkText, Text: "Plan ready."}, {Type: provider.ChunkDone}},
438 }}
439 sess := NewSession("CHILD-SYSTEM")
440 ctx := WithToolCallContext(context.Background(), "parent", event.Discard, nil, true)
441 answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the change", Options{}, event.Discard)
442 if err != nil {
443 t.Fatalf("Plan child: %v", err)
444 }
445 if answer != "Plan ready." {
446 t.Fatalf("Plan child answer = %q", answer)
447 }
448 if len(prov.requests) < 1 {
449 t.Fatal("Plan child made no provider request")
450 }
451 var user string
452 for _, msg := range prov.requests[0].Messages {
453 if msg.Role == provider.RoleUser {
454 user = msg.Content
455 break
456 }
457 }
458 if !strings.Contains(user, planmode.Marker) {
459 t.Fatalf("Plan child user turn missing workflow marker: %q", user)
460 }
461 if got := lastToolResult(sess, "complete_step"); !strings.Contains(got, "only available after plan approval") {
462 t.Fatalf("Plan child complete_step result = %q", got)
463 }
464 }
465
466 func TestCallContextMirrorsPlanModeOntoLeafKey(t *testing.T) {
467 on := withCallContext(context.Background(), "c", event.Discard, nil, true)
468 if !PlanModeFromContext(on) || !planmode.Active(on) {
469 t.Fatal("plan-mode flags disagree for an active planning call")
470 }
471 off := withCallContext(context.Background(), "c", event.Discard, nil, false)
472 if PlanModeFromContext(off) || planmode.Active(off) {
473 t.Fatal("plan-mode flags disagree for a standard call")
474 }
475 if !planmode.Active(WithToolCallContext(context.Background(), "c", event.Discard, nil, true)) {
476 t.Fatal("host-initiated wrapper lost the leaf plan-mode flag")
477 }
478 }
479
479 lines GO