diff --git a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts index 566f63c21fc..4ff22311fd2 100644 --- a/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts +++ b/integration-tests/sdk-typescript/abort-and-lifecycle.test.ts @@ -492,7 +492,6 @@ describe('AbortController and Process Lifecycle (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, cwd: testDir, - debug: true, stderr: (msg: string) => { stderrMessages.push(msg); }, diff --git a/integration-tests/sdk-typescript/configuration-options.test.ts b/integration-tests/sdk-typescript/configuration-options.test.ts index ca218248c8c..dc4afdca98e 100644 --- a/integration-tests/sdk-typescript/configuration-options.test.ts +++ b/integration-tests/sdk-typescript/configuration-options.test.ts @@ -204,8 +204,8 @@ describe('Configuration Options (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, cwd: testDir, - debug: true, // Would normally enable debug logging - logLevel: 'error', // But logLevel should take precedence + debug: true, + logLevel: 'error', stderr: (msg: string) => { stderrMessages.push(msg); }, @@ -333,7 +333,6 @@ describe('Configuration Options (E2E)', () => { // Common model-related env vars that CLI might respect OPENAI_API_KEY: process.env['OPENAI_API_KEY'] || 'test-key', }, - debug: true, stderr: (msg: string) => { stderrMessages.push(msg); }, @@ -452,8 +451,6 @@ describe('Configuration Options (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, authType: 'qwen-oauth', - debug: true, - logLevel: 'debug', stderr: (msg: string) => { stderrMessages.push(msg); }, @@ -527,7 +524,6 @@ describe('Configuration Options (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, authType: 'openai', - debug: true, stderr: (msg: string) => { stderrMessages.push(msg); }, @@ -555,7 +551,7 @@ describe('Configuration Options (E2E)', () => { }); describe('Combined Options', () => { - it('should work with logLevel, env, and authType together', async () => { + it('should work with env and authType together', async () => { const stderrMessages: string[] = []; const q = query({ @@ -563,12 +559,10 @@ describe('Configuration Options (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, cwd: testDir, - logLevel: 'debug', env: { COMBINED_TEST_VAR: 'combined_value', }, authType: 'openai', - debug: true, stderr: (msg: string) => { stderrMessages.push(msg); }, @@ -587,8 +581,7 @@ describe('Configuration Options (E2E)', () => { } } - // All three options should work together - expect(stderrMessages.length).toBeGreaterThan(0); // logLevel: debug produces logs + // Both options should work together expect(assistantText).toMatch(/6/); // Query should work assertSuccessfulCompletion(messages); } finally { @@ -596,18 +589,16 @@ describe('Configuration Options (E2E)', () => { } }); - it('should maintain system message consistency with all options', async () => { + it('should maintain system message consistency with options', async () => { const q = query({ prompt: 'Hello', options: { ...SHARED_TEST_OPTIONS, cwd: testDir, - logLevel: 'info', env: { SYSTEM_MSG_TEST: 'test', }, authType: 'openai', - debug: false, }, }); diff --git a/integration-tests/sdk-typescript/permission-control.test.ts b/integration-tests/sdk-typescript/permission-control.test.ts index 5ea241db7bc..56c9226a07a 100644 --- a/integration-tests/sdk-typescript/permission-control.test.ts +++ b/integration-tests/sdk-typescript/permission-control.test.ts @@ -338,7 +338,6 @@ describe('Permission Control (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, permissionMode: 'default', - debug: true, }, }); diff --git a/integration-tests/sdk-typescript/session-id.test.ts b/integration-tests/sdk-typescript/session-id.test.ts index 7a2ab435da3..42a36437f6b 100644 --- a/integration-tests/sdk-typescript/session-id.test.ts +++ b/integration-tests/sdk-typescript/session-id.test.ts @@ -47,7 +47,6 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, }, }); @@ -77,7 +76,6 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, }, }); @@ -120,6 +118,7 @@ describe('Session ID Support (E2E)', () => { try { for await (const _message of q) { // Consume all messages + console.log(_message); } // Verify that CLI was spawned with --session-id argument @@ -144,7 +143,6 @@ describe('Session ID Support (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, cwd: testDir, - debug: false, }, }); @@ -181,7 +179,6 @@ describe('Session ID Support (E2E)', () => { cwd: testDir, sessionId: customSessionId, resume: resumeSessionId, - debug: false, }, }); @@ -297,7 +294,6 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: uuid, - debug: false, }, }); @@ -351,7 +347,6 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, }, }); @@ -505,7 +500,6 @@ describe('Session ID Support (E2E)', () => { ...SHARED_TEST_OPTIONS, cwd: testDir, sessionId: customSessionId, - debug: false, }, }); @@ -536,7 +530,6 @@ describe('Session ID Support (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, cwd: testDir, - debug: false, }, }); @@ -545,7 +538,6 @@ describe('Session ID Support (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, cwd: testDir, - debug: false, }, }); diff --git a/integration-tests/sdk-typescript/single-turn.test.ts b/integration-tests/sdk-typescript/single-turn.test.ts index 3608e6194d4..eb1419331b5 100644 --- a/integration-tests/sdk-typescript/single-turn.test.ts +++ b/integration-tests/sdk-typescript/single-turn.test.ts @@ -43,7 +43,6 @@ describe('Single-Turn Query (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, cwd: testDir, - debug: true, }, }); @@ -292,7 +291,6 @@ describe('Single-Turn Query (E2E)', () => { options: { ...SHARED_TEST_OPTIONS, cwd: testDir, - debug: true, stderr: (msg: string) => { stderrMessages.push(msg); }, diff --git a/integration-tests/sdk-typescript/subagents.test.ts b/integration-tests/sdk-typescript/subagents.test.ts index d9fa037f7da..a785d2f3254 100644 --- a/integration-tests/sdk-typescript/subagents.test.ts +++ b/integration-tests/sdk-typescript/subagents.test.ts @@ -501,7 +501,6 @@ OTHER AGENTS CANNOT: ...SHARED_TEST_OPTIONS, cwd: testWorkDir, agents: [testAgent], - debug: true, stderr: (msg: string) => { stderrMessages.push(msg); }, diff --git a/integration-tests/sdk-typescript/tool-control.test.ts b/integration-tests/sdk-typescript/tool-control.test.ts index c4b48fc82ef..1647f82b078 100644 --- a/integration-tests/sdk-typescript/tool-control.test.ts +++ b/integration-tests/sdk-typescript/tool-control.test.ts @@ -18,6 +18,7 @@ import { isSDKResultMessage, type SDKMessage, type SDKUserMessage, + type SDKResultMessage, } from '@qwen-code/sdk'; import { SDKTestHelper, @@ -1475,7 +1476,7 @@ describe('Tool Control Parameters (E2E)', () => { expect(writeFileResults.length).toBeGreaterThan(0); for (const result of writeFileResults) { expect(result.content).toContain( - '[Operation Cancelled] Reason: Write operations are not allowed', + 'Write operations are not allowed', ); } @@ -1573,4 +1574,586 @@ describe('Tool Control Parameters (E2E)', () => { TEST_TIMEOUT, ); }); + + describe('canUseTool deny and interrupt behavior', () => { + it( + 'should interrupt loop when canUseTool returns deny with interrupt: true', + async () => { + await helper.createFile('test.txt', 'test content'); + + const canUseToolCalls: string[] = []; + const resultMessages: SDKMessage[] = []; + + const q = query({ + prompt: 'Write "modified" to test.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + coreTools: ['write_file'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + // Deny write_file with interrupt + if (toolName === 'write_file') { + return { + behavior: 'deny', + message: 'User denied file write', + interrupt: true, + }; + } + return { behavior: 'allow', updatedInput: {} }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let processExited = false; + + try { + for await (const message of q) { + messages.push(message); + if (isSDKResultMessage(message)) { + resultMessages.push(message); + } + } + } catch { + // CLI may exit with code 130 (SIGINT) when interrupt is triggered + // This is expected behavior + processExited = true; + } finally { + await q.close(); + } + + // Verify canUseTool was called for write_file + expect(canUseToolCalls).toContain('write_file'); + + // Either we got a result message or the process exited (both are valid behaviors) + if (resultMessages.length > 0) { + const resultMsg = resultMessages[0]; + expect(isSDKResultMessage(resultMsg)).toBe(true); + + if (isSDKResultMessage(resultMsg)) { + // Should be error_during_execution + expect(resultMsg.subtype).toBe('error_during_execution'); + expect(resultMsg.is_error).toBe(true); + + // Should have permission_denials + expect(resultMsg.permission_denials).toBeDefined(); + expect(resultMsg.permission_denials.length).toBeGreaterThan(0); + + // Check that write_file is in permission_denials + const writeFileDenial = resultMsg.permission_denials.find( + (d) => d.tool_name === 'write_file', + ); + expect(writeFileDenial).toBeDefined(); + } + } else { + // Process exited due to interrupt - this is also valid + expect(processExited || messages.length > 0).toBe(true); + } + + // Verify file was NOT modified (loop interrupted) + const content = await helper.readFile('test.txt'); + expect(content).toBe('test content'); + }, + TEST_TIMEOUT, + ); + + it( + 'should return EXECUTION_DENIED when canUseTool returns deny without interrupt', + async () => { + await helper.createFile('test.txt', 'test content'); + + const canUseToolCalls: string[] = []; + const resultMessages: SDKResultMessage[] = []; + + const q = query({ + prompt: 'Write "modified" to test.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + coreTools: ['write_file'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + // Deny write_file without interrupt + if (toolName === 'write_file') { + return { + behavior: 'deny', + message: 'User denied file write', + interrupt: false, + }; + } + return { behavior: 'allow', updatedInput: {} }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let processExited = false; + + try { + for await (const message of q) { + messages.push(message); + if (isSDKResultMessage(message)) { + resultMessages.push(message); + } + } + } catch { + processExited = true; + } finally { + await q.close(); + } + + // Verify canUseTool was called for write_file + expect(canUseToolCalls).toContain('write_file'); + + // Key verification: process did NOT exit with SIGINT (code 130) + // because interrupt: false was used + expect(processExited).toBe(false); + + // Find the result message + expect(resultMessages.length).toBeGreaterThan(0); + const resultMsg = resultMessages[resultMessages.length - 1]; + expect(isSDKResultMessage(resultMsg)).toBe(true); + + // File should NOT be modified (write was denied) + const content = await helper.readFile('test.txt'); + expect(content).toBe('test content'); + }, + TEST_TIMEOUT, + ); + + it( + 'should cascade-cancel pending tools when interrupt is triggered', + async () => { + // Create test files + await helper.createFile('file1.txt', 'content1'); + await helper.createFile('file2.txt', 'content2'); + await helper.createFile('file3.txt', 'content3'); + + const canUseToolCalls: Array<{ toolName: string; filePath: string }> = + []; + const resultMessages: SDKResultMessage[] = []; + + const q = query({ + prompt: + 'Write "modified1" to file1.txt, "modified2" to file2.txt, and "modified3" to file3.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + coreTools: ['write_file'], + canUseTool: async (toolName, toolInput) => { + // Track which tools were actually checked by canUseTool with file path + const filePath = + typeof toolInput === 'object' && + toolInput !== null && + 'file_path' in toolInput + ? String((toolInput as { file_path?: string }).file_path) + : 'unknown'; + canUseToolCalls.push({ toolName, filePath }); + + // Deny writing to file2.txt with interrupt + if (toolName === 'write_file' && filePath.includes('file2')) { + return { + behavior: 'deny', + message: 'User denied writing to file2.txt', + interrupt: true, + }; + } + return { behavior: 'allow', updatedInput: {} }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let processExited = false; + + try { + for await (const message of q) { + messages.push(message); + if (isSDKResultMessage(message)) { + resultMessages.push(message); + } + } + } catch { + // CLI may exit with code 130 (SIGINT) when interrupt is triggered + processExited = true; + } finally { + await q.close(); + } + + // Verify canUseTool was called + expect(canUseToolCalls.length).toBeGreaterThan(0); + + // Verify file2.txt denial triggered the interrupt + const file2Call = canUseToolCalls.find((c) => + c.filePath.includes('file2'), + ); + expect(file2Call).toBeDefined(); + + // If we got a result message, verify its contents + if (resultMessages.length > 0) { + const resultMsg = resultMessages[0]; + if (isSDKResultMessage(resultMsg) && resultMsg.is_error) { + // Should have permission_denials for denied/cancelled tools + expect(resultMsg.permission_denials).toBeDefined(); + expect(resultMsg.permission_denials.length).toBeGreaterThan(0); + + // file2.txt denial should be present + const file2Denial = resultMsg.permission_denials.find((d) => { + const input = d.tool_input as { file_path?: string } | undefined; + return input?.file_path?.includes('file2'); + }); + expect(file2Denial).toBeDefined(); + + // Verify cascade-cancelled tools are also in permission_denials + const cascadeCancelledDenials = resultMsg.permission_denials.filter( + (d) => { + const input = d.tool_input as + | { file_path?: string } + | undefined; + return ( + input?.file_path?.includes('file1') || + input?.file_path?.includes('file3') + ); + }, + ); + + // At least one of file1 or file3 should be cascade-cancelled + // (the one that wasn't already executed when interrupt fired) + // Note: Depending on execution order, we may see 0, 1, or 2 cascade-cancelled tools + expect(cascadeCancelledDenials.length).toBeGreaterThanOrEqual(0); + } + } else { + // Process exited due to interrupt - this is also valid + expect(processExited || messages.length > 0).toBe(true); + } + + // Verify file2.txt was NOT modified (it was the one denied with interrupt) + const content2 = await helper.readFile('file2.txt'); + expect(content2).toBe('content2'); + }, + TEST_TIMEOUT, + ); + + it( + 'should include all denied tools in permission_denials (both INTERRUPTED and EXECUTION_DENIED)', + async () => { + await helper.createFile('file1.txt', 'content1'); + await helper.createFile('file2.txt', 'content2'); + + const resultMessages: SDKResultMessage[] = []; + + const q = query({ + prompt: + 'Write "modified1" to file1.txt and "modified2" to file2.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + coreTools: ['write_file'], + canUseTool: async (toolName, toolInput) => { + // Deny file1.txt with interrupt (INTERRUPTED type) + if ( + toolName === 'write_file' && + typeof toolInput === 'object' && + toolInput !== null && + 'file_path' in toolInput && + String( + (toolInput as { file_path?: string }).file_path, + ).includes('file1') + ) { + return { + behavior: 'deny', + message: 'User denied file1.txt', + interrupt: true, + }; + } + // Allow file2.txt + return { behavior: 'allow', updatedInput: {} }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + let processExited = false; + + try { + for await (const message of q) { + messages.push(message); + if (isSDKResultMessage(message)) { + resultMessages.push(message); + } + } + } catch { + // CLI may exit with code 130 (SIGINT) when interrupt is triggered + processExited = true; + } finally { + await q.close(); + } + + // Either we got a result message or the process exited (both are valid behaviors) + if (resultMessages.length > 0) { + const resultMsg = resultMessages[0]; + + // Should be error_during_execution + expect(resultMsg.subtype).toBe('error_during_execution'); + + // Should have permission_denials + expect(resultMsg.permission_denials.length).toBeGreaterThanOrEqual(1); + + // Verify tool names and inputs are recorded + const denialToolNames = resultMsg.permission_denials.map( + (d) => d.tool_name, + ); + expect(denialToolNames).toContain('write_file'); + + // Verify tool_input is recorded + const denialWithInput = resultMsg.permission_denials.find( + (d) => d.tool_input !== undefined, + ); + expect(denialWithInput).toBeDefined(); + } else { + // Process exited due to interrupt - this is also valid + expect(processExited || messages.length > 0).toBe(true); + } + }, + TEST_TIMEOUT, + ); + + it( + 'should emit user message when interrupt is triggered', + async () => { + await helper.createFile('test.txt', 'test content'); + + const userMessages: string[] = []; + const resultMessages: SDKResultMessage[] = []; + + const q = query({ + prompt: 'Write "modified" to test.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + coreTools: ['write_file'], + canUseTool: async (toolName) => { + // Deny write_file with interrupt + if (toolName === 'write_file') { + return { + behavior: 'deny', + message: 'User denied file write', + interrupt: true, + }; + } + return { behavior: 'allow', updatedInput: {} }; + }, + debug: false, + }, + }); + + let processExited = false; + + try { + for await (const message of q) { + // Capture user messages + if (message.type === 'user' && 'message' in message) { + const userMsg = message as SDKUserMessage; + const content = userMsg.message.content; + if (Array.isArray(content)) { + for (const block of content) { + if (block.type === 'text') { + userMessages.push(block.text); + } + } + } else if (typeof content === 'string') { + userMessages.push(content); + } + } + + if (isSDKResultMessage(message)) { + resultMessages.push(message); + } + } + } catch { + // CLI may exit with code 130 (SIGINT) when interrupt is triggered + processExited = true; + } finally { + await q.close(); + } + + // If we got messages, check for interrupt message + // The interrupt message may be sent before process exits + if (userMessages.length > 0) { + const interruptMessage = userMessages.find((msg) => + msg.includes('Request interrupted by user for tool use'), + ); + // Message might be present or process might exit before it's captured + if (interruptMessage) { + expect(interruptMessage).toBeDefined(); + } + } + + // Either we got results/messages or process exited (both valid) + expect( + resultMessages.length > 0 || userMessages.length > 0 || processExited, + ).toBe(true); + }, + TEST_TIMEOUT, + ); + + it( + 'should not affect already executing tools during interrupt', + async () => { + // This test verifies that when interrupt is triggered, + // already completed tools remain successful, and pending tools are cancelled + await helper.createFile('file1.txt', 'content1'); + await helper.createFile('file2.txt', 'content2'); + + const canUseToolCalls: string[] = []; + let file1Allowed = false; + const resultMessages: SDKResultMessage[] = []; + + const q = query({ + prompt: + 'Write "modified1" to file1.txt and "modified2" to file2.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + coreTools: ['write_file'], + canUseTool: async (toolName, toolInput) => { + canUseToolCalls.push(toolName); + + // Allow file1.txt write first + if ( + toolName === 'write_file' && + typeof toolInput === 'object' && + toolInput !== null && + 'file_path' in toolInput && + String( + (toolInput as { file_path?: string }).file_path, + ).includes('file1') && + !file1Allowed + ) { + file1Allowed = true; + return { behavior: 'allow', updatedInput: {} }; + } + + // Deny file2.txt with interrupt (simulating that file1 already executed) + if ( + toolName === 'write_file' && + typeof toolInput === 'object' && + toolInput !== null && + 'file_path' in toolInput && + String( + (toolInput as { file_path?: string }).file_path, + ).includes('file2') + ) { + return { + behavior: 'deny', + message: 'User denied file2.txt write', + interrupt: true, + }; + } + + return { behavior: 'allow', updatedInput: {} }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + if (isSDKResultMessage(message)) { + resultMessages.push(message); + } + } + } catch { + // CLI may exit with code 130 (SIGINT) when interrupt is triggered + // This is expected behavior + } finally { + await q.close(); + } + + // Verify canUseTool was called + expect(canUseToolCalls.length).toBeGreaterThan(0); + + // file2.txt should NOT be modified (it was denied with interrupt) + const content2 = await helper.readFile('file2.txt'); + expect(content2).toBe('content2'); + }, + TEST_TIMEOUT, + ); + + it( + 'should handle canUseTool deny without interrupt field (backward compatibility)', + async () => { + await helper.createFile('test.txt', 'test content'); + + const resultMessages: SDKResultMessage[] = []; + const canUseToolCalls: string[] = []; + + const q = query({ + prompt: 'Write "modified" to test.txt.', + options: { + ...SHARED_TEST_OPTIONS, + cwd: testDir, + permissionMode: 'default', + coreTools: ['write_file'], + canUseTool: async (toolName) => { + canUseToolCalls.push(toolName); + // Deny without interrupt field (should default to false - no interrupt) + if (toolName === 'write_file') { + return { + behavior: 'deny', + message: 'User denied file write', + // Note: no interrupt field - should default to not interrupting + }; + } + return { behavior: 'allow', updatedInput: {} }; + }, + debug: false, + }, + }); + + const messages: SDKMessage[] = []; + + try { + for await (const message of q) { + messages.push(message); + if (isSDKResultMessage(message)) { + resultMessages.push(message); + } + } + + // Verify canUseTool was called + expect(canUseToolCalls).toContain('write_file'); + + // Should complete (no SIGINT caused by interrupt flag) + expect(resultMessages.length).toBeGreaterThan(0); + const lastResult = resultMessages[resultMessages.length - 1]; + + // Result could be success or error_during_execution depending on model behavior + // The key is that no interrupt was triggered + expect(lastResult.is_error).toBeDefined(); + + // File should NOT be modified (write was denied) + const content = await helper.readFile('test.txt'); + expect(content).toBe('test content'); + } finally { + await q.close(); + } + }, + TEST_TIMEOUT, + ); + }); }); diff --git a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts index 0cc402522b2..2cc9642f3eb 100644 --- a/packages/cli/src/nonInteractive/control/controllers/permissionController.ts +++ b/packages/cli/src/nonInteractive/control/controllers/permissionController.ts @@ -449,15 +449,29 @@ export class PermissionController extends BaseController { ToolConfirmationOutcome.ProceedOnce, ); } else { - // Extract cancel message from response if available - const cancelMessage = + // Extract cancel message and interrupt flag from response if available + const sdkMessage = typeof payload['message'] === 'string' ? payload['message'] : undefined; + const interrupt = payload['interrupt'] === true; + + // Build payload with cancelMessage and interrupt flag + const confirmPayload: { cancelMessage?: string; interrupt?: boolean } = + {}; + if (sdkMessage) { + confirmPayload.cancelMessage = `[PermissionDenied] ${sdkMessage}`; + } else { + confirmPayload.cancelMessage = + '[PermissionDenied] User did not allow tool call'; + } + if (interrupt) { + confirmPayload.interrupt = true; + } await toolCall.confirmationDetails.onConfirm( ToolConfirmationOutcome.Cancel, - cancelMessage ? { cancelMessage } : undefined, + confirmPayload, ); } } catch (error) { @@ -472,20 +486,24 @@ export class PermissionController extends BaseController { // On error, pass error message as cancel message // Only pass payload for exec and mcp types that support it + // Note: infrastructure errors do NOT have [PermissionDenied] prefix, + // so they will be treated as regular cancellations, not permission denials const confirmationType = toolCall.confirmationDetails.type; + const errorPayload = { + cancelMessage: `Error: ${errorMessage}`, + }; if (['edit', 'exec', 'mcp'].includes(confirmationType)) { const execOrMcpDetails = toolCall.confirmationDetails as | ToolExecuteConfirmationDetails | ToolMcpConfirmationDetails; - await execOrMcpDetails.onConfirm(ToolConfirmationOutcome.Cancel, { - cancelMessage: `Error: ${errorMessage}`, - }); + await execOrMcpDetails.onConfirm( + ToolConfirmationOutcome.Cancel, + errorPayload, + ); } else { await toolCall.confirmationDetails.onConfirm( ToolConfirmationOutcome.Cancel, - { - cancelMessage: `Error: ${errorMessage}`, - }, + errorPayload, ); } } finally { diff --git a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts index a47a07b7444..4e3a16c7d4d 100644 --- a/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts +++ b/packages/cli/src/nonInteractive/io/BaseJsonOutputAdapter.ts @@ -65,6 +65,7 @@ export interface ResultOptions { readonly stats?: SessionMetrics; readonly summary?: string; readonly subtype?: string; + readonly permission_denials?: CLIPermissionDenial[]; } /** @@ -1008,10 +1009,11 @@ export abstract class BaseJsonOutputAdapter { // Determine if this is an error response const hasError = Boolean(response.error) || Boolean(responsePartsError); - // Track permission denials (execution denied errors) + // Track permission denials (execution denied or interrupted errors) if ( response.error && - response.errorType === ToolErrorType.EXECUTION_DENIED + (response.errorType === ToolErrorType.EXECUTION_DENIED || + response.errorType === ToolErrorType.INTERRUPTED) ) { const denial: CLIPermissionDenial = { tool_name: request.name, @@ -1113,7 +1115,9 @@ export abstract class BaseJsonOutputAdapter { duration_api_ms: options.apiDurationMs, num_turns: options.numTurns, usage, - permission_denials: [...this.permissionDenials], + permission_denials: [ + ...(options.permission_denials ?? this.permissionDenials), + ], error: { message: errorMessage }, }; } else { @@ -1129,7 +1133,9 @@ export abstract class BaseJsonOutputAdapter { num_turns: options.numTurns, result: resultText, usage, - permission_denials: [...this.permissionDenials], + permission_denials: [ + ...(options.permission_denials ?? this.permissionDenials), + ], }; if (options.stats) { diff --git a/packages/cli/src/nonInteractiveCli.ts b/packages/cli/src/nonInteractiveCli.ts index cb5c23c5c03..9b2ac01e2ce 100644 --- a/packages/cli/src/nonInteractiveCli.ts +++ b/packages/cli/src/nonInteractiveCli.ts @@ -4,7 +4,11 @@ * SPDX-License-Identifier: Apache-2.0 */ -import type { Config, ToolCallRequestInfo } from '@qwen-code/qwen-code-core'; +import type { + Config, + ToolCallRequestInfo, + ToolCallResponseInfo, +} from '@qwen-code/qwen-code-core'; import { isSlashCommand } from './ui/utils/commandUtils.js'; import type { LoadedSettings } from './config/settings.js'; import { @@ -20,6 +24,7 @@ import { parseAndFormatApiError, createDebugLogger, SendMessageType, + ToolErrorType, } from '@qwen-code/qwen-code-core'; import type { Content, Part, PartListUnion } from '@google/genai'; import type { CLIUserMessage, PermissionMode } from './nonInteractive/types.js'; @@ -306,6 +311,10 @@ export async function runNonInteractive( if (toolCallRequests.length > 0) { const toolResponseParts: Part[] = []; + const toolResponses: Array<{ + request: ToolCallRequestInfo; + response: ToolCallResponseInfo; + }> = []; for (const requestInfo of toolCallRequests) { const finalRequestInfo = requestInfo; @@ -365,11 +374,95 @@ export async function runNonInteractive( adapter.emitToolResult(finalRequestInfo, toolResponse); + // Track response for interrupt detection + toolResponses.push({ + request: finalRequestInfo, + response: toolResponse, + }); + if (toolResponse.responseParts) { toolResponseParts.push(...toolResponse.responseParts); } + + // Check for interrupt immediately after each tool execution + // If INTERRUPTED, stop processing remaining tools in the batch + if (toolResponse.errorType === ToolErrorType.INTERRUPTED) { + // For remaining unprocessed tools, create INTERRUPTED error responses + const remainingTools = toolCallRequests.slice( + toolCallRequests.indexOf(requestInfo) + 1, + ); + for (const remainingTool of remainingTools) { + const interruptedResponse: ToolCallResponseInfo = { + callId: remainingTool.callId, + responseParts: [ + { + functionResponse: { + id: remainingTool.callId, + name: remainingTool.name, + response: { + output: + toolResponse.error?.message || + 'Request interrupted by user for tool use', + }, + }, + }, + ], + resultDisplay: + toolResponse.error?.message || + 'Request interrupted by user for tool use', + error: + toolResponse.error || + new Error('Request interrupted by user for tool use'), + errorType: ToolErrorType.INTERRUPTED, + }; + toolResponses.push({ + request: remainingTool, + response: interruptedResponse, + }); + if (interruptedResponse.responseParts) { + toolResponseParts.push(...interruptedResponse.responseParts); + } + } + break; + } } currentMessages = [{ role: 'user', parts: toolResponseParts }]; + + // Check for interrupt - any tool with INTERRUPTED error type + const hasInterrupt = toolResponses.some( + ({ response }) => response.errorType === ToolErrorType.INTERRUPTED, + ); + + if (hasInterrupt) { + // Add tool responses to history before interrupting + // This ensures tool call request/response pairs are preserved + await geminiClient.addHistory(currentMessages[0]); + + // Emit user message indicating interrupt + adapter.emitUserMessage( + [{ text: '[Request interrupted by user for tool use]' }], + null, + ); + + // Emit error_during_execution result and break the loop + const metrics = uiTelemetryService.getMetrics(); + const usage = computeUsageFromMetrics(metrics); + const stats = + outputFormat === OutputFormat.JSON + ? uiTelemetryService.getMetrics() + : undefined; + adapter.emitResult({ + isError: true, + subtype: 'error_during_execution', + durationMs: Date.now() - startTime, + apiDurationMs: totalApiDurationMs, + numTurns: turnCount, + errorMessage: 'Request interrupted by user for tool use', + usage, + stats, + }); + return; + } } else { // No more tool calls — check if cron jobs are keeping us alive const scheduler = !config.isCronEnabled() diff --git a/packages/core/src/core/coreToolScheduler.ts b/packages/core/src/core/coreToolScheduler.ts index c8f0c3cb2a3..012c078ac9a 100644 --- a/packages/core/src/core/coreToolScheduler.ts +++ b/packages/core/src/core/coreToolScheduler.ts @@ -1157,7 +1157,36 @@ export class CoreToolScheduler { // Use custom cancel message from payload if provided, otherwise use default const cancelMessage = payload?.cancelMessage || 'User did not allow tool call'; - this.setStatusInternal(callId, 'cancelled', cancelMessage); + + // Determine if this is a permission denial from canUseTool + // Only cancelMessages with [PermissionDenied] prefix are treated as permission denials + // All other cases (infrastructure errors, regular UI cancellations) are treated as regular cancellations + const isPermissionDenial = + payload?.cancelMessage?.startsWith('[PermissionDenied] ') ?? false; + + if (isPermissionDenial) { + // Permission denial from canUseTool - use error status + // Strip the prefix for the actual error message + const actualMessage = cancelMessage.slice('[PermissionDenied] '.length); + const errorType = payload?.interrupt + ? ToolErrorType.INTERRUPTED + : ToolErrorType.EXECUTION_DENIED; + + const errorResponse = createErrorResponse( + toolCall.request, + new Error(actualMessage), + errorType, + ); + this.setStatusInternal(callId, 'error', errorResponse); + + // If interrupt is true, cancel all pending tools with the original denial context + if (payload?.interrupt) { + await this.cancelPendingTools(signal, callId, actualMessage); + } + } else { + // Regular UI cancellation - use cancelled status + this.setStatusInternal(callId, 'cancelled', cancelMessage); + } } else if (outcome === ToolConfirmationOutcome.ModifyWithEditor) { const waitingToolCall = toolCall as WaitingToolCall; if (isModifiableDeclarativeTool(waitingToolCall.tool)) { @@ -1749,4 +1778,38 @@ export class CoreToolScheduler { } } } + + /** + * Cancel all pending tools (awaiting_approval or scheduled) when an interrupt is triggered. + * Cascade-cancelled tools are marked with INTERRUPTED error type. + * @param originalDenialMessage - The original denial reason to include in cascade-cancelled tool messages + */ + private async cancelPendingTools( + signal: AbortSignal, + triggeringCallId: string, + originalDenialMessage?: string, + ): Promise { + const pendingTools = this.toolCalls.filter( + (call) => + (call.status === 'awaiting_approval' || call.status === 'scheduled') && + call.request.callId !== triggeringCallId, + ); + + for (const pendingTool of pendingTools) { + // Cascade-cancelled tools use INTERRUPTED type with context about why + const cascadeMessage = originalDenialMessage + ? `Interrupted: ${originalDenialMessage}` + : "The user doesn't want to take this action right now."; + const errorResponse = createErrorResponse( + pendingTool.request, + new Error(cascadeMessage), + ToolErrorType.INTERRUPTED, + ); + this.setStatusInternal( + pendingTool.request.callId, + 'error', + errorResponse, + ); + } + } } diff --git a/packages/core/src/tools/tool-error.ts b/packages/core/src/tools/tool-error.ts index 96581602f45..aa8420dc0b1 100644 --- a/packages/core/src/tools/tool-error.ts +++ b/packages/core/src/tools/tool-error.ts @@ -16,6 +16,8 @@ export enum ToolErrorType { EXECUTION_FAILED = 'execution_failed', // Try to execute a tool that is excluded due to the approval mode EXECUTION_DENIED = 'execution_denied', + // Tool execution was interrupted by user + INTERRUPTED = 'interrupted', // File System Errors FILE_NOT_FOUND = 'file_not_found', diff --git a/packages/core/src/tools/tools.ts b/packages/core/src/tools/tools.ts index 0d50f351eee..27442685b21 100644 --- a/packages/core/src/tools/tools.ts +++ b/packages/core/src/tools/tools.ts @@ -605,6 +605,8 @@ export interface ToolConfirmationPayload { permissionRules?: string[]; // used to pass user answers from ask_user_question tool answers?: Record; + // used to indicate that the user wants to interrupt the session when denying a tool + interrupt?: boolean; } export interface ToolExecuteConfirmationDetails {