fix(cli): continue execution after plan mode in YOLO/bypassPermissions (#406)

* 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 <noreply@hapi.run>

* test(cli): remove unused isPlanTool helper

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>

---------

Co-authored-by: HAPI <noreply@hapi.run>
This commit is contained in:
Haoqing Wang
2026-04-06 20:42:06 +08:00
committed by GitHub
co-authored by HAPI
parent 7ba15d1e75
commit bca7521823
2 changed files with 116 additions and 0 deletions
@@ -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);
});
});
@@ -290,6 +290,14 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
//
if (!isQuestionTool && this.permissionMode === 'bypassPermissions') {
// In bypassPermissions (YOLO) mode, exit_plan_mode needs special
// handling: inject PLAN_FAKE_RESTART so the agent continues after
// the plan, rather than stalling and waiting for user input.
if (toolName === 'exit_plan_mode' || toolName === 'ExitPlanMode') {
logger.debug('Plan mode exit in bypassPermissions — injecting PLAN_FAKE_RESTART');
this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: this.permissionMode });
return { behavior: 'deny', message: PLAN_FAKE_REJECT };
}
return { behavior: 'allow', updatedInput: input as Record<string, unknown> };
}