返回 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(), &a.turn, 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(), &a.turn, 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(), &a.turn, 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(), &a.turn, 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(), &a.turn, provider.ToolCall{Name: "complete_step"})
175 if out.blocked || !strings.Contains(out.output, "tool_retired") {
176 t.Fatalf("retired tool 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(), &a.turn, 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(), &a.turn, 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(), &a.turn, 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 a.SeedTodoState([]evidence.TodoItem{{Content: "old execution step", Status: "in_progress"}})
246 a.SetPlanMode(true)
247
248 batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{
249 ID: "new-plan",
250 Name: "todo_write",
251 Arguments: `{"todos":[
252 {"content":"inspect the new request","status":"in_progress"},
253 {"content":"draft a revised plan","status":"pending"}
254 ]}`,
255 }})
256 if len(batch.results) != 1 || strings.HasPrefix(batch.results[0], "error:") {
257 t.Fatalf("plan-mode todo replacement was blocked: %+v", batch.results)
258 }
259 got := a.CanonicalTodoState()
260 if len(got) != 2 || got[0].Content != "inspect the new request" {
261 t.Fatalf("plan-mode todo state = %+v, want revised plan", got)
262 }
263 }
264
265 func TestPlanModeTodoWriteCanCompleteCurrentItem(t *testing.T) {
266 reg := tool.NewRegistry()
267 reg.Add(mustBuiltinTool(t, "todo_write"))
268 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
269 a.SeedTodoState([]evidence.TodoItem{
270 {Content: "inspect the request", Status: "in_progress"},
271 {Content: "draft a plan", Status: "pending"},
272 })
273 a.SetPlanMode(true)
274
275 batch := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{
276 ID: "mark-done",
277 Name: "todo_write",
278 Arguments: `{"todos":[
279 {"content":"inspect the request","status":"completed"},
280 {"content":"draft a plan","status":"in_progress"}
281 ]}`,
282 }})
283 if len(batch.results) != 1 || strings.HasPrefix(batch.results[0], "error:") {
284 t.Fatalf("plan-mode todo completion was blocked: %+v", batch.results)
285 }
286 got := a.CanonicalTodoState()
287 if len(got) != 2 || got[0].Status != "completed" || got[1].Status != "in_progress" {
288 t.Fatalf("plan-mode todo state = %+v, want first item completed", got)
289 }
290 }
291
292 func TestPlanModeTodoCreatedInTurnUsesTodoWriteRecovery(t *testing.T) {
293 reg := tool.NewRegistry()
294 reg.Add(mustBuiltinTool(t, "todo_write"))
295 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
296 a.SetPlanMode(true)
297
298 created := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{
299 ID: "todo",
300 Name: "todo_write",
301 Arguments: `{"todos":[
302 {"content":"finish the cleanup","status":"in_progress"}
303 ]}`,
304 }})
305 if len(created.results) != 1 || strings.HasPrefix(created.results[0], "error:") {
306 t.Fatalf("create Plan todo outcome = %+v", created.results)
307 }
308
309 signoff := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
310 ID: "sign-off",
311 Name: "complete_step",
312 Arguments: `{"result":"cleanup finished"}`,
313 })
314 if !strings.Contains(signoff.output, "retired") {
315 t.Fatalf("Plan complete_step outcome = %+v, want retirement result", signoff)
316 }
317 if got := a.CanonicalTodoState(); len(got) != 1 || got[0].Status != "in_progress" {
318 t.Fatalf("blocked sign-off changed canonical todos = %+v", got)
319 }
320
321 completed := a.executeBatch(context.Background(), &a.turn, []provider.ToolCall{{
322 ID: "complete-todo",
323 Name: "todo_write",
324 Arguments: `{"todos":[
325 {"content":"finish the cleanup","status":"completed"}
326 ]}`,
327 }})
328 if len(completed.results) != 1 || strings.HasPrefix(completed.results[0], "error:") {
329 t.Fatalf("todo_write recovery outcome = %+v", completed.results)
330 }
331 if got := a.CanonicalTodoState(); len(got) != 1 || got[0].Status != "completed" {
332 t.Fatalf("todo_write recovery state = %+v, want completed", got)
333 }
334 }
335
336 func TestPlanModeKeepsCompleteStepUnavailable(t *testing.T) {
337 reg := tool.NewRegistry()
338 a := New(nil, reg, NewSession(""), Options{}, event.Discard)
339 a.SeedTodoState([]evidence.TodoItem{{Content: "inspect the request", Status: "in_progress"}})
340 a.SetPlanMode(true)
341
342 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{
343 ID: "sign-off",
344 Name: "complete_step",
345 Arguments: `{
346 "step":"inspect the request",
347 "result":"inspected",
348 "evidence":[{"kind":"manual","summary":"checked"}]
349 }`,
350 })
351 if out.blocked {
352 t.Fatalf("retired tool should be a normal result: %+v", out)
353 }
354 if !strings.Contains(out.output, "tool_retired") {
355 t.Fatalf("plan-mode complete_step = %+v, want retirement guidance", out)
356 }
357 got := a.CanonicalTodoState()
358 if len(got) != 1 || got[0].Status != "in_progress" {
359 t.Fatalf("blocked complete_step advanced canonical todos: %+v", got)
360 }
361 }
362
363 // TestPlanModeDoesNotMutateSystemOrTools is the cache-stability test. Toggling
364 // plan mode between two stream calls must not change the system prompt or the
365 // tool list seen by the provider — those are the cache-key prefix, and any
366 // change there forces an expensive cache miss.
367 func TestPlanModeDoesNotMutateSystemOrTools(t *testing.T) {
368 prov := &mockProvider{name: "p", chunks: []provider.Chunk{
369 {Type: provider.ChunkText, Text: "ok"},
370 {Type: provider.ChunkDone},
371 }}
372 reg := tool.NewRegistry()
373 reg.Add(fakeTool{name: "read_file", readOnly: true})
374 reg.Add(fakeTool{name: "write_file"})
375 a := New(prov, reg, NewSession("STABLE-SYS"), Options{}, event.Discard)
376
377 if err := a.Run(context.Background(), "explore"); err != nil {
378 t.Fatalf("standard Run: %v", err)
379 }
380 standardSystem := prov.lastReq.Messages[0]
381 standardTools := serializeToolSchemas(t, prov.lastReq.Tools)
382
383 prov.chunks = []provider.Chunk{{Type: provider.ChunkText, Text: "ok"}, {Type: provider.ChunkDone}}
384 a.SetPlanMode(true)
385 if err := a.Run(context.Background(), "now in plan mode"); err != nil {
386 t.Fatalf("Plan Run: %v", err)
387 }
388 planSystem := prov.lastReq.Messages[0]
389 planTools := serializeToolSchemas(t, prov.lastReq.Tools)
390
391 if planSystem.Role != standardSystem.Role || planSystem.Content != standardSystem.Content {
392 t.Fatalf("system message changed across Plan toggle:\nstandard=%+v\nplan=%+v", standardSystem, planSystem)
393 }
394 if planTools != standardTools {
395 t.Fatalf("tool schemas changed across Plan toggle:\nstandard=%s\nplan=%s", standardTools, planTools)
396 }
397 }
398
399 func serializeToolSchemas(t *testing.T, schemas []provider.ToolSchema) string {
400 t.Helper()
401 b, err := json.Marshal(schemas)
402 if err != nil {
403 t.Fatalf("serialize tool schemas: %v", err)
404 }
405 return string(b)
406 }
407
408 func TestUnauthorizedMCPReaderBlockedInMainPlanAndExcludedFromReadOnlyAgents(t *testing.T) {
409 parent := tool.NewRegistry()
410 parent.Add(fakeTool{name: "read_file", readOnly: true})
411 parent.Add(annotatedMCPTool{
412 fakeTool: fakeTool{name: "mcp__srv__query", readOnly: true},
413 server: "srv",
414 raw: "query",
415 serverAuthorized: false,
416 })
417 gate := &recordingPermissionGate{allow: true}
418 a := New(nil, parent, NewSession(""), Options{Gate: gate}, event.Discard)
419 a.SetPlanMode(true)
420
421 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "mcp__srv__query"})
422 if !out.blocked || len(gate.calls) != 0 {
423 t.Fatalf("main Plan MCP reader outcome=%+v calls=%+v", out, gate.calls)
424 }
425
426 for name, filtered := range map[string]*tool.Registry{
427 "planner": FilterReadOnlyRegistry(parent),
428 "subagent": ReadOnlySubagentToolRegistry(parent, nil),
429 } {
430 if _, ok := filtered.Get("read_file"); !ok {
431 t.Fatalf("%s registry lost local reader", name)
432 }
433 if _, ok := filtered.Get("mcp__srv__query"); ok {
434 t.Fatalf("%s registry admitted reader from unauthorized server", name)
435 }
436 }
437 }
438
439 func TestPlanModeMCPWriterIsHardBlockedBeforePermission(t *testing.T) {
440 reg := tool.NewRegistry()
441 reg.Add(annotatedMCPTool{fakeTool: fakeTool{name: "mcp__srv__write"}, server: "srv", raw: "write"})
442 gate := &mcpPermissionRecordingGate{allowNormal: true}
443 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
444 a.SetPlanMode(true)
445
446 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "mcp__srv__write"})
447 if !out.blocked || gate.normalCalls != 0 {
448 t.Fatalf("MCP writer outcome=%+v gate=%+v", out, gate)
449 }
450 }
451
452 func TestPlanModeMCPWriterHonorsPermissionDenial(t *testing.T) {
453 var executions int32
454 reg := tool.NewRegistry()
455 reg.Add(annotatedMCPTool{
456 fakeTool: fakeTool{name: "mcp__srv__write", calls: &executions},
457 server: "srv",
458 raw: "write",
459 })
460 gate := &mcpPermissionRecordingGate{reason: "denied by policy"}
461 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
462 a.SetPlanMode(true)
463
464 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "mcp__srv__write"})
465 if !out.blocked || !strings.Contains(out.output, "Plan mode") || gate.normalCalls != 0 || executions != 0 {
466 t.Fatalf("denied MCP writer outcome=%+v gate=%+v executions=%d", out, gate, executions)
467 }
468 }
469
470 func TestDestructiveMCPUsesFreshApprovalInPlanEvenWhenReadOnly(t *testing.T) {
471 reg := tool.NewRegistry()
472 reg.Add(annotatedMCPTool{
473 fakeTool: fakeTool{name: "mcp__srv__danger", readOnly: true},
474 server: "srv",
475 raw: "danger/raw",
476 destructive: true,
477 })
478 gate := &mcpPermissionRecordingGate{allowNormal: true}
479 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
480 a.SetPlanMode(true)
481
482 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "mcp__srv__danger"})
483 if !out.blocked || gate.normalCalls != 0 {
484 t.Fatalf("destructive MCP outcome=%+v gate=%+v", out, gate)
485 }
486 }
487
488 func TestDestructiveMCPFailsClosedWithoutFreshApprovalGate(t *testing.T) {
489 reg := tool.NewRegistry()
490 reg.Add(annotatedMCPTool{
491 fakeTool: fakeTool{name: "mcp__srv__danger"},
492 server: "srv",
493 raw: "danger",
494 destructive: true,
495 })
496 ordinary := &recordingPermissionGate{allow: true}
497 a := New(nil, reg, NewSession(""), Options{Gate: ordinary}, event.Discard)
498 a.SetPlanMode(true)
499
500 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "mcp__srv__danger"})
501 if !out.blocked || !strings.Contains(out.output, "Plan mode") {
502 t.Fatalf("destructive MCP fail-closed outcome = %+v", out)
503 }
504 if len(ordinary.calls) != 0 {
505 t.Fatalf("destructive MCP fell back to ordinary gate: %+v", ordinary.calls)
506 }
507 }
508
509 func TestPlanModeOffStillUsesSamePermissionGate(t *testing.T) {
510 reg := tool.NewRegistry()
511 reg.Add(fakeTool{name: "write_file"})
512 gate := &recordingPermissionGate{allow: true}
513 a := New(nil, reg, NewSession(""), Options{Gate: gate}, event.Discard)
514
515 out := a.executeOne(context.Background(), &a.turn, provider.ToolCall{Name: "write_file"})
516 if out.blocked || len(gate.calls) != 1 {
517 t.Fatalf("standard mode outcome=%+v calls=%+v", out, gate.calls)
518 }
519 }
520
521 func TestRunSubAgentWithSessionInheritsPlanWorkflow(t *testing.T) {
522 reg := tool.NewRegistry()
523 prov := &scriptedProvider{name: "plan-child", turns: [][]provider.Chunk{
524 {toolCallChunk("phase", "complete_step", `{}`), {Type: provider.ChunkDone}},
525 {{Type: provider.ChunkText, Text: "Plan ready."}, {Type: provider.ChunkDone}},
526 }}
527 sess := NewSession("CHILD-SYSTEM")
528 ctx := WithToolCallContext(context.Background(), "parent", event.Discard, nil, true)
529 answer, err := RunSubAgentWithSession(ctx, prov, reg, sess, "inspect the change", Options{}, event.Discard)
530 if err != nil {
531 t.Fatalf("Plan child: %v", err)
532 }
533 if answer != "Plan ready." {
534 t.Fatalf("Plan child answer = %q", answer)
535 }
536 if len(prov.requests) < 1 {
537 t.Fatal("Plan child made no provider request")
538 }
539 var user string
540 for _, msg := range prov.requests[0].Messages {
541 if msg.Role == provider.RoleUser {
542 user = msg.Content
543 break
544 }
545 }
546 if !strings.Contains(user, planmode.Marker) {
547 t.Fatalf("Plan child user turn missing workflow marker: %q", user)
548 }
549 if got := lastToolResult(sess, "complete_step"); !strings.Contains(got, "tool_retired") {
550 t.Fatalf("Plan child complete_step result = %q", got)
551 }
552 }
553
554 func TestCallContextMirrorsPlanModeOntoLeafKey(t *testing.T) {
555 on := withCallContext(context.Background(), "c", event.Discard, nil, true)
556 if !PlanModeFromContext(on) || !planmode.Active(on) {
557 t.Fatal("plan-mode flags disagree for an active planning call")
558 }
559 off := withCallContext(context.Background(), "c", event.Discard, nil, false)
560 if PlanModeFromContext(off) || planmode.Active(off) {
561 t.Fatal("plan-mode flags disagree for a standard call")
562 }
563 if !planmode.Active(WithToolCallContext(context.Background(), "c", event.Discard, nil, true)) {
564 t.Fatal("host-initiated wrapper lost the leaf plan-mode flag")
565 }
566 }
567
567 lines GO