Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions cli/src/claude/utils/permissionHandler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,81 @@ function createFakeSession() {
return { session, queueItems };
}

async function waitForPendingRequest(handler: PermissionHandler, timeout = 2000): Promise<void> {
const start = Date.now();
while (handler['pendingRequests'].size === 0) {
if (Date.now() - start > timeout) throw new Error('Timed out waiting for pending request');
await new Promise(r => setTimeout(r, 10));
}
}

function getRpcHandler(session: ReturnType<typeof createFakeSession>['session']) {
const calls = (session.client.rpcHandlerManager.registerHandler as ReturnType<typeof vi.fn>).mock.calls;
return calls.find((call: string[]) => call[0] === 'permission')![1];
}

describe('PermissionHandler — ExitPlanMode preserves current mode', () => {
it('preserves default mode when ExitPlanMode approved in default mode', async () => {
const { session, queueItems } = createFakeSession();
const handler = new PermissionHandler(session);
handler.handleModeChange('default');

handler.onMessage({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'tc-plan3', name: 'ExitPlanMode', input: {} }],
},
} as any);

const toolCallPromise = handler.handleToolCall(
'ExitPlanMode',
{},
{ permissionMode: 'default' } as any,
{ signal: new AbortController().signal }
);

await waitForPendingRequest(handler);
await getRpcHandler(session)({ id: 'tc-plan3', approved: true });

const result = await toolCallPromise;
expect(result.behavior).toBe('deny');

expect(queueItems).toHaveLength(1);
expect(queueItems[0].mode).toEqual({ permissionMode: 'default' });
});

it('falls back to default mode when ExitPlanMode approved while in plan mode', async () => {
const { session, queueItems } = createFakeSession();
const handler = new PermissionHandler(session);
handler.handleModeChange('plan');

handler.onMessage({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'tc-plan4', name: 'ExitPlanMode', input: {} }],
},
} as any);

const toolCallPromise = handler.handleToolCall(
'ExitPlanMode',
{},
{ permissionMode: 'plan' } as any,
{ signal: new AbortController().signal }
);

await waitForPendingRequest(handler);
await getRpcHandler(session)({ id: 'tc-plan4', approved: true });

const result = await toolCallPromise;
expect(result.behavior).toBe('deny');

expect(queueItems).toHaveLength(1);
expect(queueItems[0].mode).toEqual({ permissionMode: 'default' });
});
});

describe('PermissionHandler — YOLO plan mode', () => {
it('injects PLAN_FAKE_RESTART and denies exit_plan_mode in bypassPermissions', async () => {
const { session, queueItems } = createFakeSession();
Expand Down
11 changes: 6 additions & 5 deletions cli/src/claude/utils/permissionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,11 +257,12 @@ export class PermissionHandler extends BasePermissionHandler<PermissionResponse,
if (response.approved) {
logger.debug('Plan approved - injecting PLAN_FAKE_RESTART');
// Inject the approval message at the beginning of the queue
if (response.mode && PLAN_EXIT_MODES.includes(response.mode)) {
this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: response.mode });
} else {
this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: 'default' });
}
const nextMode = response.mode && PLAN_EXIT_MODES.includes(response.mode)
? response.mode
: this.permissionMode === 'plan'
? 'default'
: this.permissionMode;
this.session.queue.unshift(PLAN_FAKE_RESTART, { permissionMode: nextMode });
pending.resolve({ behavior: 'deny', message: PLAN_FAKE_REJECT });
} else {
pending.resolve({ behavior: 'deny', message: response.reason || 'Plan rejected' });
Expand Down
69 changes: 69 additions & 0 deletions cli/src/claude/utils/sdkToLogConverter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,6 +382,75 @@ describe('SDKToLogConverter', () => {
})
})

describe('Sidechain UUID chain', () => {
it('should not break sidechain chain with system init messages', () => {
// Simulate a subagent: prompt → system init → assistant with tool_use
const taskToolUseId = 'toolu_task123'

// 1. Create the sidechain prompt message (like convertSidechainUserMessage)
const promptMsg = converter.convertSidechainUserMessage(taskToolUseId, 'Do something')
const promptUuid = promptMsg.uuid

// 2. System init message (sidechain) — this is skipped by web UI normalizer
const systemInit = converter.convert({
type: 'system',
subtype: 'init',
session_id: 'subagent-session-456',
model: 'claude-sonnet-4-6',
parent_tool_use_id: taskToolUseId
} as unknown as SDKSystemMessage)

// 3. Subagent's assistant message with tool_use
const assistantMsg = converter.convert({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'tool_use', id: 'toolu_bash1', name: 'Bash', input: { command: 'npm test' } }]
},
parent_tool_use_id: taskToolUseId
} as unknown as SDKAssistantMessage)

// The assistant message should chain to the prompt UUID, not the system init UUID,
// because the system init is invisible to the tracer.
expect(systemInit?.isSidechain).toBe(true)
expect(systemInit?.parentUuid).toBe(promptUuid)
expect(assistantMsg?.isSidechain).toBe(true)
expect(assistantMsg?.parentUuid).toBe(promptUuid)
})

it('should chain visible system messages normally in sidechain', () => {
const taskToolUseId = 'toolu_task456'

// Prompt
const promptMsg = converter.convertSidechainUserMessage(taskToolUseId, 'Do something')
const promptUuid = promptMsg.uuid

// api_error system message (visible in normalizer)
const apiError = converter.convert({
type: 'system',
subtype: 'api_error',
parent_tool_use_id: taskToolUseId,
error: { message: 'rate limited' },
retryAttempt: 1,
maxRetries: 3
} as unknown as SDKSystemMessage)
const apiErrorUuid = apiError!.uuid

// Next assistant message should chain to the api_error (visible message)
const assistantMsg = converter.convert({
type: 'assistant',
message: {
role: 'assistant',
content: [{ type: 'text', text: 'Retrying...' }]
},
parent_tool_use_id: taskToolUseId
} as unknown as SDKAssistantMessage)

expect(apiError?.parentUuid).toBe(promptUuid)
expect(assistantMsg?.parentUuid).toBe(apiErrorUuid)
})
})

describe('Tool results with mode', () => {
it('should add mode to tool result when available in responses', () => {
const responses = new Map<string, { approved: boolean; mode?: ClaudePermissionMode; reason?: string }>()
Expand Down
10 changes: 9 additions & 1 deletion cli/src/claude/utils/sdkToLogConverter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,15 @@ export class SDKToLogConverter {
if (sdkMessage.parent_tool_use_id) {
isSidechain = true;
parentUuid = this.sidechainLastUUID.get((sdkMessage as any).parent_tool_use_id) ?? null;
this.sidechainLastUUID.set((sdkMessage as any).parent_tool_use_id!, uuid);
// System init messages are skipped by the web UI normalizer, so their
// UUIDs are never registered in the tracer's uuidToSidechainId map.
// If we update the chain here, subsequent messages chain to a UUID
// the tracer can never resolve, orphaning the entire sidechain.
const isInvisibleToTracer = sdkMessage.type === 'system'
&& ((sdkMessage as SDKSystemMessage).subtype === 'init' || (sdkMessage as SDKSystemMessage).subtype === 'result');
if (!isInvisibleToTracer) {
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MAJOR] This still advances the sidechain parent map to system records that the tracer cannot resolve. api_error/turn-duration/compact system records are normalized as role: 'event' without UUID-bearing agent content, and traceMessages() only stores UUIDs from sidechain agent messages, so the next sidechain assistant becomes an orphan when its parentUuid is this system UUID.

Suggested fix:

const shouldAdvanceSidechainChain = sdkMessage.type !== 'system';
if (shouldAdvanceSidechainChain) {
    this.sidechainLastUUID.set((sdkMessage as any).parent_tool_use_id!, uuid);
}

this.sidechainLastUUID.set((sdkMessage as any).parent_tool_use_id!, uuid);
}
}
const baseFields = {
parentUuid: parentUuid,
Expand Down
Loading