From 35736f3f3ac6fbc55d9e6bad013fbf44363cc862 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Wed, 16 Sep 2026 22:27:15 +0800 Subject: [PATCH] fix(desktop): route teammate permission prompts to the lead session Print-mode team leads were treating mailbox permission asks as ordinary chat, so desktop members hung forever with no approval UI. Forward those requests through the existing can_use_tool host prompt and label them with the teammate name. --- desktop/src/components/chat/MessageList.tsx | 1 + .../src/components/chat/PermissionDialog.tsx | 21 +- .../src/components/chat/chatBlocks.test.tsx | 40 +++ desktop/src/i18n/locales/en.ts | 8 +- desktop/src/i18n/locales/jp.ts | 8 +- desktop/src/i18n/locales/kr.ts | 8 +- desktop/src/i18n/locales/zh-TW.ts | 8 +- desktop/src/i18n/locales/zh.ts | 8 +- desktop/src/stores/chatStore.test.ts | 27 ++ desktop/src/stores/chatStore.ts | 7 +- desktop/src/types/chat.ts | 2 + src/cli/print.autoMode.test.ts | 9 + src/cli/print.ts | 43 +++- src/cli/structuredIO.ts | 46 +++- .../__tests__/conversation-service.test.ts | 33 +++ .../translate-cli-message.golden.json | 18 ++ .../__tests__/translateCliMessage.golden.ts | 19 ++ .../__tests__/websocket-handler.test.ts | 23 ++ src/server/services/conversationService.ts | 7 + src/server/ws/events.ts | 1 + src/server/ws/handler.ts | 5 + .../swarm/printLeaderPermissionBridge.test.ts | 213 ++++++++++++++++ .../swarm/printLeaderPermissionBridge.ts | 236 ++++++++++++++++++ 23 files changed, 749 insertions(+), 42 deletions(-) create mode 100644 src/utils/swarm/printLeaderPermissionBridge.test.ts create mode 100644 src/utils/swarm/printLeaderPermissionBridge.ts diff --git a/desktop/src/components/chat/MessageList.tsx b/desktop/src/components/chat/MessageList.tsx index 64a65a8b..09dabc16 100644 --- a/desktop/src/components/chat/MessageList.tsx +++ b/desktop/src/components/chat/MessageList.tsx @@ -3834,6 +3834,7 @@ export const MessageBlock = memo(function MessageBlock({ toolName={message.toolName} input={message.input} description={message.description} + displayName={message.displayName} /> ) case 'error': { diff --git a/desktop/src/components/chat/PermissionDialog.tsx b/desktop/src/components/chat/PermissionDialog.tsx index 64b76f78..7ca9fccc 100644 --- a/desktop/src/components/chat/PermissionDialog.tsx +++ b/desktop/src/components/chat/PermissionDialog.tsx @@ -21,6 +21,7 @@ type Props = { toolName: string input: unknown description?: string + displayName?: string } /** @@ -80,19 +81,27 @@ function extractToolDetails(toolName: string, input: unknown, t: (key: Translati } } -function getPermissionTitle(toolName: string, input: unknown, t: (key: TranslationKey, params?: Record) => string) { +function getPermissionTitle( + toolName: string, + input: unknown, + t: (key: TranslationKey, params?: Record) => string, + displayName?: string, +) { const obj = (input && typeof input === 'object') ? input as Record : {} const filePath = typeof obj.file_path === 'string' ? obj.file_path : '' const fileName = filePath ? filePath.split('/').pop() || filePath : '' + const actor = displayName || 'Claude' switch (toolName) { case 'Edit': case 'Write': - return fileName ? t('permission.allowEditFile', { toolName, fileName }) : t('permission.allowEditFileGeneric', { toolName: toolName.toLowerCase() }) + return fileName + ? t('permission.allowEditFile', { actor, toolName, fileName }) + : t('permission.allowEditFileGeneric', { actor, toolName: toolName.toLowerCase() }) case 'Bash': - return t('permission.allowBash') + return t('permission.allowBash', { actor }) default: - return t('permission.allowTool', { toolName }) + return t('permission.allowTool', { actor, toolName }) } } @@ -121,7 +130,7 @@ function renderPermissionPreview(toolName: string, input: unknown) { return null } -export function PermissionDialog({ sessionId, requestId, toolName, input, description }: Props) { +export function PermissionDialog({ sessionId, requestId, toolName, input, description, displayName }: Props) { const { respondToPermission } = useChatStore() const activeTabId = useTabStore((s) => s.activeTabId) const targetSessionId = sessionId ?? activeTabId @@ -148,7 +157,7 @@ export function PermissionDialog({ sessionId, requestId, toolName, input, descri const details = extractToolDetails(toolName, input, t) const rawInput = typeof input === 'string' ? input : JSON.stringify(input, null, 2) const preview = renderPermissionPreview(toolName, input) - const title = getPermissionTitle(toolName, input, t) + const title = getPermissionTitle(toolName, input, t, displayName) const allowRawToggle = !preview const permissionContext = (details.primary || description || toolName).slice(0, 160) diff --git a/desktop/src/components/chat/chatBlocks.test.tsx b/desktop/src/components/chat/chatBlocks.test.tsx index cbb2c14a..00785c39 100644 --- a/desktop/src/components/chat/chatBlocks.test.tsx +++ b/desktop/src/components/chat/chatBlocks.test.tsx @@ -1031,4 +1031,44 @@ describe('chat blocks', () => { expect(screen.getByRole('button', { name: 'Allow: /outside/two.ts' })).toBeTruthy() expect(screen.queryByText('Responded')).toBeNull() }) + + it('labels teammate permission prompts with the member name on the lead session', () => { + const teammatePermission = { + requestId: 'perm-teammate-1', + toolName: 'Bash', + toolUseId: 'tool-teammate-1', + input: { command: 'ls' }, + displayName: 'researcher', + } + useChatStore.setState({ + sessions: { + 'active-tab': { + messages: [], + chatState: 'permission_pending', + connectionState: 'connected', + streamingText: '', + streamingToolInput: '', + activeToolUseId: null, + activeToolName: null, + activeThinkingId: null, + pendingPermission: teammatePermission, + pendingPermissions: { + [teammatePermission.requestId]: teammatePermission, + }, + pendingComputerUsePermission: null, + tokenUsage: { input_tokens: 0, output_tokens: 0 }, + streamingResponseChars: 0, + elapsedSeconds: 0, + statusVerb: '', + slashCommands: [], + agentTaskNotifications: {}, + elapsedTimer: null, + }, + }, + }) + + render() + + expect(screen.getByText('Allow researcher to run this command?')).toBeTruthy() + }) }) diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index e9e1623d..ef185d78 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -2646,10 +2646,10 @@ Row 9, all 8 cells: continuing from straight down, turning left through lower-le 'streaming.working': 'Working', // ─── Permission Dialog ────────────────────────────────────── - 'permission.allowEditFile': 'Allow Claude to {toolName} {fileName}?', - 'permission.allowEditFileGeneric': 'Allow Claude to {toolName} this file?', - 'permission.allowBash': 'Allow Claude to run this command?', - 'permission.allowTool': 'Allow Claude to use {toolName}?', + 'permission.allowEditFile': 'Allow {actor} to {toolName} {fileName}?', + 'permission.allowEditFileGeneric': 'Allow {actor} to {toolName} this file?', + 'permission.allowBash': 'Allow {actor} to run this command?', + 'permission.allowTool': 'Allow {actor} to use {toolName}?', 'permission.awaitingApproval': 'Awaiting approval', 'permission.responded': 'Responded', 'permission.allow': 'Allow', diff --git a/desktop/src/i18n/locales/jp.ts b/desktop/src/i18n/locales/jp.ts index 330f3f3d..35a66946 100644 --- a/desktop/src/i18n/locales/jp.ts +++ b/desktop/src/i18n/locales/jp.ts @@ -2649,10 +2649,10 @@ export const jp: Record = { 'streaming.working': '作業中', // ─── Permission Dialog ────────────────────────────────────── - 'permission.allowEditFile': 'Claude が {fileName} を {toolName} することを許可しますか?', - 'permission.allowEditFileGeneric': 'Claude がこのファイルを {toolName} することを許可しますか?', - 'permission.allowBash': 'Claude がこのコマンドを実行することを許可しますか?', - 'permission.allowTool': 'Claude が {toolName} を使用することを許可しますか?', + 'permission.allowEditFile': '{actor} が {fileName} を {toolName} することを許可しますか?', + 'permission.allowEditFileGeneric': '{actor} がこのファイルを {toolName} することを許可しますか?', + 'permission.allowBash': '{actor} がこのコマンドを実行することを許可しますか?', + 'permission.allowTool': '{actor} が {toolName} を使用することを許可しますか?', 'permission.awaitingApproval': '承認待ち', 'permission.responded': '応答済み', 'permission.allow': '許可', diff --git a/desktop/src/i18n/locales/kr.ts b/desktop/src/i18n/locales/kr.ts index 37f1aa8f..0c2e1301 100644 --- a/desktop/src/i18n/locales/kr.ts +++ b/desktop/src/i18n/locales/kr.ts @@ -2649,10 +2649,10 @@ export const kr: Record = { 'streaming.working': '작업 중', // ─── Permission Dialog ────────────────────────────────────── - 'permission.allowEditFile': 'Claude가 {fileName}을(를) {toolName}하도록 허용하시겠습니까?', - 'permission.allowEditFileGeneric': 'Claude가 이 파일을 {toolName}하도록 허용하시겠습니까?', - 'permission.allowBash': 'Claude가 이 명령을 실행하도록 허용하시겠습니까?', - 'permission.allowTool': 'Claude가 {toolName}을(를) 사용하도록 허용하시겠습니까?', + 'permission.allowEditFile': '{actor}가 {fileName}을(를) {toolName}하도록 허용하시겠습니까?', + 'permission.allowEditFileGeneric': '{actor}가 이 파일을 {toolName}하도록 허용하시겠습니까?', + 'permission.allowBash': '{actor}가 이 명령을 실행하도록 허용하시겠습니까?', + 'permission.allowTool': '{actor}가 {toolName}을(를) 사용하도록 허용하시겠습니까?', 'permission.awaitingApproval': '승인 대기 중', 'permission.responded': '응답함', 'permission.allow': '허용', diff --git a/desktop/src/i18n/locales/zh-TW.ts b/desktop/src/i18n/locales/zh-TW.ts index e9a1e02a..30965267 100644 --- a/desktop/src/i18n/locales/zh-TW.ts +++ b/desktop/src/i18n/locales/zh-TW.ts @@ -2648,10 +2648,10 @@ export const zh: Record = { 'streaming.working': '工作中', // ─── Permission Dialog ────────────────────────────────────── - 'permission.allowEditFile': '允許 Claude {toolName} {fileName}?', - 'permission.allowEditFileGeneric': '允許 Claude {toolName}此檔案?', - 'permission.allowBash': '允許 Claude 執行此命令?', - 'permission.allowTool': '允許 Claude 使用 {toolName}?', + 'permission.allowEditFile': '允許 {actor} {toolName} {fileName}?', + 'permission.allowEditFileGeneric': '允許 {actor} {toolName}此檔案?', + 'permission.allowBash': '允許 {actor} 執行此命令?', + 'permission.allowTool': '允許 {actor} 使用 {toolName}?', 'permission.awaitingApproval': '等待審批', 'permission.responded': '已響應', 'permission.allow': '允許', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 940fa0e7..fd53a8d8 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -2647,10 +2647,10 @@ export const zh: Record = { 'streaming.working': '工作中', // ─── Permission Dialog ────────────────────────────────────── - 'permission.allowEditFile': '允许 Claude {toolName} {fileName}?', - 'permission.allowEditFileGeneric': '允许 Claude {toolName}此文件?', - 'permission.allowBash': '允许 Claude 执行此命令?', - 'permission.allowTool': '允许 Claude 使用 {toolName}?', + 'permission.allowEditFile': '允许 {actor} {toolName} {fileName}?', + 'permission.allowEditFileGeneric': '允许 {actor} {toolName}此文件?', + 'permission.allowBash': '允许 {actor} 执行此命令?', + 'permission.allowTool': '允许 {actor} 使用 {toolName}?', 'permission.awaitingApproval': '等待审批', 'permission.responded': '已响应', 'permission.allow': '允许', diff --git a/desktop/src/stores/chatStore.test.ts b/desktop/src/stores/chatStore.test.ts index 1a70fe34..9a7c2f06 100644 --- a/desktop/src/stores/chatStore.test.ts +++ b/desktop/src/stores/chatStore.test.ts @@ -9243,6 +9243,33 @@ describe('chatStore history mapping', () => { expect(session?.chatState).toBe('tool_executing') }) + it('keeps teammate displayName on the lead-session permission prompt', () => { + useChatStore.setState({ + sessions: { [TEST_SESSION_ID]: makeSession() }, + }) + + useChatStore.getState().handleServerMessage(TEST_SESSION_ID, { + type: 'permission_request', + requestId: 'perm-teammate-1', + toolName: 'Bash', + toolUseId: 'tool-teammate-1', + input: { command: 'ls' }, + description: 'list files', + displayName: 'researcher', + }) + + const session = useChatStore.getState().sessions[TEST_SESSION_ID] + expect(session?.pendingPermission).toEqual(expect.objectContaining({ + requestId: 'perm-teammate-1', + displayName: 'researcher', + })) + expect(session?.messages).toContainEqual(expect.objectContaining({ + type: 'permission_request', + requestId: 'perm-teammate-1', + displayName: 'researcher', + })) + }) + it('removes replayed or cancelled requests when the server resolves them', () => { useChatStore.setState({ sessions: { diff --git a/desktop/src/stores/chatStore.ts b/desktop/src/stores/chatStore.ts index e42d07c3..a706002f 100644 --- a/desktop/src/stores/chatStore.ts +++ b/desktop/src/stores/chatStore.ts @@ -103,6 +103,7 @@ export type PendingPermission = { toolUseId?: string input: unknown description?: string + displayName?: string } type PendingPermissions = Record @@ -4776,7 +4777,9 @@ export const useChatStore = create((set, get) => ({ cooldownScope: 'permission-prompt', requestAttention: true, title: 'Claude Code Haha 需要你的确认', - body: msg.toolName + body: msg.displayName && msg.toolName + ? `${msg.displayName} 请求使用 ${msg.toolName},正在等待允许。` + : msg.toolName ? `${msg.toolName} 请求执行,正在等待允许。` : '有一个工具请求正在等待允许。', target: { type: 'session', sessionId }, @@ -4788,6 +4791,7 @@ export const useChatStore = create((set, get) => ({ toolUseId: msg.toolUseId, input: msg.input, description: msg.description, + ...(msg.displayName ? { displayName: msg.displayName } : {}), } const pendingPermissions = { ...getPendingPermissionRecord(s), @@ -4828,6 +4832,7 @@ export const useChatStore = create((set, get) => ({ toolUseId: msg.toolUseId, input: msg.input, description: msg.description, + ...(msg.displayName ? { displayName: msg.displayName } : {}), timestamp: Date.now(), }], } diff --git a/desktop/src/types/chat.ts b/desktop/src/types/chat.ts index 392267b0..f06cb5fd 100644 --- a/desktop/src/types/chat.ts +++ b/desktop/src/types/chat.ts @@ -107,6 +107,7 @@ export type ServerMessage = toolUseId?: string input: unknown description?: string + displayName?: string } | { type: 'computer_use_permission_request' @@ -401,6 +402,7 @@ export type UIMessage = toolUseId?: string input: unknown description?: string + displayName?: string timestamp: number } | { id: string; type: 'error'; message: string; code: string; businessErrorCode?: string; timestamp: number } diff --git a/src/cli/print.autoMode.test.ts b/src/cli/print.autoMode.test.ts index 24051999..359c2bd0 100644 --- a/src/cli/print.autoMode.test.ts +++ b/src/cli/print.autoMode.test.ts @@ -10,3 +10,12 @@ test('model metadata advertises Auto by feature instead of provider or model', ( ) expect(source).toContain('const hasAutoMode = autoModeSupported') }) + +test('print mode routes teammate permission mailbox messages to the host instead of the model', () => { + expect(source).toContain('partitionLeadMailboxMessages') + expect(source).toContain('resolveTeammatePermissionRequests') + expect(source).toContain('hostPermissionPromptAvailable') + expect(source).not.toMatch( + /formatted = unread\s*\.map/, + ) +}) diff --git a/src/cli/print.ts b/src/cli/print.ts index 2c39dadb..134ea5fb 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -357,6 +357,10 @@ import { markMessagesAsRead, isShutdownApproved, } from '../utils/teammateMailbox.js' +import { + partitionLeadMailboxMessages, + resolveTeammatePermissionRequests, +} from '../utils/swarm/printLeaderPermissionBridge.js' import { removeTeammateFromTeamFile } from '../utils/swarm/teamHelpers.js' import { unassignTeammateTasks } from '../utils/tasks.js' import { getRunningTasks } from '../utils/task/framework.js' @@ -891,7 +895,11 @@ export async function runHeadless( getAppState, setAppState, agents, - options, + { + ...options, + hostPermissionPromptAvailable: + effectivePermissionPromptToolName === 'stdio', + }, turnInterruptionState, )) { partialOutputTracker.observe(message) @@ -1017,6 +1025,7 @@ function runHeadlessStreaming( verbose: boolean | undefined jsonSchema: Record | undefined permissionPromptToolName: string | undefined + hostPermissionPromptAvailable?: boolean allowedTools: string[] | undefined thinkingConfig: ThinkingConfig | undefined maxTurns: number | undefined @@ -2564,10 +2573,29 @@ function runHeadlessStreaming( refreshedState.teamContext?.teamName, ) + const teamName = refreshedState.teamContext?.teamName + const partitioned = partitionLeadMailboxMessages(unread) + + if ( + partitioned.permissionRequests.length > 0 || + partitioned.sandboxPermissionRequests.length > 0 + ) { + logForDebugging( + `[print.ts] Routing ${partitioned.permissionRequests.length} teammate permission request(s) and ${partitioned.sandboxPermissionRequests.length} sandbox request(s) to the host`, + ) + void resolveTeammatePermissionRequests({ + host: structuredIO, + canPromptHost: options.hostPermissionPromptAvailable === true, + teamName, + permissionRequests: partitioned.permissionRequests, + sandboxPermissionRequests: + partitioned.sandboxPermissionRequests, + }) + } + // Process shutdown_approved messages - remove teammates from team file // This mirrors what useInboxPoller does in interactive mode (lines 546-606) - const teamName = refreshedState.teamContext?.teamName - for (const m of unread) { + for (const m of partitioned.remaining) { const shutdownApproval = isShutdownApproved(m.text) if (shutdownApproval && teamName) { const teammateToRemove = shutdownApproval.from @@ -2618,8 +2646,13 @@ function runHeadlessStreaming( } } - // Format messages same as useInboxPoller - const formatted = unread + if (partitioned.remaining.length === 0) { + continue + } + + // Format remaining teammate chat the same way as useInboxPoller. + // Permission requests stay out of the model context. + const formatted = partitioned.remaining .map( (m: { from: string; text: string; color?: string }) => `<${TEAMMATE_MESSAGE_TAG} teammate_id="${m.from}"${m.color ? ` color="${m.color}"` : ''}>\n${m.text}\n`, diff --git a/src/cli/structuredIO.ts b/src/cli/structuredIO.ts index 5737d0c6..bb336d66 100644 --- a/src/cli/structuredIO.ts +++ b/src/cli/structuredIO.ts @@ -752,16 +752,12 @@ export class StructuredIO { }) => Promise { return async (hostPattern): Promise => { try { - const result = await this.sendRequest( - { - subtype: 'can_use_tool', - tool_name: SANDBOX_NETWORK_ACCESS_TOOL_NAME, - input: { host: hostPattern.host }, - tool_use_id: randomUUID(), - description: `Allow network connection to ${hostPattern.host}?`, - }, - permissionToolOutputSchema(), - ) + const result = await this.askHostForToolPermission({ + toolName: SANDBOX_NETWORK_ACCESS_TOOL_NAME, + input: { host: hostPattern.host }, + toolUseId: randomUUID(), + description: `Allow network connection to ${hostPattern.host}?`, + }) return result.behavior === 'allow' } catch { // If the request fails (stream closed, abort, etc.), deny the connection @@ -770,6 +766,36 @@ export class StructuredIO { } } + /** + * Prompt the SDK host for a tool permission without going through a live + * tool-use context. Used by teammate mailbox requests in --print: the worker + * already decided it needs a human, so the lead session just forwards that + * ask onto the existing can_use_tool protocol. + */ + askHostForToolPermission(params: { + toolName: string + input: Record + toolUseId: string + description?: string + displayName?: string + permissionSuggestions?: unknown[] + }): Promise { + return this.sendRequest( + { + subtype: 'can_use_tool', + tool_name: params.toolName, + input: params.input, + tool_use_id: params.toolUseId, + ...(params.description ? { description: params.description } : {}), + ...(params.displayName ? { display_name: params.displayName } : {}), + ...(Array.isArray(params.permissionSuggestions) + ? { permission_suggestions: params.permissionSuggestions } + : {}), + }, + permissionToolOutputSchema(), + ) + } + /** * Sends an MCP message to an SDK server and waits for the response */ diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 383ef742..fb188f86 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -1630,6 +1630,39 @@ describe('ConversationService', () => { })) }) + test('retains teammate display_name on pending permission requests', () => { + const service = new ConversationService() as any + service.sessions.set('lead-session', { + outputCallbacks: [], + seenSdkMessageUuids: new Set(), + sdkMessages: [], + initMessage: null, + pendingPermissionRequests: new Map(), + }) + + service.handleSdkPayload('lead-session', JSON.stringify({ + type: 'control_request', + request_id: 'teammate-perm', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + tool_use_id: 'toolu_teammate', + input: { command: 'ls' }, + description: 'list files', + display_name: 'researcher', + }, + })) + + expect(service.getPendingPermissionRequests('lead-session')).toEqual([{ + requestId: 'teammate-perm', + toolName: 'Bash', + toolUseId: 'toolu_teammate', + input: { command: 'ls' }, + description: 'list files', + displayName: 'researcher', + }]) + }) + // CLI 的 WebSocketTransport 每次重连成功都会把整个发送缓冲区重放一遍,并假定 // 「The server deduplicates by UUID」。以前 server 没实现这个契约:笔记本睡醒后 // CLI 重连,一整轮早已结束的对话会被重新推上来,前端当成实时输出再渲染一遍 diff --git a/src/server/__tests__/fixtures/translate-cli-message.golden.json b/src/server/__tests__/fixtures/translate-cli-message.golden.json index 3c0de25e..672d947d 100644 --- a/src/server/__tests__/fixtures/translate-cli-message.golden.json +++ b/src/server/__tests__/fixtures/translate-cli-message.golden.json @@ -389,6 +389,24 @@ ] } ], + "teammate-permission-request": [ + { + "in": "control_request/can_use_tool", + "out": [ + { + "type": "permission_request", + "requestId": "req_teammate_perm", + "toolName": "Bash", + "toolUseId": "toolu_teammate_perm", + "input": { + "command": "ls" + }, + "description": "list files", + "displayName": "researcher" + } + ] + } + ], "system-init-and-slash": [ { "in": "system/init", diff --git a/src/server/__tests__/translateCliMessage.golden.ts b/src/server/__tests__/translateCliMessage.golden.ts index 1d20b197..a90381d8 100644 --- a/src/server/__tests__/translateCliMessage.golden.ts +++ b/src/server/__tests__/translateCliMessage.golden.ts @@ -220,6 +220,25 @@ export const goldenScenarios: GoldenScenario[] = [ { type: 'control_cancel_request', request_id: 'req_golden_1' }, ], }, + { + id: 'teammate-permission-request', + description: + 'A teammate can_use_tool prompt keeps display_name so the lead session can label the member.', + messages: [ + { + type: 'control_request', + request_id: 'req_teammate_perm', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + tool_use_id: 'toolu_teammate_perm', + input: { command: 'ls' }, + description: 'list files', + display_name: 'researcher', + }, + }, + ], + }, { id: 'system-init-and-slash', description: 'Session init advertises slash commands; local command output renders separately.', diff --git a/src/server/__tests__/websocket-handler.test.ts b/src/server/__tests__/websocket-handler.test.ts index 1fa52e1b..7f6afb98 100644 --- a/src/server/__tests__/websocket-handler.test.ts +++ b/src/server/__tests__/websocket-handler.test.ts @@ -121,6 +121,29 @@ describe('translateCliMessage usage mapping', () => { allowed: false, }]) }) + + it('forwards teammate display_name onto the lead-session permission prompt', () => { + expect(translateCliMessage({ + type: 'control_request', + request_id: 'teammate-perm-1', + request: { + subtype: 'can_use_tool', + tool_name: 'Bash', + tool_use_id: 'toolu_teammate', + input: { command: 'ls' }, + description: 'list files', + display_name: 'researcher', + }, + }, 'session-1')).toEqual([{ + type: 'permission_request', + requestId: 'teammate-perm-1', + toolName: 'Bash', + toolUseId: 'toolu_teammate', + input: { command: 'ls' }, + description: 'list files', + displayName: 'researcher', + }]) + }) }) describe('WebSocket handler session title lifecycle', () => { diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 63369415..60f3b652 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -230,6 +230,7 @@ type SessionProcess = { toolName: string toolUseId?: string description?: string + displayName?: string input: Record permissionSuggestions?: unknown[] } @@ -243,6 +244,7 @@ export type PendingPermissionRequest = { toolUseId?: string input: Record description?: string + displayName?: string } type SessionStartOptions = { @@ -982,6 +984,7 @@ export class ConversationService { ...(request.toolUseId ? { toolUseId: request.toolUseId } : {}), input: request.input, ...(request.description ? { description: request.description } : {}), + ...(request.displayName ? { displayName: request.displayName } : {}), })) } @@ -1123,6 +1126,10 @@ export class ConversationService { typeof msg.request.description === 'string' && msg.request.description.trim() ? msg.request.description : undefined, + displayName: + typeof msg.request.display_name === 'string' && msg.request.display_name.trim() + ? msg.request.display_name.trim() + : undefined, permissionSuggestions: Array.isArray(msg.request.permission_suggestions) ? msg.request.permission_suggestions : undefined, diff --git a/src/server/ws/events.ts b/src/server/ws/events.ts index cc3fc8d6..97c6e063 100644 --- a/src/server/ws/events.ts +++ b/src/server/ws/events.ts @@ -81,6 +81,7 @@ export type ServerMessage = toolUseId?: string input: unknown description?: string + displayName?: string } | { type: 'computer_use_permission_request' diff --git a/src/server/ws/handler.ts b/src/server/ws/handler.ts index fa22d165..9b594eac 100644 --- a/src/server/ws/handler.ts +++ b/src/server/ws/handler.ts @@ -3226,6 +3226,10 @@ export function translateCliMessage(cliMsg: any, sessionId: string): ServerMessa : undefined, input: cliMsg.request.input || {}, description: cliMsg.request.description, + ...(typeof cliMsg.request.display_name === 'string' && + cliMsg.request.display_name.trim() + ? { displayName: cliMsg.request.display_name.trim() } + : {}), }] } return [] @@ -3729,6 +3733,7 @@ function replayPendingPermissionRequests( ...(request.toolUseId ? { toolUseId: request.toolUseId } : {}), input: request.input, ...(request.description ? { description: request.description } : {}), + ...(request.displayName ? { displayName: request.displayName } : {}), }) } return requests.map((request) => request.requestId) diff --git a/src/utils/swarm/printLeaderPermissionBridge.test.ts b/src/utils/swarm/printLeaderPermissionBridge.test.ts new file mode 100644 index 00000000..b645e27c --- /dev/null +++ b/src/utils/swarm/printLeaderPermissionBridge.test.ts @@ -0,0 +1,213 @@ +import { afterEach, describe, expect, mock, spyOn, test } from 'bun:test' +import { jsonStringify } from '../slowOperations.js' +import { + createPermissionRequestMessage, + createSandboxPermissionRequestMessage, + isPermissionResponse, + isSandboxPermissionResponse, + type TeammateMessage, +} from '../teammateMailbox.js' +import * as mailbox from '../teammateMailbox.js' +import { + HEADLESS_TEAMMATE_PERMISSION_UNAVAILABLE, + partitionLeadMailboxMessages, + resolveTeammatePermissionRequests, + type HostToolPermissionPrompt, +} from './printLeaderPermissionBridge.js' + +function mailboxMessage( + from: string, + payload: unknown, + extras: Partial = {}, +): TeammateMessage { + return { + from, + text: jsonStringify(payload), + timestamp: new Date().toISOString(), + read: false, + ...extras, + } +} + +describe('printLeaderPermissionBridge', () => { + const writes: Array<{ to: string; text: string; teamName?: string }> = [] + let writeSpy: ReturnType | undefined + + afterEach(() => { + writes.length = 0 + writeSpy?.mockRestore() + writeSpy = undefined + }) + + function captureMailboxWrites() { + writeSpy = spyOn(mailbox, 'writeToMailbox').mockImplementation( + async (to, message, teamName) => { + writes.push({ to, text: message.text, teamName }) + }, + ) + } + + test('keeps permission and sandbox asks out of remaining teammate chat', () => { + const permission = createPermissionRequestMessage({ + request_id: 'perm-1', + agent_id: 'researcher', + tool_name: 'Bash', + tool_use_id: 'toolu_1', + description: 'list files', + input: { command: 'ls' }, + }) + const sandbox = createSandboxPermissionRequestMessage({ + requestId: 'sandbox-1', + workerId: 'w1', + workerName: 'researcher', + host: 'example.com', + }) + const chat = mailboxMessage('researcher', 'need a second look at the plan') + + const partitioned = partitionLeadMailboxMessages([ + mailboxMessage('researcher', permission), + mailboxMessage('researcher', sandbox), + chat, + ]) + + expect(partitioned.permissionRequests).toHaveLength(1) + expect(partitioned.permissionRequests[0]?.parsed.request_id).toBe('perm-1') + expect(partitioned.sandboxPermissionRequests).toHaveLength(1) + expect(partitioned.sandboxPermissionRequests[0]?.parsed.requestId).toBe( + 'sandbox-1', + ) + expect(partitioned.remaining).toEqual([chat]) + }) + + test('rejects teammate tool asks when the host cannot prompt', async () => { + captureMailboxWrites() + const parsed = createPermissionRequestMessage({ + request_id: 'perm-deny-no-host', + agent_id: 'researcher', + tool_name: 'Bash', + tool_use_id: 'toolu_deny', + description: 'rm -rf /', + input: { command: 'rm -rf /' }, + }) + const host: HostToolPermissionPrompt = { + askHostForToolPermission: mock(() => { + throw new Error('host should not be prompted') + }), + } + + await resolveTeammatePermissionRequests({ + host, + canPromptHost: false, + teamName: 'team-alpha', + permissionRequests: [ + { message: mailboxMessage('researcher', parsed), parsed }, + ], + sandboxPermissionRequests: [], + }) + + expect(host.askHostForToolPermission).not.toHaveBeenCalled() + expect(writes).toHaveLength(1) + expect(writes[0]?.to).toBe('researcher') + expect(writes[0]?.teamName).toBe('team-alpha') + expect(isPermissionResponse(writes[0]?.text ?? '')).toEqual( + expect.objectContaining({ + type: 'permission_response', + request_id: 'perm-deny-no-host', + subtype: 'error', + error: HEADLESS_TEAMMATE_PERMISSION_UNAVAILABLE, + }), + ) + }) + + test('writes an approved mailbox response after the host allows the tool', async () => { + captureMailboxWrites() + const parsed = createPermissionRequestMessage({ + request_id: 'perm-allow', + agent_id: 'researcher', + tool_name: 'Bash', + tool_use_id: 'toolu_allow', + description: 'list files', + input: { command: 'ls' }, + }) + const host: HostToolPermissionPrompt = { + askHostForToolPermission: mock(async params => { + expect(params).toEqual({ + toolName: 'Bash', + input: { command: 'ls' }, + toolUseId: 'toolu_allow', + description: 'list files', + displayName: 'researcher', + permissionSuggestions: [], + }) + return { + behavior: 'allow', + updatedInput: { command: 'ls -la' }, + } + }), + } + + await resolveTeammatePermissionRequests({ + host, + canPromptHost: true, + teamName: 'team-alpha', + permissionRequests: [ + { message: mailboxMessage('researcher', parsed), parsed }, + ], + sandboxPermissionRequests: [], + }) + + expect(writes).toHaveLength(1) + const parsedResponse = isPermissionResponse(writes[0]?.text ?? '') + expect(parsedResponse).toEqual( + expect.objectContaining({ + type: 'permission_response', + request_id: 'perm-allow', + subtype: 'success', + }), + ) + expect( + parsedResponse && 'response' in parsedResponse + ? parsedResponse.response?.updated_input + : undefined, + ).toEqual({ command: 'ls -la' }) + }) + + test('forwards teammate sandbox network asks through the host prompt', async () => { + captureMailboxWrites() + const parsed = createSandboxPermissionRequestMessage({ + requestId: 'sandbox-allow', + workerId: 'w1', + workerName: 'researcher', + host: 'api.example.com', + }) + const host: HostToolPermissionPrompt = { + askHostForToolPermission: mock(async params => { + expect(params.toolName).toBe('SandboxNetworkAccess') + expect(params.description).toContain('api.example.com') + expect(params.input).toEqual({ host: 'api.example.com' }) + expect(params.displayName).toBe('researcher') + return { behavior: 'allow', updatedInput: params.input } + }), + } + + await resolveTeammatePermissionRequests({ + host, + canPromptHost: true, + teamName: 'team-alpha', + permissionRequests: [], + sandboxPermissionRequests: [ + { message: mailboxMessage('researcher', parsed), parsed }, + ], + }) + + expect(writes).toHaveLength(1) + expect(isSandboxPermissionResponse(writes[0]?.text ?? '')).toEqual( + expect.objectContaining({ + type: 'sandbox_permission_response', + requestId: 'sandbox-allow', + host: 'api.example.com', + allow: true, + }), + ) + }) +}) diff --git a/src/utils/swarm/printLeaderPermissionBridge.ts b/src/utils/swarm/printLeaderPermissionBridge.ts new file mode 100644 index 00000000..32dca771 --- /dev/null +++ b/src/utils/swarm/printLeaderPermissionBridge.ts @@ -0,0 +1,236 @@ +/** + * Headless / desktop host for teammate permission requests. + * + * Interactive REPL consumes mailbox permission_request via useInboxPoller and + * the in-memory leaderPermissionBridge. --print never mounts REPL, so those + * requests used to be formatted as ordinary teammate chat and injected into + * the model while the worker waited forever. + * + * This module is the missing leader-side consumer: classify mailbox messages, + * prompt the SDK host through the existing can_use_tool protocol, then write + * the resolution back to the worker mailbox. Members never see a permission + * UI of their own — the lead session is the only approval surface. + */ +import { logForDebugging } from '../debug.js' +import { errorMessage } from '../errors.js' +import type { PermissionUpdate } from '../permissions/PermissionUpdateSchema.js' +import { + isPermissionRequest, + isSandboxPermissionRequest, + type PermissionRequestMessage, + type SandboxPermissionRequestMessage, + type TeammateMessage, +} from '../teammateMailbox.js' +import { + sendPermissionResponseViaMailbox, + sendSandboxPermissionResponseViaMailbox, +} from './permissionSync.js' + +export const HEADLESS_TEAMMATE_PERMISSION_UNAVAILABLE = + 'Team lead cannot prompt for teammate tool permission in this session.' + +export type HostToolPermissionDecision = + | { + behavior: 'allow' + updatedInput?: Record + updatedPermissions?: PermissionUpdate[] + } + | { + behavior: 'deny' + message?: string + } + +export type HostToolPermissionPrompt = { + askHostForToolPermission(params: { + toolName: string + input: Record + toolUseId: string + description?: string + displayName?: string + permissionSuggestions?: unknown[] + }): Promise +} + +export type PartitionedLeadMailbox = { + permissionRequests: Array<{ + message: TeammateMessage + parsed: PermissionRequestMessage + }> + sandboxPermissionRequests: Array<{ + message: TeammateMessage + parsed: SandboxPermissionRequestMessage + }> + remaining: TeammateMessage[] +} + +export function partitionLeadMailboxMessages( + messages: TeammateMessage[], +): PartitionedLeadMailbox { + const permissionRequests: PartitionedLeadMailbox['permissionRequests'] = [] + const sandboxPermissionRequests: PartitionedLeadMailbox['sandboxPermissionRequests'] = + [] + const remaining: TeammateMessage[] = [] + + for (const message of messages) { + const permissionRequest = isPermissionRequest(message.text) + if (permissionRequest) { + permissionRequests.push({ message, parsed: permissionRequest }) + continue + } + const sandboxRequest = isSandboxPermissionRequest(message.text) + if (sandboxRequest) { + sandboxPermissionRequests.push({ message, parsed: sandboxRequest }) + continue + } + remaining.push(message) + } + + return { permissionRequests, sandboxPermissionRequests, remaining } +} + +export async function resolveTeammatePermissionRequests(params: { + host: HostToolPermissionPrompt + canPromptHost: boolean + teamName: string | undefined + permissionRequests: PartitionedLeadMailbox['permissionRequests'] + sandboxPermissionRequests: PartitionedLeadMailbox['sandboxPermissionRequests'] +}): Promise { + const { + host, + canPromptHost, + teamName, + permissionRequests, + sandboxPermissionRequests, + } = params + + await Promise.all([ + ...permissionRequests.map(({ parsed }) => + resolveToolPermission({ + host, + canPromptHost, + teamName, + parsed, + }), + ), + ...sandboxPermissionRequests.map(({ parsed }) => + resolveSandboxPermission({ + host, + canPromptHost, + teamName, + parsed, + }), + ), + ]) +} + +async function resolveToolPermission(params: { + host: HostToolPermissionPrompt + canPromptHost: boolean + teamName: string | undefined + parsed: PermissionRequestMessage +}): Promise { + const { host, canPromptHost, teamName, parsed } = params + const reject = (feedback: string) => + sendPermissionResponseViaMailbox( + parsed.agent_id, + { + decision: 'rejected', + resolvedBy: 'leader', + feedback, + }, + parsed.request_id, + teamName, + ) + + if (!canPromptHost) { + logForDebugging( + `[print.ts] Rejecting teammate permission for ${parsed.tool_name} from ${parsed.agent_id}: no host prompt`, + ) + await reject(HEADLESS_TEAMMATE_PERMISSION_UNAVAILABLE) + return + } + + try { + const result = await host.askHostForToolPermission({ + toolName: parsed.tool_name, + input: parsed.input, + toolUseId: parsed.tool_use_id, + description: parsed.description, + // display_name labels the member on the lead-session prompt. + // Do not send worker identity as agent_id: desktop treats a set + // agent_id as a stopped-subagent permission and drops it. + displayName: parsed.agent_id, + permissionSuggestions: parsed.permission_suggestions, + }) + if (result.behavior === 'allow') { + await sendPermissionResponseViaMailbox( + parsed.agent_id, + { + decision: 'approved', + resolvedBy: 'leader', + updatedInput: result.updatedInput, + permissionUpdates: result.updatedPermissions, + }, + parsed.request_id, + teamName, + ) + return + } + await reject(result.message || 'Permission denied') + } catch (error) { + logForDebugging( + `[print.ts] Host prompt failed for teammate permission ${parsed.request_id}: ${errorMessage(error)}`, + ) + await reject(errorMessage(error)) + } +} + +async function resolveSandboxPermission(params: { + host: HostToolPermissionPrompt + canPromptHost: boolean + teamName: string | undefined + parsed: SandboxPermissionRequestMessage +}): Promise { + const { host, canPromptHost, teamName, parsed } = params + const hostName = parsed.hostPattern?.host + if (!hostName) { + logForDebugging( + '[print.ts] Ignoring sandbox permission request with no host', + ) + return + } + + const respond = (allow: boolean) => + sendSandboxPermissionResponseViaMailbox( + parsed.workerName, + parsed.requestId, + hostName, + allow, + teamName, + ) + + if (!canPromptHost) { + logForDebugging( + `[print.ts] Denying teammate sandbox access to ${hostName} from ${parsed.workerName}: no host prompt`, + ) + await respond(false) + return + } + + try { + const result = await host.askHostForToolPermission({ + // Keep this aligned with SANDBOX_NETWORK_ACCESS_TOOL_NAME in structuredIO. + toolName: 'SandboxNetworkAccess', + input: { host: hostName }, + toolUseId: parsed.requestId, + description: `${parsed.workerName} needs network access to ${hostName}`, + displayName: parsed.workerName, + }) + await respond(result.behavior === 'allow') + } catch (error) { + logForDebugging( + `[print.ts] Host prompt failed for teammate sandbox permission ${parsed.requestId}: ${errorMessage(error)}`, + ) + await respond(false) + } +}