diff --git a/vscode-extension/openclaude-vscode/package.json b/vscode-extension/openclaude-vscode/package.json index 9022fa1701..f5ecba3206 100644 --- a/vscode-extension/openclaude-vscode/package.json +++ b/vscode-extension/openclaude-vscode/package.json @@ -160,7 +160,7 @@ ] }, "scripts": { - "test": "node --test ./src/*.test.js", + "test": "bun test src", "lint": "node scripts/lint.js", "package": "npx @vscode/vsce package --no-dependencies" }, diff --git a/vscode-extension/openclaude-vscode/src/chat/chatProvider.js b/vscode-extension/openclaude-vscode/src/chat/chatProvider.js index 5d71aabdff..eac0ab585a 100644 --- a/vscode-extension/openclaude-vscode/src/chat/chatProvider.js +++ b/vscode-extension/openclaude-vscode/src/chat/chatProvider.js @@ -6,6 +6,7 @@ const vscode = require('vscode'); const crypto = require('crypto'); const { ProcessManager } = require('./processManager'); +const { buildPermissionControlResult } = require('./permissionResponse'); const { toViewModel } = require('./messageParser'); const { renderChatHtml } = require('./chatRenderer'); const { isAssistantMessage, isPartialMessage, isStreamEvent, @@ -50,6 +51,8 @@ class ChatController { this._thinkingTokens = 0; this._thinkingStartTime = null; this._currentBlockType = null; + /** @type {Map, permissionSuggestions: unknown[], toolUseId: string | null }>} */ + this._pendingPermissions = new Map(); this._onDidChangeState = new vscode.EventEmitter(); this.onDidChangeState = this._onDidChangeState.event; @@ -142,6 +145,7 @@ class ChatController { this._process.dispose(); this._process = null; } + this._pendingPermissions.clear(); } async sendMessage(text) { @@ -185,26 +189,15 @@ class ChatController { sendPermissionResponse(requestId, action, toolUseId) { if (!this._process) return; - if (action === 'deny') { - try { - this._process.write({ - type: 'control_response', - response: { - subtype: 'error', - request_id: requestId, - error: 'User denied permission', - }, - }); - } catch (err) { - this._broadcast({ type: 'error', message: err.message }); - } - return; - } + const pending = this._pendingPermissions.get(requestId); + this._pendingPermissions.delete(requestId); + const result = buildPermissionControlResult(action, { + input: pending?.input, + toolUseId: toolUseId || pending?.toolUseId || null, + permissionSuggestions: pending?.permissionSuggestions, + }); try { - this._process.sendControlResponse(requestId, { - toolUseID: toolUseId || undefined, - ...(action === 'allow-session' ? { remember: true } : {}), - }); + this._process.sendControlResponse(requestId, result); } catch (err) { this._broadcast({ type: 'error', message: err.message }); } @@ -231,9 +224,23 @@ class ChatController { if (msg.type === 'control_request' || isControlRequest(msg)) { const req = msg.request || {}; const { toolDisplayName, parseToolInput } = require('./messageParser'); + const requestId = msg.request_id; + const toolInput = + req.input && typeof req.input === 'object' && !Array.isArray(req.input) + ? req.input + : {}; + if (requestId) { + this._pendingPermissions.set(requestId, { + input: toolInput, + permissionSuggestions: Array.isArray(req.permission_suggestions) + ? req.permission_suggestions + : [], + toolUseId: req.tool_use_id || null, + }); + } this._broadcast({ type: 'permission_request', - requestId: msg.request_id, + requestId, toolName: req.tool_name || 'Unknown', displayName: req.display_name || req.title || toolDisplayName(req.tool_name), description: req.description || '', diff --git a/vscode-extension/openclaude-vscode/src/chat/permissionResponse.js b/vscode-extension/openclaude-vscode/src/chat/permissionResponse.js new file mode 100644 index 0000000000..ec6ab93f8b --- /dev/null +++ b/vscode-extension/openclaude-vscode/src/chat/permissionResponse.js @@ -0,0 +1,46 @@ +/** + * Build SDK can_use_tool control_response payloads for the VS Code chat host. + */ + +/** + * @param {'allow' | 'deny' | 'allow-session'} action + * @param {{ + * input?: Record | null, + * toolUseId?: string | null, + * permissionSuggestions?: unknown[] | null, + * }} ctx + */ +function buildPermissionControlResult(action, ctx = {}) { + const toolUseID = ctx.toolUseId || undefined; + const input = + ctx.input && typeof ctx.input === 'object' && !Array.isArray(ctx.input) + ? ctx.input + : {}; + + if (action === 'deny') { + return { + behavior: 'deny', + message: 'User denied permission', + toolUseID, + }; + } + + const result = { + behavior: 'allow', + updatedInput: input, + toolUseID, + }; + + if (action === 'allow-session') { + const suggestions = Array.isArray(ctx.permissionSuggestions) + ? ctx.permissionSuggestions + : []; + if (suggestions.length > 0) { + result.updatedPermissions = suggestions; + } + } + + return result; +} + +module.exports = { buildPermissionControlResult }; diff --git a/vscode-extension/openclaude-vscode/src/chat/permissionResponse.test.js b/vscode-extension/openclaude-vscode/src/chat/permissionResponse.test.js new file mode 100644 index 0000000000..0255befdf5 --- /dev/null +++ b/vscode-extension/openclaude-vscode/src/chat/permissionResponse.test.js @@ -0,0 +1,42 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const { buildPermissionControlResult } = require('./permissionResponse'); + +test('buildPermissionControlResult allow includes behavior and updatedInput', () => { + const input = { file_path: 'C:\\project\\foo.txt', old_string: 'a', new_string: 'b' }; + assert.deepEqual(buildPermissionControlResult('allow', { + input, + toolUseId: 'toolu_123', + }), { + behavior: 'allow', + updatedInput: input, + toolUseID: 'toolu_123', + }); +}); + +test('buildPermissionControlResult allow-session attaches permission suggestions', () => { + const suggestions = [{ + type: 'addRules', + rules: [{ toolName: 'Edit', ruleContent: 'C:\\project' }], + behavior: 'allow', + destination: 'session', + }]; + assert.deepEqual(buildPermissionControlResult('allow-session', { + input: { file_path: 'C:\\project\\foo.txt' }, + toolUseId: 'toolu_456', + permissionSuggestions: suggestions, + }), { + behavior: 'allow', + updatedInput: { file_path: 'C:\\project\\foo.txt' }, + toolUseID: 'toolu_456', + updatedPermissions: suggestions, + }); +}); + +test('buildPermissionControlResult deny uses deny behavior', () => { + assert.deepEqual(buildPermissionControlResult('deny', { toolUseId: 'toolu_789' }), { + behavior: 'deny', + message: 'User denied permission', + toolUseID: 'toolu_789', + }); +});