mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
co-authored by
E2E Test
Claude
parent
d22fb0d5b4
commit
fcac5cf0f0
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user