mirror of
https://github.com/stablyai/orca.git
synced 2026-10-08 08:02:32 +00:00
Merge remote-tracking branch 'origin/main' into brennanb2025/nc-resume-on-restart
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
|
||||
|
||||
@@ -17347,7 +17347,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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { execFile } from 'node:child_process'
|
||||
import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { promisify } from 'node:util'
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
isUnsupportedWorktreeListZError
|
||||
} from './git-worktree-command-capabilities'
|
||||
import { gitCredentialPromptGuardEnv } from './git-credential-prompt-env'
|
||||
import { buildGitGrepArgs } from './text-search'
|
||||
import { parseGitRemoteFetchUrls } from './git-remote-url-index'
|
||||
import { GIT_HISTORY_COMMIT_FORMAT, parseGitHistoryLog } from './git-history-log-parser'
|
||||
import {
|
||||
@@ -566,4 +567,27 @@ describeBinaryCompatibility('real Git binary compatibility', () => {
|
||||
expect(item?.subject).toBe('decorated commit')
|
||||
expect(item?.references?.map((ref) => ref.id)).toContain('refs/tags/compat-decorated')
|
||||
})
|
||||
|
||||
it('excludes and includes a directory subtree through the generated pathspecs', async () => {
|
||||
await mkdir(join(repoPath, 'vendored'), { recursive: true })
|
||||
await writeFile(join(repoPath, 'vendored', 'inner.txt'), 'pathspecneedle\n')
|
||||
await writeFile(join(repoPath, 'kept.txt'), 'pathspecneedle\n')
|
||||
await runGit(['add', '-A'])
|
||||
await runGit(['commit', '-qm', 'pathspec fixture'])
|
||||
|
||||
const listFiles = async (opts: Parameters<typeof buildGitGrepArgs>[1]): Promise<string[]> => {
|
||||
const args = buildGitGrepArgs('pathspecneedle', opts).map((arg) =>
|
||||
arg === '-n' ? '-l' : arg
|
||||
)
|
||||
const { stdout } = await runGit(args)
|
||||
return stdout.split(/[\0\n]/).filter(Boolean)
|
||||
}
|
||||
|
||||
const excluded = await listFiles({ excludePattern: 'vendored' })
|
||||
expect(excluded).toContain('kept.txt')
|
||||
expect(excluded.some((file) => file.startsWith('vendored/'))).toBe(false)
|
||||
|
||||
const included = await listFiles({ includePattern: 'vendored' })
|
||||
expect(included).toEqual(['vendored/inner.txt'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -37,3 +37,13 @@ export function toGitGlobPathspec(glob: string, exclude?: boolean): string {
|
||||
const pattern = needsRecursive ? `**/${glob}` : glob
|
||||
return exclude ? `:(exclude,glob)${pattern}` : `:(glob)${pattern}`
|
||||
}
|
||||
|
||||
export function toGitGlobPathspecs(glob: string, exclude?: boolean): string[] {
|
||||
const directoryOnly = /\/+$/u.test(glob)
|
||||
const trimmed = glob.replace(/\/+$/, '')
|
||||
if (!trimmed) {
|
||||
return []
|
||||
}
|
||||
const pathspec = toGitGlobPathspec(trimmed, exclude)
|
||||
return directoryOnly ? [`${pathspec}/**`] : [pathspec, `${pathspec}/**`]
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
MAX_LINE_CONTENT_LENGTH,
|
||||
SEARCH_JSON_STRUCTURE_LIMITS
|
||||
} from './text-search'
|
||||
import { splitSearchGlobPatterns, toGitGlobPathspec } from './text-search-glob-patterns'
|
||||
import { splitSearchGlobPatterns, toGitGlobPathspecs } from './text-search-glob-patterns'
|
||||
import { normalizeRelativePath } from './text-search-paths'
|
||||
|
||||
describe('normalizeRelativePath', () => {
|
||||
@@ -239,6 +239,18 @@ describe('buildGitGrepArgs', () => {
|
||||
expect(args).toContain(':(exclude,glob)dist/**')
|
||||
})
|
||||
|
||||
it('excludes a directory subtree the way rg --glob does', () => {
|
||||
const args = buildGitGrepArgs('q', { excludePattern: 'node_modules' })
|
||||
expect(args).toContain(':(exclude,glob)**/node_modules')
|
||||
expect(args).toContain(':(exclude,glob)**/node_modules/**')
|
||||
})
|
||||
|
||||
it('includes a directory subtree the way rg --glob does', () => {
|
||||
const args = buildGitGrepArgs('q', { includePattern: 'src' })
|
||||
expect(args).toContain(':(glob)**/src')
|
||||
expect(args).toContain(':(glob)**/src/**')
|
||||
})
|
||||
|
||||
it('keeps escaped commas inside one generated folder pathspec', () => {
|
||||
const args = buildGitGrepArgs('q', { includePattern: 'foo\\,bar/**, *.ts' })
|
||||
expect(args).toContain(':(glob)foo\\,bar/**')
|
||||
@@ -246,11 +258,29 @@ describe('buildGitGrepArgs', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('toGitGlobPathspec', () => {
|
||||
describe('toGitGlobPathspecs', () => {
|
||||
it('wraps bare globs with **/ to match recursively', () => {
|
||||
expect(toGitGlobPathspec('*.ts')).toBe(':(glob)**/*.ts')
|
||||
expect(toGitGlobPathspec('src/*.ts')).toBe(':(glob)src/*.ts')
|
||||
expect(toGitGlobPathspec('*.ts', true)).toBe(':(exclude,glob)**/*.ts')
|
||||
expect(toGitGlobPathspecs('*.ts')).toContain(':(glob)**/*.ts')
|
||||
expect(toGitGlobPathspecs('src/*.ts')).toContain(':(glob)src/*.ts')
|
||||
expect(toGitGlobPathspecs('*.ts', true)).toContain(':(exclude,glob)**/*.ts')
|
||||
})
|
||||
|
||||
it('also covers the subtree so a directory name behaves like rg --glob', () => {
|
||||
expect(toGitGlobPathspecs('node_modules', true)).toEqual([
|
||||
':(exclude,glob)**/node_modules',
|
||||
':(exclude,glob)**/node_modules/**'
|
||||
])
|
||||
expect(toGitGlobPathspecs('src')).toEqual([':(glob)**/src', ':(glob)**/src/**'])
|
||||
expect(toGitGlobPathspecs('build/out')).toEqual([':(glob)build/out', ':(glob)build/out/**'])
|
||||
})
|
||||
|
||||
it('keeps trailing-slash patterns directory-only', () => {
|
||||
expect(toGitGlobPathspecs('node_modules/', true)).toEqual([':(exclude,glob)**/node_modules/**'])
|
||||
expect(toGitGlobPathspecs('src/')).toEqual([':(glob)**/src/**'])
|
||||
})
|
||||
|
||||
it('drops a pattern that is only separators', () => {
|
||||
expect(toGitGlobPathspecs('/')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -321,6 +351,23 @@ describe('ingestGitGrepLine', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('does not broaden a separator-only include pattern to the repository', () => {
|
||||
const rootPath = mkdtempSync(join(tmpdir(), 'orca-search-git-'))
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: rootPath, stdio: 'ignore' })
|
||||
writeFileSync(join(rootPath, 'target.ts'), 'needle\n')
|
||||
|
||||
expect(() =>
|
||||
execFileSync('git', buildGitGrepArgs('needle', { includePattern: '/' }), {
|
||||
cwd: rootPath,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
).toThrow()
|
||||
} finally {
|
||||
rmSync(rootPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('parses git grep null-delimited line, finds all submatch positions', () => {
|
||||
const acc = createAccumulator()
|
||||
const re = buildSubmatchRegex('foo', {})
|
||||
@@ -358,6 +405,23 @@ describe('ingestGitGrepLine', () => {
|
||||
expect(f.matches[0]).toMatchObject({ line: 10, column: 1, matchLength: 12 })
|
||||
})
|
||||
|
||||
it('does not broaden a separator-only include pattern to the repository', () => {
|
||||
const rootPath = mkdtempSync(join(tmpdir(), 'orca-search-git-'))
|
||||
try {
|
||||
execFileSync('git', ['init'], { cwd: rootPath, stdio: 'ignore' })
|
||||
writeFileSync(join(rootPath, 'target.ts'), 'needle\n')
|
||||
|
||||
expect(() =>
|
||||
execFileSync('git', buildGitGrepArgs('needle', { includePattern: '/' }), {
|
||||
cwd: rootPath,
|
||||
stdio: 'ignore'
|
||||
})
|
||||
).toThrow()
|
||||
} finally {
|
||||
rmSync(rootPath, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('handles colons in filenames via null delimiter', () => {
|
||||
const acc = createAccumulator()
|
||||
const re = buildSubmatchRegex('x', {})
|
||||
|
||||
@@ -12,7 +12,7 @@ import { normalizeSearchResult } from './search-match-count'
|
||||
import { escapeRegex } from './string-utils'
|
||||
import type { SearchFileResult, SearchOptions, SearchResult } from './code-search-types'
|
||||
import { pushSearchMatch } from './text-search-match-accumulator'
|
||||
import { splitSearchGlobPatterns, toGitGlobPathspec } from './text-search-glob-patterns'
|
||||
import { splitSearchGlobPatterns, toGitGlobPathspecs } from './text-search-glob-patterns'
|
||||
import { joinSearchRoot, normalizeRelativePath, relativeToSearchRoot } from './text-search-paths'
|
||||
|
||||
export type SearchAccumulator = {
|
||||
@@ -195,20 +195,26 @@ export function buildGitGrepArgs(query: string, opts: SearchOptionsLike): string
|
||||
gitArgs.push('-e', query, '--')
|
||||
|
||||
let hasPathspecs = false
|
||||
let hasIncludePathspecs = false
|
||||
if (opts.includePattern) {
|
||||
for (const pat of splitSearchGlobPatterns(opts.includePattern)) {
|
||||
gitArgs.push(toGitGlobPathspec(pat))
|
||||
hasPathspecs = true
|
||||
const pathspecs = toGitGlobPathspecs(pat)
|
||||
gitArgs.push(...pathspecs)
|
||||
hasPathspecs ||= pathspecs.length > 0
|
||||
hasIncludePathspecs ||= pathspecs.length > 0
|
||||
}
|
||||
}
|
||||
if (opts.excludePattern) {
|
||||
for (const pat of splitSearchGlobPatterns(opts.excludePattern)) {
|
||||
gitArgs.push(toGitGlobPathspec(pat, true))
|
||||
hasPathspecs = true
|
||||
const pathspecs = toGitGlobPathspecs(pat, true)
|
||||
gitArgs.push(...pathspecs)
|
||||
hasPathspecs ||= pathspecs.length > 0
|
||||
}
|
||||
}
|
||||
// Why: git grep needs a pathspec to search the working tree; '.' means everything under cwd.
|
||||
if (!hasPathspecs) {
|
||||
if (opts.includePattern && !hasIncludePathspecs) {
|
||||
gitArgs.push(':(top,literal).git')
|
||||
} else if (!hasPathspecs) {
|
||||
gitArgs.push('.')
|
||||
}
|
||||
return gitArgs
|
||||
|
||||
Reference in New Issue
Block a user