From bca75218231d51754e3b1cc202432548c0bb1b9d Mon Sep 17 00:00:00 2001 From: Haoqing Wang <78337154+hqhq1025@users.noreply.github.com> Date: Mon, 6 Apr 2026 20:42:06 +0800 Subject: [PATCH] fix(cli): continue execution after plan mode in YOLO/bypassPermissions (#406) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(cli): continue execution after plan mode in YOLO/bypassPermissions In YOLO mode (bypassPermissions), exit_plan_mode was auto-approved like any other tool, skipping the PLAN_FAKE_RESTART injection that tells the agent to continue. Combined with isAborted() always returning true for exit_plan_mode, claudeRemote exited the query loop and stalled waiting for user input. Fix: in the bypassPermissions branch of handleToolCall, intercept exit_plan_mode specifically — inject PLAN_FAKE_RESTART into the message queue and return deny with PLAN_FAKE_REJECT, matching the behavior of the normal approval flow. Closes #172 via [HAPI](https://hapi.run) Co-Authored-By: HAPI * test(cli): remove unused isPlanTool helper via [HAPI](https://hapi.run) Co-Authored-By: HAPI --------- Co-authored-by: HAPI --- .../claude/utils/permissionHandler.test.ts | 108 ++++++++++++++++++ cli/src/claude/utils/permissionHandler.ts | 8 ++ 2 files changed, 116 insertions(+) create mode 100644 cli/src/claude/utils/permissionHandler.test.ts diff --git a/cli/src/claude/utils/permissionHandler.test.ts b/cli/src/claude/utils/permissionHandler.test.ts new file mode 100644 index 00000000..062de88b --- /dev/null +++ b/cli/src/claude/utils/permissionHandler.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it, vi } from 'vitest'; +import { PermissionHandler } from './permissionHandler'; +import { PLAN_FAKE_REJECT, PLAN_FAKE_RESTART } from '../sdk/prompts'; +import type { Session } from '../session'; + +function createFakeSession() { + const queueItems: { message: string; mode: unknown }[] = []; + + const session = { + client: { + rpcHandlerManager: { + registerHandler: vi.fn(), + }, + updateAgentState: vi.fn(), + }, + queue: { + unshift: vi.fn((message: string, mode: unknown) => { + queueItems.push({ message, mode }); + }), + }, + setPermissionMode: vi.fn(), + } as unknown as Session; + + return { session, queueItems }; +} + +describe('PermissionHandler — YOLO plan mode', () => { + it('injects PLAN_FAKE_RESTART and denies exit_plan_mode in bypassPermissions', async () => { + const { session, queueItems } = createFakeSession(); + const handler = new PermissionHandler(session); + handler.handleModeChange('bypassPermissions'); + + // Simulate Claude emitting an assistant message with exit_plan_mode tool_use + handler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'tc-1', name: 'exit_plan_mode', input: {} }], + }, + } as any); + + const result = await handler.handleToolCall( + 'exit_plan_mode', + {}, + { permissionMode: 'bypassPermissions' } as any, + { signal: new AbortController().signal } + ); + + // Should deny with PLAN_FAKE_REJECT (so Claude restarts) + expect(result.behavior).toBe('deny'); + expect(result).toEqual({ behavior: 'deny', message: PLAN_FAKE_REJECT }); + + // Should inject PLAN_FAKE_RESTART into the queue + expect(queueItems).toHaveLength(1); + expect(queueItems[0].message).toBe(PLAN_FAKE_RESTART); + expect(queueItems[0].mode).toEqual({ permissionMode: 'bypassPermissions' }); + }); + + it('injects PLAN_FAKE_RESTART for ExitPlanMode variant', async () => { + const { session, queueItems } = createFakeSession(); + const handler = new PermissionHandler(session); + handler.handleModeChange('bypassPermissions'); + + handler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'tc-2', name: 'ExitPlanMode', input: {} }], + }, + } as any); + + const result = await handler.handleToolCall( + 'ExitPlanMode', + {}, + { permissionMode: 'bypassPermissions' } as any, + { signal: new AbortController().signal } + ); + + expect(result.behavior).toBe('deny'); + expect(result).toEqual({ behavior: 'deny', message: PLAN_FAKE_REJECT }); + expect(queueItems).toHaveLength(1); + expect(queueItems[0].message).toBe(PLAN_FAKE_RESTART); + }); + + it('allows normal tools in bypassPermissions without queue injection', async () => { + const { session, queueItems } = createFakeSession(); + const handler = new PermissionHandler(session); + handler.handleModeChange('bypassPermissions'); + + handler.onMessage({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'tool_use', id: 'tc-3', name: 'Bash', input: { command: 'ls' } }], + }, + } as any); + + const result = await handler.handleToolCall( + 'Bash', + { command: 'ls' }, + { permissionMode: 'bypassPermissions' } as any, + { signal: new AbortController().signal } + ); + + expect(result.behavior).toBe('allow'); + expect(queueItems).toHaveLength(0); + }); +}); diff --git a/cli/src/claude/utils/permissionHandler.ts b/cli/src/claude/utils/permissionHandler.ts index 444bf7af..b2f0ae88 100644 --- a/cli/src/claude/utils/permissionHandler.ts +++ b/cli/src/claude/utils/permissionHandler.ts @@ -290,6 +290,14 @@ export class PermissionHandler extends BasePermissionHandler }; }