refactor(native-chat): one shared parser for the approval envelope (#30848)

The phone and the desktop each parsed the status row's
{ approval: { tool, summary } } envelope with identical validation.
Move that validation and the answer keys into
src/shared/native-chat-approval-envelope.ts; each caller still builds
its own card (phone English labels, desktop translated labels) and
keeps its own precedence and heuristics. No behaviour change.

Co-authored-by: E2E Test <e2e@test.local>
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Jinwoo Hong
2026-10-21 19:14:28 -04:00
committed by GitHub
co-authored by E2E Test Claude
parent d22fb0d5b4
commit fcac5cf0f0
6 changed files with 164 additions and 54 deletions
@@ -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']) {
@@ -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 }
]
}
}
@@ -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', () => {
@@ -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 }
]
}
}
@@ -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()
})
})
@@ -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
}
}