diff --git a/src/main/claude/claude-structured-inbound-control.test.ts b/src/main/claude/claude-structured-inbound-control.test.ts index 20d0b9dd06a..43c96ee8bea 100644 --- a/src/main/claude/claude-structured-inbound-control.test.ts +++ b/src/main/claude/claude-structured-inbound-control.test.ts @@ -41,10 +41,12 @@ function callbacksFor() { describe('Claude permission callbacks', () => { it('registers a decodable can_use_tool as a durable prompt and settles it from the registry', async () => { const control = callbacksFor() + const controller = new AbortController() + const removeListener = vi.spyOn(controller.signal, 'removeEventListener') const answered = control.canUseTool( 'Bash', { command: 'git status' }, - permissionOptions('perm-1', 'tool-1', new AbortController().signal, [ + permissionOptions('perm-1', 'tool-1', controller.signal, [ { type: 'addRules', rules: [], behavior: 'allow', destination: 'session' } ]) ) @@ -63,6 +65,7 @@ describe('Claude permission callbacks', () => { // The prompt's settle is the SDK callback's own resolve — answering resolves this promise. found?.prompt.settle({ behavior: 'allow', toolUseID: 'tool-1' }) await expect(answered).resolves.toEqual({ behavior: 'allow', toolUseID: 'tool-1' }) + expect(removeListener).toHaveBeenCalledTimes(1) }) it('keeps the SDK permission presentation and strips terminal escapes', async () => { diff --git a/src/main/claude/claude-structured-inbound-control.ts b/src/main/claude/claude-structured-inbound-control.ts index 8d2954f028f..b2bed91f1f4 100644 --- a/src/main/claude/claude-structured-inbound-control.ts +++ b/src/main/claude/claude-structured-inbound-control.ts @@ -56,6 +56,11 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep } { const canUseTool: CanUseTool = (toolName, input, options) => new Promise((resolve) => { + let cancel = (): void => {} + const settle = (response: PermissionResult | null): void => { + options.signal.removeEventListener('abort', cancel) + resolve(response) + } // Classify first so later permission-mode policy cannot swallow a plan proposal. const subject = claudePermissionSubject(toolName, input) const prompt = deps.prompts.register({ @@ -66,14 +71,14 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep toolUseId: options.toolUseID, input, suggestions: options.suggestions ?? [], - settle: resolve, + settle, turnId: deps.currentTurnId?.() ?? null }) if (!prompt) { - resolve(denySafeResult(options.toolUseID)) + settle(denySafeResult(options.toolUseID)) return } - const cancel = (): void => { + cancel = (): void => { if (deps.prompts.forgetIfPending(prompt)) { deps.emit({ type: 'prompt-cancelled', @@ -82,7 +87,7 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep }) // Null is the SDK's "no response written" sentinel: a cancelled request must not // be answered, only forgotten. - resolve(null) + settle(null) } } if (options.signal.aborted) {