mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
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:
@@ -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> };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user