diff --git a/mobile/src/session/mobile-native-chat-permission.test.ts b/mobile/src/session/mobile-native-chat-permission.test.ts index 3062e35c238..62c86563010 100644 --- a/mobile/src/session/mobile-native-chat-permission.test.ts +++ b/mobile/src/session/mobile-native-chat-permission.test.ts @@ -141,6 +141,20 @@ describe('parseApprovalFromStatus', () => { expect(parseApprovalFromStatus(JSON.stringify({ questions: [] }))).toBeNull() expect(parseApprovalFromStatus(JSON.stringify({ approval: {} }))).toBeNull() }) + + it('omits detail for a missing, empty or non-string summary', () => { + for (const summary of [undefined, '', 42]) { + const card = parseApprovalFromStatus(JSON.stringify({ approval: { tool: 'Edit', summary } })) + expect(card).toEqual({ + title: 'Allow Edit?', + detail: undefined, + options: [ + { label: 'Allow', send: '1' }, + { label: 'Deny', send: '\x1b' } + ] + }) + } + }) }) for (const agent of ['opencode', 'opencode2']) { diff --git a/mobile/src/session/mobile-native-chat-permission.ts b/mobile/src/session/mobile-native-chat-permission.ts index 5871de560a2..865bfdbbf87 100644 --- a/mobile/src/session/mobile-native-chat-permission.ts +++ b/mobile/src/session/mobile-native-chat-permission.ts @@ -1,4 +1,4 @@ -import { nativeChatApprovalAcceptKey } from '../../../src/shared/native-chat-agent-support' +import { parseNativeChatApprovalEnvelope } from '../../../src/shared/native-chat-approval-envelope' import type { AgentJournalApprovalSubject } from '../../../src/shared/agent-session-journal-types' // Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface @@ -24,8 +24,6 @@ export type MobileChatPermission = { options: Array<{ label: string; send: string }> } -const ESCAPE = String.fromCharCode(27) - /** Parse the live `agentStatus.interactivePrompt` approval envelope * (`{ approval: { tool, summary } }`, emitted by the host on a PermissionRequest) * into an Allow/Deny card. This is the reliable, agent-emitted signal — unlike @@ -37,33 +35,16 @@ export function parseApprovalFromStatus( interactivePrompt: string | undefined | null, agent?: string ): MobileChatPermission | null { - if (!interactivePrompt) { + const envelope = parseNativeChatApprovalEnvelope(interactivePrompt, agent) + if (!envelope) { return null } - let parsed: unknown - try { - parsed = JSON.parse(interactivePrompt) - } catch { - return null - } - if (!parsed || typeof parsed !== 'object') { - return null - } - const approval = (parsed as { approval?: unknown }).approval - if (!approval || typeof approval !== 'object') { - return null - } - const tool = (approval as { tool?: unknown }).tool - if (typeof tool !== 'string' || tool.length === 0) { - return null - } - const summary = (approval as { summary?: unknown }).summary return { - title: `Allow ${tool}?`, - detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined, + title: `Allow ${envelope.tool}?`, + detail: envelope.summary, options: [ - { label: 'Allow', send: nativeChatApprovalAcceptKey(agent) }, - { label: 'Deny', send: ESCAPE } + { label: 'Allow', send: envelope.allowSend }, + { label: 'Deny', send: envelope.denySend } ] } } diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts index 4cbc4c692ea..e1b7d75706f 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.test.ts @@ -1,4 +1,5 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it } from 'vitest' +import { i18n } from '@/i18n/i18n' import { buildAskAnswerKeys, buildCodexAskAnswerKeys, @@ -108,6 +109,39 @@ describe('parseApprovalFromStatus', () => { expect(approval?.title).toBe('Allow Edit?') expect(approval?.detail).toBeUndefined() }) + + it('omits detail for an empty or non-string summary', () => { + for (const summary of ['', 42, null]) { + const approval = parseApprovalFromStatus( + JSON.stringify({ approval: { tool: 'Edit', summary } }) + ) + expect(approval?.title).toBe('Allow Edit?') + expect(approval?.detail).toBeUndefined() + } + }) + + describe('translated labels', () => { + afterEach(async () => { + await i18n.changeLanguage('en') + }) + + it('translates the title and buttons but keeps the tool, summary and keys', async () => { + await i18n.changeLanguage('ko') + expect( + parseApprovalFromStatus( + JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } }), + 'opencode' + ) + ).toEqual({ + title: 'Bash을(를) 허용하시겠습니까?', + detail: 'rm -rf build', + options: [ + { label: '허용', send: '\r' }, + { label: '거부', send: ESC } + ] + }) + }) + }) }) describe('parseInteractivePrompt', () => { diff --git a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts index baaf6803a23..ea9fa1d96cf 100644 --- a/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts +++ b/src/renderer/src/components/native-chat/native-chat-interactive-prompt.ts @@ -1,4 +1,4 @@ -import { nativeChatApprovalAcceptKey } from '../../../../shared/native-chat-agent-support' +import { parseNativeChatApprovalEnvelope } from '../../../../shared/native-chat-approval-envelope' import { translate } from '@/i18n/i18n' import type { AgentJournalApprovalItem, @@ -64,45 +64,26 @@ export type InteractivePromptCard = | { kind: 'approval'; approval: ChatApproval } | null -const ESCAPE = String.fromCharCode(27) - -/** Parse the desktop-only approval envelope; question parsing stays cross-platform. */ +/** Desktop card for the shared approval envelope; question parsing stays cross-platform. */ export function parseApprovalFromStatus( interactivePrompt: string | undefined | null, agent?: string ): ChatApproval | null { - if (!interactivePrompt) { + const envelope = parseNativeChatApprovalEnvelope(interactivePrompt, agent) + if (!envelope) { return null } - let parsed: unknown - try { - parsed = JSON.parse(interactivePrompt) - } catch { - return null - } - if (!parsed || typeof parsed !== 'object') { - return null - } - const approval = (parsed as { approval?: unknown }).approval - if (!approval || typeof approval !== 'object') { - return null - } - const tool = (approval as { tool?: unknown }).tool - if (typeof tool !== 'string' || tool.length === 0) { - return null - } - const summary = (approval as { summary?: unknown }).summary return { title: translate('components.native-chat.approval.title', 'Allow {{value0}}?', { - value0: tool + value0: envelope.tool }), - detail: typeof summary === 'string' && summary.length > 0 ? summary : undefined, + detail: envelope.summary, options: [ { label: translate('components.native-chat.approval.allow', 'Allow'), - send: nativeChatApprovalAcceptKey(agent) + send: envelope.allowSend }, - { label: translate('components.native-chat.approval.deny', 'Deny'), send: ESCAPE } + { label: translate('components.native-chat.approval.deny', 'Deny'), send: envelope.denySend } ] } } diff --git a/src/shared/native-chat-approval-envelope.test.ts b/src/shared/native-chat-approval-envelope.test.ts new file mode 100644 index 00000000000..44de9f4e8d0 --- /dev/null +++ b/src/shared/native-chat-approval-envelope.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from 'vitest' +import { parseNativeChatApprovalEnvelope } from './native-chat-approval-envelope' + +const ESC = String.fromCharCode(27) + +describe('parseNativeChatApprovalEnvelope', () => { + it('reads tool and summary with the default TUI answer keys', () => { + expect( + parseNativeChatApprovalEnvelope( + JSON.stringify({ approval: { tool: 'Bash', summary: 'rm -rf build' } }) + ) + ).toEqual({ tool: 'Bash', summary: 'rm -rf build', allowSend: '1', denySend: ESC }) + }) + + it.each([ + ['claude', '1'], + ['codex', '1'], + ['opencode', '\r'], + ['opencode2', '\r'], + ['zcode', '\r'] + ])('allows a %s prompt with %j', (agent, allowSend) => { + expect( + parseNativeChatApprovalEnvelope(JSON.stringify({ approval: { tool: 'shell' } }), agent) + ).toEqual({ tool: 'shell', allowSend, denySend: ESC }) + }) + + it.each([ + ['missing', { tool: 'Edit' }], + ['empty', { tool: 'Edit', summary: '' }], + ['non-string', { tool: 'Edit', summary: 42 }] + ])('omits a %s summary', (_label, approval) => { + expect(parseNativeChatApprovalEnvelope(JSON.stringify({ approval }))).not.toHaveProperty( + 'summary' + ) + }) + + it.each([ + ['null', null], + ['undefined', undefined], + ['empty string', ''], + ['invalid JSON', '{bad'], + ['a JSON primitive', '"approval"'], + ['JSON null', 'null'], + ['an array', '[]'], + ['no approval', '{}'], + ['a question envelope', JSON.stringify({ questions: [] })], + ['a non-object approval', JSON.stringify({ approval: 'Bash' })], + ['no tool', JSON.stringify({ approval: {} })], + ['an empty tool', JSON.stringify({ approval: { tool: '' } })], + ['a non-string tool', JSON.stringify({ approval: { tool: 7 } })] + ])('returns null for %s', (_label, input) => { + expect(parseNativeChatApprovalEnvelope(input)).toBeNull() + }) +}) diff --git a/src/shared/native-chat-approval-envelope.ts b/src/shared/native-chat-approval-envelope.ts new file mode 100644 index 00000000000..175ce208479 --- /dev/null +++ b/src/shared/native-chat-approval-envelope.ts @@ -0,0 +1,46 @@ +import { nativeChatApprovalAcceptKey } from './native-chat-agent-support' + +/** The facts in a status row's `{ approval: { tool, summary } }` envelope; callers own labels. */ +export type NativeChatApprovalEnvelope = { + tool: string + summary?: string + /** Keys that answer the agent's default TUI approval prompt. */ + allowSend: string + denySend: string +} + +const ESCAPE = String.fromCharCode(27) + +/** Parse the approval envelope the host writes to `agentStatus.interactivePrompt` on a PermissionRequest. */ +export function parseNativeChatApprovalEnvelope( + interactivePrompt: string | undefined | null, + agent?: string +): NativeChatApprovalEnvelope | null { + if (!interactivePrompt) { + return null + } + let parsed: unknown + try { + parsed = JSON.parse(interactivePrompt) + } catch { + return null + } + if (typeof parsed !== 'object' || parsed === null || !('approval' in parsed)) { + return null + } + const approval = parsed.approval + if (typeof approval !== 'object' || approval === null || !('tool' in approval)) { + return null + } + const tool = approval.tool + if (typeof tool !== 'string' || tool.length === 0) { + return null + } + const summary = 'summary' in approval ? approval.summary : undefined + return { + tool, + ...(typeof summary === 'string' && summary.length > 0 ? { summary } : {}), + allowSend: nativeChatApprovalAcceptKey(agent), + denySend: ESCAPE + } +}