mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
fix(native-chat): render approvals from the harness presentation, not serialized tool input (#21087)
* fix(native-chat): render approvals from the harness presentation, not serialized tool input The approval card built its title from the tool name and rendered JSON.stringify(input) into an element with no height bound. Any large payload - a file write's contents, a proposed plan - pushed the action buttons past the viewport with no way to scroll to them, leaving the prompt unanswerable without zooming the pane out. Thread the agent SDK's own presentation fields through the prompt registry into the journal item: title, displayName, description, decisionReason, blockedPath and matchedAskRule. The SDK documents its title as the prompt text to use instead of reconstructing one, and warns that the decision reason may carry terminal escapes, so those are stripped before rendering. The card now also shows why a request was raised rather than only what it was. Bound the detail in a scrollable region that is reachable by keyboard, and cap it main-side with the existing shared tool-detail limit rather than the far looser journal payload bound. Focus moves to the card when a prompt appears and Escape resolves it, which previously did nothing because the composer owning that handler is unmounted while a prompt is pending. Mobile rendered the same unbounded detail and is fixed alongside. * fix(native-chat): keep approval actions reachable
This commit is contained in:
@@ -5,6 +5,7 @@ import { MobileNativeChatPermission } from './MobileNativeChatPermission'
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
Pressable: 'Pressable',
|
||||
ScrollView: 'ScrollView',
|
||||
StyleSheet: { create: (styles: unknown) => styles, hairlineWidth: 1 },
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
@@ -62,4 +63,50 @@ describe('MobileNativeChatPermission', () => {
|
||||
await act(async () => cancel.props.onPress())
|
||||
expect(onCancel).toHaveBeenCalledWith({ itemId: 'approval-1', expectedRevision: 4 })
|
||||
})
|
||||
|
||||
it('keeps oversized provider context in a bounded scroller above the actions', async () => {
|
||||
const description = `Workspace access ${'description '.repeat(400)}`
|
||||
const decisionReason = `Outside the allowed root ${'reason '.repeat(400)}`
|
||||
const blockedPath = `/repo/${'nested/'.repeat(400)}secrets.txt`
|
||||
const ruleContent = `/repo/${'**/'.repeat(400)}`
|
||||
await act(async () => {
|
||||
renderer = create(
|
||||
createElement(MobileNativeChatPermission, {
|
||||
permission: {
|
||||
title: 'Claude wants to read secrets.txt '.repeat(400),
|
||||
description,
|
||||
decisionReason,
|
||||
blockedPath,
|
||||
matchedAskRule: { source: 'project', toolName: 'Read', ruleContent },
|
||||
options: [{ label: 'Allow', send: '1' }]
|
||||
},
|
||||
onRespond: vi.fn(async () => true)
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
const card = renderer.root.findByProps({ testID: 'native-chat-approval-card' })
|
||||
const title = renderer.root.findByProps({ testID: 'native-chat-approval-title' })
|
||||
const content = renderer.root.findByProps({ testID: 'native-chat-approval-content' })
|
||||
const actions = renderer.root.findByProps({ testID: 'native-chat-approval-actions' })
|
||||
const contentText = content.findAllByType('Text')
|
||||
const containsText = (value: string): boolean =>
|
||||
contentText.some((node) => {
|
||||
const children = Array.isArray(node.props.children)
|
||||
? node.props.children
|
||||
: [node.props.children]
|
||||
return children.includes(value)
|
||||
})
|
||||
|
||||
expect(card.props.style).toMatchObject({ flexShrink: 1, minHeight: 0 })
|
||||
expect(title.props).toMatchObject({ numberOfLines: 2, ellipsizeMode: 'tail' })
|
||||
expect(content.props.style).toMatchObject({ maxHeight: 240, minHeight: 0, flexShrink: 1 })
|
||||
expect(containsText(description)).toBe(true)
|
||||
expect(containsText(decisionReason)).toBe(true)
|
||||
expect(containsText(blockedPath)).toBe(true)
|
||||
expect(containsText(ruleContent)).toBe(true)
|
||||
expect(content.findAllByProps({ children: 'Allow' })).toHaveLength(0)
|
||||
expect(actions.findAllByProps({ children: 'Allow' })).toHaveLength(1)
|
||||
expect(actions.props.style).toMatchObject({ flexShrink: 0 })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { memo, useRef, useState } from 'react'
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'
|
||||
import { ShieldQuestion, X } from 'lucide-react-native'
|
||||
import { colors, radii, spacing, typography } from '../theme/mobile-theme'
|
||||
import type { MobileChatPermission } from './mobile-native-chat-permission'
|
||||
@@ -18,6 +18,13 @@ function MobileNativeChatPermissionImpl({
|
||||
}): React.JSX.Element {
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const submittingRef = useRef(false)
|
||||
const hasContext = Boolean(
|
||||
permission.description ||
|
||||
permission.decisionReason ||
|
||||
permission.blockedPath ||
|
||||
permission.matchedAskRule ||
|
||||
permission.detail
|
||||
)
|
||||
const respond = async (send: string): Promise<void> => {
|
||||
if (submittingRef.current) {
|
||||
return
|
||||
@@ -31,10 +38,17 @@ function MobileNativeChatPermissionImpl({
|
||||
}
|
||||
}
|
||||
return (
|
||||
<View style={styles.card}>
|
||||
<View testID="native-chat-approval-card" style={styles.card}>
|
||||
<View style={styles.header}>
|
||||
<ShieldQuestion size={16} color={colors.accentBlue} strokeWidth={2} />
|
||||
<Text style={styles.title}>{permission.title}</Text>
|
||||
<Text
|
||||
testID="native-chat-approval-title"
|
||||
style={styles.title}
|
||||
numberOfLines={2}
|
||||
ellipsizeMode="tail"
|
||||
>
|
||||
{permission.title}
|
||||
</Text>
|
||||
{onCancel ? (
|
||||
<Pressable
|
||||
accessibilityLabel="Cancel"
|
||||
@@ -47,8 +61,40 @@ function MobileNativeChatPermissionImpl({
|
||||
</Pressable>
|
||||
) : null}
|
||||
</View>
|
||||
{permission.detail ? <Text style={styles.detail}>{permission.detail}</Text> : null}
|
||||
<View style={styles.options}>
|
||||
{hasContext ? (
|
||||
<ScrollView
|
||||
testID="native-chat-approval-content"
|
||||
style={styles.contentScroll}
|
||||
contentContainerStyle={styles.content}
|
||||
nestedScrollEnabled
|
||||
>
|
||||
{permission.description ? (
|
||||
<Text style={styles.detail}>{permission.description}</Text>
|
||||
) : null}
|
||||
{permission.decisionReason ? (
|
||||
<Text style={styles.detail}>
|
||||
<Text style={styles.contextLabel}>Reason: </Text>
|
||||
{permission.decisionReason}
|
||||
</Text>
|
||||
) : null}
|
||||
{permission.blockedPath ? (
|
||||
<Text style={styles.detail}>
|
||||
<Text style={styles.contextLabel}>Blocked path: </Text>
|
||||
{permission.blockedPath}
|
||||
</Text>
|
||||
) : null}
|
||||
{permission.matchedAskRule ? (
|
||||
<Text style={styles.detail}>
|
||||
<Text style={styles.contextLabel}>Ask rule: </Text>
|
||||
{permission.matchedAskRule.ruleContent ?? permission.matchedAskRule.toolName}
|
||||
{' · '}
|
||||
{permission.matchedAskRule.source}
|
||||
</Text>
|
||||
) : null}
|
||||
{permission.detail ? <Text style={styles.detail}>{permission.detail}</Text> : null}
|
||||
</ScrollView>
|
||||
) : null}
|
||||
<View testID="native-chat-approval-actions" style={styles.options}>
|
||||
{permission.options.map((option, index) => {
|
||||
const isPrimary = index === 0
|
||||
return (
|
||||
@@ -85,12 +131,15 @@ const styles = StyleSheet.create({
|
||||
borderRadius: radii.card,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: colors.borderSubtle,
|
||||
backgroundColor: colors.bgPanel
|
||||
backgroundColor: colors.bgPanel,
|
||||
flexShrink: 1,
|
||||
minHeight: 0
|
||||
},
|
||||
header: {
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: spacing.sm
|
||||
gap: spacing.sm,
|
||||
flexShrink: 0
|
||||
},
|
||||
title: {
|
||||
flex: 1,
|
||||
@@ -109,10 +158,23 @@ const styles = StyleSheet.create({
|
||||
fontSize: typography.metaSize,
|
||||
lineHeight: typography.metaSize + 5
|
||||
},
|
||||
contextLabel: {
|
||||
color: colors.textPrimary,
|
||||
fontWeight: '600'
|
||||
},
|
||||
contentScroll: {
|
||||
maxHeight: 240,
|
||||
minHeight: 0,
|
||||
flexShrink: 1
|
||||
},
|
||||
content: {
|
||||
gap: spacing.sm
|
||||
},
|
||||
options: {
|
||||
flexDirection: 'row',
|
||||
flexWrap: 'wrap',
|
||||
gap: spacing.sm
|
||||
gap: spacing.sm,
|
||||
flexShrink: 0
|
||||
},
|
||||
option: {
|
||||
minHeight: 44,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { AgentJournalApprovalMatchedAskRule } from '../../../src/shared/agent-session-journal-types'
|
||||
|
||||
// Agent permission asks (e.g. Claude/Codex "Do you want to proceed?") surface
|
||||
// as plain TUI text in the agent's last assistant message — there is no
|
||||
// structured permission event on mobile. We detect them heuristically so the
|
||||
@@ -10,6 +12,11 @@
|
||||
* (e.g. "y", "1") when the user taps it. */
|
||||
export type MobileChatPermission = {
|
||||
title: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
decisionReason?: string
|
||||
blockedPath?: string
|
||||
matchedAskRule?: AgentJournalApprovalMatchedAskRule
|
||||
detail?: string
|
||||
/** Structured prompt identity, present only when the host can cancel it exactly. */
|
||||
prompt?: { itemId: string; expectedRevision: number }
|
||||
|
||||
@@ -135,6 +135,11 @@ export function projectStructuredPermission(
|
||||
return {
|
||||
title: prompt.body.title,
|
||||
prompt: { itemId: prompt.itemId, expectedRevision: prompt.revision },
|
||||
...(prompt.body.displayName ? { displayName: prompt.body.displayName } : {}),
|
||||
...(prompt.body.description ? { description: prompt.body.description } : {}),
|
||||
...(prompt.body.decisionReason ? { decisionReason: prompt.body.decisionReason } : {}),
|
||||
...(prompt.body.blockedPath ? { blockedPath: prompt.body.blockedPath } : {}),
|
||||
...(prompt.body.matchedAskRule ? { matchedAskRule: prompt.body.matchedAskRule } : {}),
|
||||
...(prompt.body.detail ? { detail: prompt.body.detail } : {}),
|
||||
options: prompt.body.options.map((option) => ({
|
||||
label: option.label,
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { CanUseTool } from '@anthropic-ai/claude-agent-sdk'
|
||||
import {
|
||||
stripAnsiEscapeSequences,
|
||||
TERMINAL_CONTROL_CHARACTER_PATTERN
|
||||
} from '../../shared/ansi-escape-sequences'
|
||||
import type { ClaudePromptPresentation } from './claude-prompt-registry'
|
||||
|
||||
type ClaudePermissionOptions = Parameters<CanUseTool>[2]
|
||||
|
||||
function presentationText(value: unknown): string | null {
|
||||
if (typeof value !== 'string') {
|
||||
return null
|
||||
}
|
||||
const sanitized = stripAnsiEscapeSequences(value)
|
||||
.replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '')
|
||||
.trim()
|
||||
return sanitized.length > 0 ? sanitized : null
|
||||
}
|
||||
|
||||
export function claudePermissionPresentation(
|
||||
options: ClaudePermissionOptions
|
||||
): ClaudePromptPresentation {
|
||||
const title = presentationText(options.title)
|
||||
const displayName = presentationText(options.displayName)
|
||||
const description = presentationText(options.description)
|
||||
const decisionReason = presentationText(options.decisionReason)
|
||||
const blockedPath = presentationText(options.blockedPath)
|
||||
const matchedSource = presentationText(options.matchedAskRule?.source)
|
||||
const matchedToolName = presentationText(options.matchedAskRule?.toolName)
|
||||
const matchedRuleContent = presentationText(options.matchedAskRule?.ruleContent)
|
||||
return {
|
||||
...(title ? { title } : {}),
|
||||
...(displayName ? { displayName } : {}),
|
||||
...(description ? { description } : {}),
|
||||
...(decisionReason ? { decisionReason } : {}),
|
||||
...(blockedPath ? { blockedPath } : {}),
|
||||
...(matchedSource && matchedToolName
|
||||
? {
|
||||
matchedAskRule: {
|
||||
source: matchedSource,
|
||||
toolName: matchedToolName,
|
||||
...(matchedRuleContent ? { ruleContent: matchedRuleContent } : {})
|
||||
}
|
||||
}
|
||||
: {})
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,19 @@
|
||||
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { AgentJournalApprovalMatchedAskRule } from '../../shared/agent-session-journal-types'
|
||||
|
||||
/** Settles the SDK's `canUseTool` promise; `null` writes no provider response. */
|
||||
export type ClaudePromptSettle = (response: PermissionResult | null) => void
|
||||
|
||||
export type ClaudePendingPrompt = {
|
||||
export type ClaudePromptPresentation = {
|
||||
title?: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
decisionReason?: string
|
||||
blockedPath?: string
|
||||
matchedAskRule?: AgentJournalApprovalMatchedAskRule
|
||||
}
|
||||
|
||||
export type ClaudePendingPrompt = ClaudePromptPresentation & {
|
||||
requestId: string
|
||||
promptKey: string
|
||||
toolUseId: string
|
||||
@@ -17,7 +27,7 @@ export type ClaudePendingPrompt = {
|
||||
turnId?: string | null
|
||||
}
|
||||
|
||||
export type ClaudePromptRegistration = {
|
||||
export type ClaudePromptRegistration = ClaudePromptPresentation & {
|
||||
requestId: string
|
||||
toolName: string
|
||||
toolUseId: string
|
||||
@@ -89,6 +99,12 @@ export class ClaudePromptRegistry {
|
||||
kind: questions.length > 0 ? 'question' : 'approval',
|
||||
input,
|
||||
suggestions: Array.isArray(registration.suggestions) ? registration.suggestions : [],
|
||||
...(registration.title ? { title: registration.title } : {}),
|
||||
...(registration.displayName ? { displayName: registration.displayName } : {}),
|
||||
...(registration.description ? { description: registration.description } : {}),
|
||||
...(registration.decisionReason ? { decisionReason: registration.decisionReason } : {}),
|
||||
...(registration.blockedPath ? { blockedPath: registration.blockedPath } : {}),
|
||||
...(registration.matchedAskRule ? { matchedAskRule: registration.matchedAskRule } : {}),
|
||||
questionIds: questions.map(questionId),
|
||||
answers: new Map(),
|
||||
settle: registration.settle,
|
||||
|
||||
@@ -14,14 +14,17 @@ function permissionOptions(
|
||||
requestId: string,
|
||||
toolUseID: string,
|
||||
signal: AbortSignal,
|
||||
suggestions?: unknown[]
|
||||
suggestions?: CanUseToolOptions['suggestions'],
|
||||
presentation: Partial<CanUseToolOptions> = {}
|
||||
): CanUseToolOptions {
|
||||
// Spread first so the required fields below stay definite rather than optional.
|
||||
return {
|
||||
...presentation,
|
||||
requestId,
|
||||
toolUseID,
|
||||
signal,
|
||||
...(suggestions ? { suggestions } : {})
|
||||
} as unknown as CanUseToolOptions
|
||||
}
|
||||
}
|
||||
|
||||
function callbacksFor() {
|
||||
@@ -41,7 +44,9 @@ describe('Claude permission callbacks', () => {
|
||||
const answered = control.canUseTool(
|
||||
'Bash',
|
||||
{ command: 'git status' },
|
||||
permissionOptions('perm-1', 'tool-1', new AbortController().signal, [{ type: 'addRules' }])
|
||||
permissionOptions('perm-1', 'tool-1', new AbortController().signal, [
|
||||
{ type: 'addRules', rules: [], behavior: 'allow', destination: 'session' }
|
||||
])
|
||||
)
|
||||
|
||||
expect(control.emit).toHaveBeenCalledWith(
|
||||
@@ -52,12 +57,49 @@ describe('Claude permission callbacks', () => {
|
||||
})
|
||||
)
|
||||
const found = control.prompts.find('perm-1')
|
||||
expect(found?.prompt.suggestions).toEqual([{ type: 'addRules' }])
|
||||
expect(found?.prompt.suggestions).toEqual([
|
||||
{ type: 'addRules', rules: [], behavior: 'allow', destination: 'session' }
|
||||
])
|
||||
// The prompt's settle is the SDK callback's own resolve — answering resolves this promise.
|
||||
found?.prompt.settle({ behavior: 'allow', toolUseID: 'tool-1' })
|
||||
await expect(answered).resolves.toEqual({ behavior: 'allow', toolUseID: 'tool-1' })
|
||||
})
|
||||
|
||||
it('keeps the SDK permission presentation and strips terminal escapes', async () => {
|
||||
const control = callbacksFor()
|
||||
const answered = control.canUseTool(
|
||||
'Read',
|
||||
{ file_path: '/repo/secrets.txt' },
|
||||
permissionOptions(
|
||||
'perm-presentation',
|
||||
'tool-presentation',
|
||||
new AbortController().signal,
|
||||
[],
|
||||
{
|
||||
title: '\u001b[31mClaude wants to read secrets.txt\u001b[0m',
|
||||
displayName: 'Read file',
|
||||
description: 'Read access outside the workspace',
|
||||
decisionReason: '\u001b[33mThe path is outside the allowed root.\u001b[0m',
|
||||
blockedPath: '/repo/secrets.txt',
|
||||
matchedAskRule: { source: 'project', toolName: 'Read', ruleContent: '/repo/**' }
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
const prompt = control.prompts.find('perm-presentation')?.prompt
|
||||
expect(prompt).toMatchObject({
|
||||
title: 'Claude wants to read secrets.txt',
|
||||
displayName: 'Read file',
|
||||
description: 'Read access outside the workspace',
|
||||
decisionReason: 'The path is outside the allowed root.',
|
||||
blockedPath: '/repo/secrets.txt',
|
||||
matchedAskRule: { source: 'project', toolName: 'Read', ruleContent: '/repo/**' }
|
||||
})
|
||||
expect(JSON.stringify(prompt)).not.toContain('\\u001b')
|
||||
prompt?.settle({ behavior: 'deny', message: 'done', toolUseID: 'tool-presentation' })
|
||||
await expect(answered).resolves.toMatchObject({ behavior: 'deny' })
|
||||
})
|
||||
|
||||
it('denies a malformed permission request without registering a prompt', async () => {
|
||||
const control = callbacksFor()
|
||||
const answered = control.canUseTool(
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type { CanUseTool, OnUserDialog, PermissionResult } from '@anthropic-ai/claude-agent-sdk'
|
||||
import type { ClaudePromptRegistry } from './claude-structured-prompt-replies'
|
||||
import type { ClaudeStructuredSessionEvent } from './claude-structured-session-state'
|
||||
import { claudePermissionPresentation } from './claude-permission-presentation'
|
||||
|
||||
export const CLAUDE_CAN_USE_TOOL_SUBTYPE = 'can_use_tool'
|
||||
export const CLAUDE_REQUEST_USER_DIALOG_SUBTYPE = 'request_user_dialog'
|
||||
@@ -53,6 +54,7 @@ export function buildClaudePermissionCallbacks(deps: ClaudePermissionCallbackDep
|
||||
const canUseTool: CanUseTool = (toolName, input, options) =>
|
||||
new Promise<PermissionResult | null>((resolve) => {
|
||||
const prompt = deps.prompts.register({
|
||||
...claudePermissionPresentation(options),
|
||||
requestId: options.requestId,
|
||||
toolName,
|
||||
toolUseId: options.toolUseID,
|
||||
|
||||
@@ -3,13 +3,96 @@ import { agentJournalItemKey } from '../../shared/agent-session-journal-item-key
|
||||
import { encodeAgentSessionQuestionAnswers } from '../../shared/agent-session-question-answer'
|
||||
import { cancelledJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds'
|
||||
import { MAX_JOURNAL_LIFECYCLE_BATCH_BYTES } from '../native-chat/agent-session-journal/journal-row-schema'
|
||||
import { claudeQuestionItems } from './claude-structured-prompt-items'
|
||||
import { MAX_TOOL_DETAIL_LENGTH } from '../../shared/native-chat-tool-summary'
|
||||
import { claudeApprovalItem, claudeQuestionItems } from './claude-structured-prompt-items'
|
||||
import {
|
||||
applyClaudePromptAnswer,
|
||||
encodeClaudeQuestionOptionId,
|
||||
type ClaudePendingPrompt
|
||||
} from './claude-structured-prompt-replies'
|
||||
|
||||
function approvalPrompt(
|
||||
input: Record<string, unknown>,
|
||||
presentation: Partial<ClaudePendingPrompt> = {}
|
||||
): ClaudePendingPrompt {
|
||||
return {
|
||||
requestId: 'approval-1',
|
||||
promptKey: 'approval-1',
|
||||
toolUseId: 'tool-approval',
|
||||
toolName: 'ExitPlanMode',
|
||||
kind: 'approval',
|
||||
input,
|
||||
suggestions: [],
|
||||
questionIds: [],
|
||||
answers: new Map(),
|
||||
settle: () => {},
|
||||
...presentation
|
||||
}
|
||||
}
|
||||
|
||||
describe('Claude structured approval presentation', () => {
|
||||
it('journals the harness presentation instead of reconstructing from tool input', () => {
|
||||
const prompt = approvalPrompt(
|
||||
{ file_path: '/repo/secrets.txt', content: 'export const token = 1' },
|
||||
{
|
||||
toolName: 'Write',
|
||||
title: 'Claude wants to write secrets.txt',
|
||||
displayName: 'Write file',
|
||||
description: 'Write access inside the workspace.',
|
||||
decisionReason: 'The path requires approval.',
|
||||
blockedPath: '/repo/secrets.txt',
|
||||
matchedAskRule: { source: 'project', toolName: 'Write', ruleContent: 'ask' }
|
||||
}
|
||||
)
|
||||
|
||||
expect(claudeApprovalItem(prompt)).toMatchObject({
|
||||
kind: 'approval',
|
||||
title: 'Claude wants to write secrets.txt',
|
||||
displayName: 'Write file',
|
||||
description: 'Write access inside the workspace.',
|
||||
decisionReason: 'The path requires approval.',
|
||||
blockedPath: '/repo/secrets.txt',
|
||||
matchedAskRule: { source: 'project', toolName: 'Write', ruleContent: 'ask' },
|
||||
options: [
|
||||
{ id: 'allow', label: 'Allow' },
|
||||
{ id: 'allowForSession', label: 'Allow for this session' },
|
||||
{ id: 'deny', label: 'Deny' },
|
||||
{ id: 'cancel', label: 'Stop' }
|
||||
]
|
||||
})
|
||||
})
|
||||
|
||||
it.each([{ plan: '' }, {}])(
|
||||
'falls back to a reconstructed title when the harness sends no presentation',
|
||||
(input) => {
|
||||
const item = claudeApprovalItem(approvalPrompt(input))
|
||||
|
||||
expect(item.title).toBe('Allow ExitPlanMode?')
|
||||
expect(item.detail).toContain('{')
|
||||
expect(item.options[0]?.label).toBe('Allow')
|
||||
}
|
||||
)
|
||||
|
||||
it('caps an oversized generic payload with the shared tool-detail limit', () => {
|
||||
const item = claudeApprovalItem(
|
||||
approvalPrompt({ plan: '', payload: 'x'.repeat(MAX_TOOL_DETAIL_LENGTH * 2) })
|
||||
)
|
||||
|
||||
expect(item.detail?.length).toBeLessThanOrEqual(MAX_TOOL_DETAIL_LENGTH + 1)
|
||||
expect(item.detail?.endsWith('…')).toBe(true)
|
||||
})
|
||||
|
||||
it('denying authorizes nothing', () => {
|
||||
const prompt = approvalPrompt({ plan: '# Release' })
|
||||
|
||||
expect(applyClaudePromptAnswer({ prompt }, 'deny')).toEqual({
|
||||
behavior: 'deny',
|
||||
message: 'User denied this action.',
|
||||
toolUseID: 'tool-approval'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Claude structured question addressing', () => {
|
||||
it('bounds a valid grouped question before cancellation enters a lifecycle batch', () => {
|
||||
const oversized = 'large prompt text '.repeat(40_000)
|
||||
|
||||
@@ -5,10 +5,7 @@ import type {
|
||||
AgentJournalQuestion,
|
||||
AgentJournalQuestionItem
|
||||
} from '../../shared/agent-session-journal-types'
|
||||
import {
|
||||
boundInlineText,
|
||||
DEFAULT_JOURNAL_PAYLOAD_LIMITS
|
||||
} from '../native-chat/agent-session-journal/journal-payload-bounds'
|
||||
import { formatToolInput, truncateToolDetail } from '../../shared/native-chat-tool-summary'
|
||||
import { boundJournalPromptBody } from '../native-chat/agent-session-journal/journal-prompt-body-bounds'
|
||||
import { claudeRecord, claudeText } from './claude-structured-item-translation'
|
||||
import {
|
||||
@@ -45,17 +42,22 @@ export function claudePromptIdentity(input: {
|
||||
}
|
||||
|
||||
export function claudeApprovalItem(prompt: ClaudePendingPrompt): AgentJournalApprovalItem {
|
||||
const serialized = JSON.stringify(prompt.input)
|
||||
return {
|
||||
const detail = truncateToolDetail(formatToolInput(prompt.input))
|
||||
return boundJournalPromptBody({
|
||||
kind: 'approval',
|
||||
title: `Allow ${prompt.toolName}?`,
|
||||
detail: serialized ? boundInlineText(serialized, DEFAULT_JOURNAL_PAYLOAD_LIMITS).text : null,
|
||||
title: prompt.title ?? `Allow ${prompt.toolName}?`,
|
||||
...(prompt.displayName ? { displayName: prompt.displayName } : {}),
|
||||
...(prompt.description ? { description: prompt.description } : {}),
|
||||
...(prompt.decisionReason ? { decisionReason: prompt.decisionReason } : {}),
|
||||
...(prompt.blockedPath ? { blockedPath: prompt.blockedPath } : {}),
|
||||
...(prompt.matchedAskRule ? { matchedAskRule: prompt.matchedAskRule } : {}),
|
||||
detail: detail || null,
|
||||
options: CLAUDE_APPROVAL_DECISIONS.map((decision) => ({
|
||||
id: decision,
|
||||
label: APPROVAL_LABELS[decision]
|
||||
})),
|
||||
resolution: { ...PENDING }
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export type ClaudeQuestionItem = {
|
||||
|
||||
@@ -10,6 +10,7 @@ export {
|
||||
ClaudePromptRegistry,
|
||||
type ClaudePendingPrompt,
|
||||
type ClaudePromptClaim,
|
||||
type ClaudePromptPresentation,
|
||||
type ClaudePromptRegistration,
|
||||
type ClaudePromptSettle
|
||||
} from './claude-prompt-registry'
|
||||
|
||||
@@ -51,6 +51,23 @@ export function boundJournalPromptBody(
|
||||
return {
|
||||
...body,
|
||||
title: boundPromptText(body.title),
|
||||
...(body.displayName === undefined ? {} : { displayName: boundPromptText(body.displayName) }),
|
||||
...(body.description === undefined ? {} : { description: boundPromptText(body.description) }),
|
||||
...(body.decisionReason === undefined
|
||||
? {}
|
||||
: { decisionReason: boundPromptText(body.decisionReason) }),
|
||||
...(body.blockedPath === undefined ? {} : { blockedPath: boundPromptText(body.blockedPath) }),
|
||||
...(body.matchedAskRule === undefined
|
||||
? {}
|
||||
: {
|
||||
matchedAskRule: {
|
||||
source: boundPromptText(body.matchedAskRule.source),
|
||||
toolName: boundPromptText(body.matchedAskRule.toolName),
|
||||
...(body.matchedAskRule.ruleContent === undefined
|
||||
? {}
|
||||
: { ruleContent: boundPromptText(body.matchedAskRule.ruleContent) })
|
||||
}
|
||||
}),
|
||||
detail: body.detail === null ? null : boundPromptText(body.detail),
|
||||
options: boundPromptOptions(body.options)
|
||||
}
|
||||
|
||||
@@ -661,7 +661,10 @@ describe('a structured Claude session over agentSession.*', () => {
|
||||
)
|
||||
await getStructuredAgentSessionHost()?.flushStreamedEvents(SESSION)
|
||||
const approval = itemsOf(stream).find((item) => item.body?.kind === 'approval')
|
||||
expect(approval?.body).toMatchObject({ title: 'Allow Bash?', detail: '{"command":"ls"}' })
|
||||
expect(approval?.body).toMatchObject({
|
||||
title: 'Allow Bash?',
|
||||
detail: '{\n "command": "ls"\n}'
|
||||
})
|
||||
await ok('agentSession.respondToApproval', {
|
||||
envelope: envelope(
|
||||
'agentSession.respondTo:approval',
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
import { fireEvent, render, screen } from '@testing-library/react'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { NativeChatApprovalCard } from './NativeChatApprovalCard'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('NativeChatApprovalCard', () => {
|
||||
it('exposes cancellation while it owns the composer region', () => {
|
||||
const onCancel = vi.fn()
|
||||
@@ -26,4 +28,76 @@ describe('NativeChatApprovalCard', () => {
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Cancel' }))
|
||||
expect(onCancel).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('focuses once on appearance and routes Escape through cancellation', () => {
|
||||
const onCancel = vi.fn()
|
||||
const { rerender } = render(
|
||||
<NativeChatApprovalCard
|
||||
approval={{ title: 'Allow command?', options: [{ label: 'Allow', send: 'allow' }] }}
|
||||
onChoose={() => {}}
|
||||
onCancel={onCancel}
|
||||
shouldFocus
|
||||
/>
|
||||
)
|
||||
const card = screen.getByRole('group', { name: 'Allow command?' })
|
||||
|
||||
expect(document.activeElement).toBe(card)
|
||||
fireEvent.keyDown(card, { key: 'Escape' })
|
||||
expect(onCancel).toHaveBeenCalledOnce()
|
||||
|
||||
const outside = document.createElement('button')
|
||||
document.body.appendChild(outside)
|
||||
outside.focus()
|
||||
rerender(
|
||||
<NativeChatApprovalCard
|
||||
approval={{ title: 'Allow command?', options: [{ label: 'Allow', send: 'allow' }] }}
|
||||
onChoose={() => {}}
|
||||
onCancel={onCancel}
|
||||
shouldFocus
|
||||
/>
|
||||
)
|
||||
expect(document.activeElement).toBe(outside)
|
||||
outside.remove()
|
||||
})
|
||||
|
||||
it('keeps all oversized provider context in one bounded scroller above the actions', () => {
|
||||
const description = `Read access outside the workspace ${'description '.repeat(400)}`
|
||||
const decisionReason = `The path is outside the allowed root. ${'reason '.repeat(400)}`
|
||||
const blockedPath = `/repo/${'nested/'.repeat(400)}secrets.txt`
|
||||
const ruleContent = `/repo/${'**/'.repeat(400)}`
|
||||
render(
|
||||
<NativeChatApprovalCard
|
||||
approval={{
|
||||
title: 'Claude wants to read secrets.txt '.repeat(400),
|
||||
description,
|
||||
decisionReason,
|
||||
blockedPath,
|
||||
matchedAskRule: { source: 'project', toolName: 'Read', ruleContent },
|
||||
detail: 'x'.repeat(4_000),
|
||||
options: [{ label: 'Allow', send: 'allow' }]
|
||||
}}
|
||||
onChoose={() => {}}
|
||||
/>
|
||||
)
|
||||
|
||||
const card = document.querySelector('[data-native-chat-approval-card="true"]')
|
||||
const content = document.querySelector('[data-native-chat-approval-content="true"]')
|
||||
const detail = document.querySelector('[data-native-chat-approval-detail="true"]')
|
||||
const actions = document.querySelector('[data-native-chat-approval-actions="true"]')
|
||||
const allow = screen.getByRole('button', { name: 'Allow' })
|
||||
|
||||
expect(card?.classList.contains('min-h-0')).toBe(true)
|
||||
expect(card?.classList.contains('overflow-hidden')).toBe(true)
|
||||
expect(content?.classList.contains('max-h-72')).toBe(true)
|
||||
expect(content?.classList.contains('overflow-auto')).toBe(true)
|
||||
expect(content?.getAttribute('tabindex')).toBe('0')
|
||||
expect(content?.textContent).toContain(description.trim())
|
||||
expect(content?.textContent).toContain(decisionReason.trim())
|
||||
expect(content?.textContent).toContain(blockedPath)
|
||||
expect(content?.textContent).toContain(ruleContent)
|
||||
expect(content?.contains(detail)).toBe(true)
|
||||
expect(content?.contains(allow)).toBe(false)
|
||||
expect(actions?.contains(allow)).toBe(true)
|
||||
expect(actions?.classList.contains('shrink-0')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useEffect, useRef } from 'react'
|
||||
import { ShieldQuestion, X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { translate } from '@/i18n/i18n'
|
||||
@@ -5,36 +6,62 @@ import type { ChatApproval } from './native-chat-interactive-prompt'
|
||||
|
||||
export type NativeChatApprovalCardProps = {
|
||||
approval: ChatApproval
|
||||
/** Send the chosen option's literal string to the agent's PTY. */
|
||||
onChoose: (send: string) => void
|
||||
/** Deliver the option's transport-specific response token. */
|
||||
onChoose: (option: string) => void
|
||||
/** Cancel the active provider turn while this card owns the composer region. */
|
||||
onCancel?: () => void
|
||||
shouldFocus?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Native renderer for an agent tool-approval (PermissionRequest) as an
|
||||
* Allow/Deny card. Each button writes its option's literal `send` string back
|
||||
* to the agent (a number to allow; ESC to deny). The first option reads as the
|
||||
* affirmative action and gets the primary styling.
|
||||
* Allow/Deny card. PTY callers supply literal replies while structured callers
|
||||
* supply journal option IDs. The first option gets the primary styling.
|
||||
*/
|
||||
export function NativeChatApprovalCard({
|
||||
approval,
|
||||
onChoose,
|
||||
onCancel
|
||||
onCancel,
|
||||
shouldFocus = false
|
||||
}: NativeChatApprovalCardProps): React.JSX.Element {
|
||||
const cardRef = useRef<HTMLDivElement>(null)
|
||||
const hasContext = Boolean(
|
||||
approval.description ||
|
||||
approval.decisionReason ||
|
||||
approval.blockedPath ||
|
||||
approval.matchedAskRule ||
|
||||
approval.detail
|
||||
)
|
||||
useEffect(() => {
|
||||
if (shouldFocus) {
|
||||
cardRef.current?.focus()
|
||||
}
|
||||
}, [shouldFocus])
|
||||
|
||||
return (
|
||||
<div className="shrink-0 bg-background">
|
||||
<div className="mx-auto w-full max-w-4xl px-3 pt-2 pb-1 sm:px-4">
|
||||
<div className="flex w-full flex-col gap-2 rounded-lg border border-input bg-card px-4 py-3 shadow-xs">
|
||||
<div className="flex items-start gap-2">
|
||||
<div className="min-h-0 shrink overflow-hidden bg-background">
|
||||
<div className="mx-auto flex h-full min-h-0 max-h-full w-full max-w-4xl px-3 pt-2 pb-1 sm:px-4">
|
||||
<div
|
||||
ref={cardRef}
|
||||
data-native-chat-approval-card="true"
|
||||
role="group"
|
||||
aria-label={approval.title}
|
||||
tabIndex={-1}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === 'Escape' && !event.nativeEvent.isComposing && onCancel) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onCancel()
|
||||
}
|
||||
}}
|
||||
className="flex min-h-0 w-full flex-1 flex-col gap-2 overflow-hidden rounded-lg border border-input bg-card px-4 py-3 shadow-xs focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
||||
>
|
||||
<div className="flex shrink-0 items-start gap-2">
|
||||
<ShieldQuestion className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-semibold text-foreground">{approval.title}</p>
|
||||
{approval.detail ? (
|
||||
<p className="mt-0.5 break-words font-mono text-xs text-muted-foreground">
|
||||
{approval.detail}
|
||||
</p>
|
||||
) : null}
|
||||
<p className="line-clamp-2 break-words text-sm font-semibold text-foreground">
|
||||
{approval.title}
|
||||
</p>
|
||||
</div>
|
||||
{onCancel ? (
|
||||
<button
|
||||
@@ -47,7 +74,54 @@ export function NativeChatApprovalCard({
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{hasContext ? (
|
||||
<div
|
||||
data-native-chat-approval-content="true"
|
||||
tabIndex={0}
|
||||
className="min-h-0 max-h-72 shrink space-y-2 overflow-auto text-xs text-muted-foreground scrollbar-sleek focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring/70"
|
||||
>
|
||||
{approval.description ? (
|
||||
<p className="whitespace-pre-wrap break-words">{approval.description}</p>
|
||||
) : null}
|
||||
{approval.decisionReason ? (
|
||||
<p className="whitespace-pre-wrap break-words">
|
||||
<span className="font-medium text-foreground/80">
|
||||
{translate('components.native-chat.approval.reason', 'Reason')}:{' '}
|
||||
</span>
|
||||
{approval.decisionReason}
|
||||
</p>
|
||||
) : null}
|
||||
{approval.blockedPath ? (
|
||||
<p className="break-words">
|
||||
<span className="font-medium text-foreground/80">
|
||||
{translate('components.native-chat.approval.blockedPath', 'Blocked path')}:{' '}
|
||||
</span>
|
||||
<span className="font-mono">{approval.blockedPath}</span>
|
||||
</p>
|
||||
) : null}
|
||||
{approval.matchedAskRule ? (
|
||||
<p className="break-words">
|
||||
<span className="font-medium text-foreground/80">
|
||||
{translate('components.native-chat.approval.askRule', 'Ask rule')}:{' '}
|
||||
</span>
|
||||
{approval.matchedAskRule.ruleContent ?? approval.matchedAskRule.toolName}
|
||||
<span className="text-muted-foreground/80">
|
||||
{' · '}
|
||||
{approval.matchedAskRule.source}
|
||||
</span>
|
||||
</p>
|
||||
) : null}
|
||||
{approval.detail ? (
|
||||
<div
|
||||
data-native-chat-approval-detail="true"
|
||||
className="whitespace-pre-wrap break-words font-mono"
|
||||
>
|
||||
{approval.detail}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div data-native-chat-approval-actions="true" className="flex shrink-0 flex-wrap gap-2">
|
||||
{approval.options.map((opt, i) => (
|
||||
<button
|
||||
key={`${opt.label}-${i}`}
|
||||
|
||||
@@ -62,6 +62,21 @@ describe('resolution receipts', () => {
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('uses the SDK display name in the compact resolved receipt', () => {
|
||||
render(
|
||||
<NativeChatResolutionReceipt
|
||||
body={{
|
||||
...approval,
|
||||
title: 'Claude wants to present its implementation plan',
|
||||
displayName: 'Present plan'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(screen.getByText('Present plan')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Claude wants to present its implementation plan')).toBeNull()
|
||||
})
|
||||
|
||||
it('renders cancellation quietly without inventing a choice or resolver', () => {
|
||||
render(
|
||||
<NativeChatResolutionReceipt
|
||||
|
||||
@@ -35,7 +35,7 @@ export function NativeChatResolutionReceipt({
|
||||
) : null
|
||||
}
|
||||
const { resolution } = body
|
||||
const title = body.kind === 'approval' ? body.title : body.question
|
||||
const title = body.kind === 'approval' ? (body.displayName ?? body.title) : body.question
|
||||
const answers = nativeChatReceiptAnswers(body)
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -104,6 +104,22 @@ export function NativeChatStructuredSession(
|
||||
{ sessionId: props.sessionId, isVisible: props.isVisible }
|
||||
)
|
||||
const prompt = controller.prompts[0] ?? null
|
||||
const approvalBody = prompt?.body.kind === 'approval' ? prompt.body : null
|
||||
const approval = approvalBody
|
||||
? {
|
||||
title: approvalBody.title,
|
||||
...(approvalBody.displayName ? { displayName: approvalBody.displayName } : {}),
|
||||
...(approvalBody.description ? { description: approvalBody.description } : {}),
|
||||
...(approvalBody.decisionReason ? { decisionReason: approvalBody.decisionReason } : {}),
|
||||
...(approvalBody.blockedPath ? { blockedPath: approvalBody.blockedPath } : {}),
|
||||
...(approvalBody.matchedAskRule ? { matchedAskRule: approvalBody.matchedAskRule } : {}),
|
||||
...(approvalBody.detail ? { detail: approvalBody.detail } : {}),
|
||||
options: approvalBody.options.map((option) => ({
|
||||
label: option.label,
|
||||
send: option.id
|
||||
}))
|
||||
}
|
||||
: null
|
||||
const cancelPrompt = () => {
|
||||
if (controller.turnId && prompt) {
|
||||
void controller.cancel(controller.turnId, {
|
||||
@@ -222,18 +238,13 @@ export function NativeChatStructuredSession(
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{prompt?.body.kind === 'approval' ? (
|
||||
{prompt && approval ? (
|
||||
<NativeChatApprovalCard
|
||||
approval={{
|
||||
title: prompt.body.title,
|
||||
...(prompt.body.detail ? { detail: prompt.body.detail } : {}),
|
||||
options: prompt.body.options.map((option) => ({
|
||||
label: option.label,
|
||||
send: option.id
|
||||
}))
|
||||
}}
|
||||
key={`${prompt.itemId}:${prompt.revision}`}
|
||||
approval={approval}
|
||||
onChoose={(optionId) => void controller.respond(prompt, optionId)}
|
||||
onCancel={cancelPrompt}
|
||||
shouldFocus={props.isVisible && props.isFocusedGroup}
|
||||
/>
|
||||
) : null}
|
||||
{prompt && questionBody ? (
|
||||
|
||||
@@ -254,6 +254,23 @@ describe('useNativeChatComposerRevealFocus', () => {
|
||||
expect(focusCalls).toBe(1)
|
||||
})
|
||||
|
||||
it('re-arms when a prompt replaces an already focused composer', () => {
|
||||
render({ isVisible: true, isFocusedGroup: true, composerReady: true })
|
||||
drainFrames()
|
||||
expect(focusCalls).toBe(1)
|
||||
|
||||
render({ isVisible: true, isFocusedGroup: true, composerReady: false })
|
||||
drainFrames()
|
||||
act(() => {
|
||||
;(document.activeElement as HTMLElement | null)?.blur()
|
||||
})
|
||||
render({ isVisible: true, isFocusedGroup: true, composerReady: true })
|
||||
drainFrames()
|
||||
|
||||
expect(focusCalls).toBe(2)
|
||||
expect(container.querySelector('textarea')).toBe(document.activeElement)
|
||||
})
|
||||
|
||||
it('leaves focus alone when it is already inside the pane', () => {
|
||||
render({ isVisible: false, isFocusedGroup: true })
|
||||
const field = container.querySelector('textarea')
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { translate } from '@/i18n/i18n'
|
||||
import type { AgentJournalApprovalMatchedAskRule } from '../../../../shared/agent-session-journal-types'
|
||||
import {
|
||||
buildAskAnswerKeys,
|
||||
buildCodexAskAnswerKeys,
|
||||
@@ -31,6 +32,11 @@ export {
|
||||
|
||||
export type ChatApproval = {
|
||||
title: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
decisionReason?: string
|
||||
blockedPath?: string
|
||||
matchedAskRule?: AgentJournalApprovalMatchedAskRule
|
||||
detail?: string
|
||||
options: { label: string; send: string }[]
|
||||
}
|
||||
|
||||
@@ -36,11 +36,11 @@ export function useNativeChatComposerRevealFocus({
|
||||
const claimedRef = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!revealed) {
|
||||
if (!revealed || !composerReady) {
|
||||
claimedRef.current = false
|
||||
return
|
||||
}
|
||||
if (claimedRef.current || !composerReady) {
|
||||
if (claimedRef.current) {
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
|
||||
@@ -17316,7 +17316,10 @@
|
||||
"title": "Allow {{value0}}?",
|
||||
"allow": "Allow",
|
||||
"deny": "Deny",
|
||||
"cancel": "Cancel"
|
||||
"cancel": "Cancel",
|
||||
"reason": "Reason",
|
||||
"blockedPath": "Blocked path",
|
||||
"askRule": "Ask rule"
|
||||
},
|
||||
"launchPromptNotDelivered": "Not delivered — check the terminal",
|
||||
"structuredSessionCloseFailed": "Could not close this chat session",
|
||||
|
||||
@@ -50,7 +50,12 @@ const CANONICAL_BODIES: AgentJournalItemBody[] = [
|
||||
{ kind: 'diff', path: 'a.ts', patch: PAYLOAD },
|
||||
{
|
||||
kind: 'approval',
|
||||
title: 'Run?',
|
||||
title: 'Claude wants to present a plan',
|
||||
displayName: 'Present plan',
|
||||
description: 'Review the proposed implementation steps.',
|
||||
decisionReason: 'Plan mode requires approval.',
|
||||
blockedPath: '/repo/PLAN.md',
|
||||
matchedAskRule: { source: 'project', toolName: 'ExitPlanMode', ruleContent: 'ask' },
|
||||
detail: null,
|
||||
options: [{ id: 'a', label: 'Yes' }],
|
||||
resolution: RESOLUTION
|
||||
|
||||
@@ -132,6 +132,12 @@ const Resolution = z.object({
|
||||
resolvedAt: z.number().nullable()
|
||||
})
|
||||
|
||||
const ApprovalMatchedAskRule = z.object({
|
||||
source: z.string(),
|
||||
toolName: z.string(),
|
||||
ruleContent: z.string().optional()
|
||||
})
|
||||
|
||||
const MessageBody = z.object({
|
||||
kind: z.literal('message'),
|
||||
role: z.string().min(1),
|
||||
@@ -154,6 +160,11 @@ export const AgentJournalItemBodySchema = z.discriminatedUnion('kind', [
|
||||
z.object({
|
||||
kind: z.literal('approval'),
|
||||
title: z.string(),
|
||||
displayName: z.string().optional(),
|
||||
description: z.string().optional(),
|
||||
decisionReason: z.string().optional(),
|
||||
blockedPath: z.string().optional(),
|
||||
matchedAskRule: ApprovalMatchedAskRule.optional(),
|
||||
detail: z.string().nullable(),
|
||||
options: z.array(PromptOption),
|
||||
resolution: Resolution
|
||||
|
||||
@@ -140,9 +140,20 @@ export type AgentJournalQuestion = {
|
||||
freeTextQuestionId?: string
|
||||
}
|
||||
|
||||
export type AgentJournalApprovalMatchedAskRule = {
|
||||
source: string
|
||||
toolName: string
|
||||
ruleContent?: string
|
||||
}
|
||||
|
||||
export type AgentJournalApprovalItem = {
|
||||
kind: 'approval'
|
||||
title: string
|
||||
displayName?: string
|
||||
description?: string
|
||||
decisionReason?: string
|
||||
blockedPath?: string
|
||||
matchedAskRule?: AgentJournalApprovalMatchedAskRule
|
||||
detail: string | null
|
||||
options: AgentJournalPromptOption[]
|
||||
resolution: AgentJournalResolution
|
||||
|
||||
Reference in New Issue
Block a user