mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
feat(web): make grouped tool summaries specific (#1134)
This commit is contained in:
@@ -141,6 +141,33 @@ describe('isEligibleForToolGrouping', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Codex activity headings', () => {
|
||||
it('associates only an immediately preceding reasoning heading', () => {
|
||||
const reasoning = makeToolBlock('reasoning-1', 'CodexReasoning', { title: 'Inspecting authentication' })
|
||||
const visible = buildVisibleChatBlocks([
|
||||
reasoning,
|
||||
makeToolBlock('read-1', 'Read', { file_path: 'auth.ts' }),
|
||||
makeToolBlock('read-2', 'Read', { file_path: 'session.ts' }),
|
||||
], { hasMoreMessages: false })
|
||||
|
||||
expect(visible).toHaveLength(2)
|
||||
expect(isToolGroupBlock(visible[1])).toBe(true)
|
||||
expect(isToolGroupBlock(visible[1]) ? visible[1].activityTitle : null).toBe('Inspecting authentication')
|
||||
})
|
||||
|
||||
it('does not carry a heading across a text boundary', () => {
|
||||
const visible = buildVisibleChatBlocks([
|
||||
makeToolBlock('reasoning-1', 'CodexReasoning', { title: 'Inspecting authentication' }),
|
||||
makeTextBlock('text-boundary'),
|
||||
makeToolBlock('read-1', 'Read', { file_path: 'auth.ts' }),
|
||||
makeToolBlock('read-2', 'Read', { file_path: 'session.ts' }),
|
||||
], { hasMoreMessages: false })
|
||||
|
||||
const group = visible.find(isToolGroupBlock)
|
||||
expect(group?.activityTitle).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildVisibleChatBlocks', () => {
|
||||
it('groups contiguous eligible root tool cards', () => {
|
||||
const visible = buildVisibleChatBlocks([
|
||||
|
||||
@@ -30,6 +30,7 @@ export type ToolGroupBlock = {
|
||||
defaultOpen: boolean
|
||||
historyState: 'complete' | 'needs-older-history'
|
||||
needsOlderHistory: boolean
|
||||
activityTitle?: string | null
|
||||
summary: ToolGroupSummary
|
||||
}
|
||||
|
||||
@@ -258,6 +259,11 @@ export function buildVisibleChatBlocks(
|
||||
|
||||
const startsAtOldestVisibleBoundary = visibleBlocks.length === 0
|
||||
const needsOlderHistory = options.hasMoreMessages && startsAtOldestVisibleBoundary
|
||||
const previousBlock = visibleBlocks.at(-1)
|
||||
const activityTitle = previousBlock?.kind === 'tool-call'
|
||||
&& previousBlock.tool.name === 'CodexReasoning'
|
||||
? getInputStringAny(previousBlock.tool.input, ['title'])
|
||||
: null
|
||||
visibleBlocks.push({
|
||||
kind: 'tool-group',
|
||||
id: createToolGroupId(tools, needsOlderHistory, previousGroups),
|
||||
@@ -269,6 +275,7 @@ export function buildVisibleChatBlocks(
|
||||
defaultOpen: false,
|
||||
historyState: needsOlderHistory ? 'needs-older-history' : 'complete',
|
||||
needsOlderHistory,
|
||||
activityTitle,
|
||||
summary: summarizeToolGroup(tools)
|
||||
})
|
||||
index = cursor - 1
|
||||
|
||||
@@ -100,7 +100,7 @@ describe('ToolGroupCard', () => {
|
||||
it('renders a collapsed target-first header', () => {
|
||||
const view = renderCard(makeGroup())
|
||||
|
||||
expect(screen.getByRole('button', { name: /inspect project files/i })).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByRole('button', { name: /inspect a\.ts/i })).toHaveAttribute('aria-expanded', 'false')
|
||||
expect(screen.getByText('Run 1 · Read 1')).toBeInTheDocument()
|
||||
expect(screen.getByText('2 actions')).toBeInTheDocument()
|
||||
expect(screen.queryByText('src/a.ts')).not.toBeInTheDocument()
|
||||
@@ -111,7 +111,7 @@ describe('ToolGroupCard', () => {
|
||||
|
||||
it('expands to show compact rows and opens a detail dialog per row', async () => {
|
||||
const view = renderCard(makeGroup())
|
||||
const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i })
|
||||
const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i })
|
||||
|
||||
expect(view.container.querySelector('svg[data-state="closed"]')).toBeInTheDocument()
|
||||
fireEvent.click(groupToggle)
|
||||
@@ -212,7 +212,7 @@ describe('ToolGroupCard', () => {
|
||||
}
|
||||
|
||||
const view = render(<Harness />)
|
||||
const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i })
|
||||
const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i })
|
||||
|
||||
fireEvent.click(groupToggle)
|
||||
|
||||
@@ -272,7 +272,7 @@ describe('ToolGroupCard', () => {
|
||||
}
|
||||
|
||||
const view = render(<Harness />)
|
||||
const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i })
|
||||
const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i })
|
||||
|
||||
fireEvent.click(groupToggle)
|
||||
|
||||
@@ -327,7 +327,7 @@ describe('ToolGroupCard', () => {
|
||||
}
|
||||
|
||||
const view = render(<Harness />)
|
||||
const groupToggle = within(view.container).getByRole('button', { name: /inspect project files/i })
|
||||
const groupToggle = within(view.container).getByRole('button', { name: /inspect a\.ts/i })
|
||||
|
||||
fireEvent.click(groupToggle)
|
||||
|
||||
|
||||
@@ -114,6 +114,87 @@ describe('formatGroupedRowLabel', () => {
|
||||
})
|
||||
|
||||
describe('formatGroupedHeaderTitle', () => {
|
||||
it('uses an immediately preceding Codex activity heading', () => {
|
||||
const group = makeGroup([
|
||||
makeTool('read-activity-1', 'Read', { file_path: 'auth.ts' }),
|
||||
makeTool('read-activity-2', 'Read', { file_path: 'session.ts' }),
|
||||
])
|
||||
group.activityTitle = 'Inspecting the authentication flow'
|
||||
|
||||
expect(formatGroupedHeaderTitle(group, tEn)).toBe('Inspecting the authentication flow')
|
||||
})
|
||||
|
||||
it('uses a specific file target instead of the generic inspection label', () => {
|
||||
const group = makeGroup([
|
||||
makeTool('read-1', 'Read', { file_path: '/repo/src/auth.ts' }),
|
||||
makeTool('read-2', 'Read', { file_path: '/repo/src/session.ts' }),
|
||||
])
|
||||
|
||||
expect(formatGroupedHeaderTitle(group, tEn)).toBe('Inspect auth.ts')
|
||||
})
|
||||
|
||||
it('uses a specific search pattern', () => {
|
||||
const group = makeGroup([
|
||||
makeTool('grep-1', 'Grep', { pattern: 'authToken' }),
|
||||
makeTool('grep-2', 'Grep', { pattern: 'authToken' }),
|
||||
])
|
||||
|
||||
expect(formatGroupedHeaderTitle(group, tEn)).toBe('Search “authToken”')
|
||||
})
|
||||
|
||||
it('extracts safe targets from Codex inspection and search commands', () => {
|
||||
const inspect = makeGroup([
|
||||
makeTool('inspect-command-1', 'shell_command', { command: "sed -n '1,120p' web/src/auth.ts" }),
|
||||
makeTool('inspect-command-2', 'shell_command', { command: 'cat web/src/session.ts' }),
|
||||
])
|
||||
const search = makeGroup([
|
||||
makeTool('search-command-1', 'shell_command', { command: "rg 'authToken' web/src" }),
|
||||
makeTool('search-command-2', 'shell_command', { command: "grep 'authToken' cli/src/index.ts" }),
|
||||
])
|
||||
|
||||
expect(formatGroupedHeaderTitle(inspect, tEn)).toBe('Inspect auth.ts')
|
||||
expect(formatGroupedHeaderTitle(search, tEn)).toBe('Search “authToken”')
|
||||
})
|
||||
|
||||
it('skips search option values and redacts common token prefixes', () => {
|
||||
const optioned = makeGroup([
|
||||
makeTool('search-option-1', 'shell_command', { command: "rg -g '*.ts' authToken web/src" }),
|
||||
makeTool('search-option-2', 'shell_command', { command: "grep -m 2 authToken cli/src" }),
|
||||
])
|
||||
const credential = makeGroup([
|
||||
makeTool('search-secret-1', 'Grep', { pattern: 'ghp_1234567890abcdefghijklmnop' }),
|
||||
makeTool('search-secret-2', 'Grep', { pattern: 'ghp_1234567890abcdefghijklmnop' }),
|
||||
])
|
||||
|
||||
expect(formatGroupedHeaderTitle(optioned, tEn)).toBe('Search “authToken”')
|
||||
expect(formatGroupedHeaderTitle(credential, tEn)).toBe('Search project content')
|
||||
expect(formatGroupedHeaderTitle(credential, tEn)).not.toContain('ghp_')
|
||||
})
|
||||
|
||||
it('uses a safe project command but hides arbitrary command text', () => {
|
||||
const safe = makeGroup([
|
||||
makeTool('cmd-1', 'Bash', { command: 'bun test' }),
|
||||
makeTool('cmd-2', 'Bash', { command: 'bun test' }),
|
||||
])
|
||||
const sensitive = makeGroup([
|
||||
makeTool('cmd-3', 'Bash', { command: 'curl -H "Authorization: Bearer abc" example.com' }),
|
||||
makeTool('cmd-4', 'Bash', { command: 'curl example.com' }),
|
||||
])
|
||||
|
||||
expect(formatGroupedHeaderTitle(safe, tEn)).toBe('Run bun test')
|
||||
expect(formatGroupedHeaderTitle(sensitive, tEn)).toBe('Run project commands')
|
||||
expect(formatGroupedHeaderTitle(sensitive, tEn)).not.toContain('Bearer')
|
||||
})
|
||||
|
||||
it('prefers a Claude call description and truncates long labels', () => {
|
||||
const first = makeTool('cmd-description-1', 'Bash', { command: 'node script.js' })
|
||||
first.tool.description = 'Check the authentication migration behavior before applying changes'
|
||||
const second = makeTool('cmd-description-2', 'Bash', { command: 'node other.js' })
|
||||
const group = makeGroup([first, second])
|
||||
|
||||
expect(formatGroupedHeaderTitle(group, tEn)).toBe('Check the authentication migration behavior before applying changes')
|
||||
})
|
||||
|
||||
it('uses the primary activity without an inline +n suffix', () => {
|
||||
const group = makeGroup([
|
||||
makeTool('shell-1', 'shell_command', { command: 'Get-ChildItem src -Recurse' }),
|
||||
@@ -123,7 +204,7 @@ describe('formatGroupedHeaderTitle', () => {
|
||||
makeTool('shell-5', 'shell_command', { command: 'cat README.md' }),
|
||||
])
|
||||
|
||||
expect(formatGroupedHeaderTitle(group, tZh)).toBe('检查项目文件')
|
||||
expect(formatGroupedHeaderTitle(group, tZh)).toBe('检查 src')
|
||||
})
|
||||
|
||||
it('uses a neutral title for all-generic tool groups', () => {
|
||||
|
||||
@@ -13,8 +13,58 @@ export type GroupedSummaryIntent =
|
||||
| 'generic-command'
|
||||
| 'generic-tool'
|
||||
|
||||
const FILE_INSPECTION_COMMAND_RE = /\b(get-childitem|ls|dir|get-content|cat|type|tree)\b/i
|
||||
const FILE_INSPECTION_COMMAND_RE = /\b(get-childitem|ls|dir|get-content|cat|type|tree)\b|\bsed\s+-n\b/i
|
||||
const CONTENT_SEARCH_COMMAND_RE = /\b(rg|grep|select-string|findstr)\b/i
|
||||
const SAFE_PROJECT_COMMAND_RE = /^(?:(?:bun|npm|pnpm|yarn) (?:run )?(?:test|lint|build|typecheck)(?:[:\w.-]*)|git (?:status|diff|log)(?:\s+--?[\w.-]+)*|cargo (?:test|check)|go test(?:\s+\.\/\.\.\.)?|pytest(?:\s+-[\w-]+)*)$/i
|
||||
const SENSITIVE_TEXT_RE = /(?:bearer\s+\S+|(?:api[_-]?key|token|password|secret)(?:\s*[:=]\s*\S+|\s+\S{12,})|(?:gh[pousr]_|github_pat_|sk-[a-z0-9_-]*|xox[baprs]-)[a-z0-9_-]{12,}|[a-f0-9]{32,}|[a-z0-9_+/=-]{40,})/i
|
||||
const SEARCH_OPTIONS_WITH_VALUE = new Set([
|
||||
'-g', '--glob', '-t', '--type', '-A', '-B', '-C', '--context',
|
||||
'--before-context', '--after-context', '-m', '--max-count'
|
||||
])
|
||||
const MAX_SPECIFIC_LABEL_LENGTH = 72
|
||||
|
||||
function truncateLabel(value: string): string {
|
||||
const normalized = value.replace(/\s+/g, ' ').trim()
|
||||
return normalized.length > MAX_SPECIFIC_LABEL_LENGTH
|
||||
? `${normalized.slice(0, MAX_SPECIFIC_LABEL_LENGTH - 1)}…`
|
||||
: normalized
|
||||
}
|
||||
|
||||
function safeLabelValue(value: string | null): string | null {
|
||||
if (!value || SENSITIVE_TEXT_RE.test(value)) return null
|
||||
return truncateLabel(value)
|
||||
}
|
||||
|
||||
function basename(value: string): string {
|
||||
const parts = value.replace(/\\/g, '/').split('/').filter(Boolean)
|
||||
return parts.at(-1) ?? value
|
||||
}
|
||||
|
||||
function simpleCommandParts(command: string): string[] | null {
|
||||
if (/[;&|<>$`(){}\n\r]/.test(command)) return null
|
||||
return command.trim().split(/\s+/).filter(Boolean)
|
||||
}
|
||||
|
||||
function getInspectionCommandTarget(command: string): string | null {
|
||||
const parts = simpleCommandParts(command)
|
||||
if (!parts || parts.length < 2) return null
|
||||
const target = [...parts].reverse().find((part) => !part.startsWith('-') && !/^['"]?\d+(?:,\d+)?p['"]?$/.test(part))
|
||||
if (!target || target === parts[0]) return null
|
||||
return safeLabelValue(target.replace(/^['"]|['"]$/g, ''))
|
||||
}
|
||||
|
||||
function getSearchCommandPattern(command: string): string | null {
|
||||
const parts = simpleCommandParts(command)
|
||||
if (!parts) return null
|
||||
const executableIndex = parts.findIndex((part) => /^(?:rg|grep|select-string|findstr)$/i.test(part))
|
||||
if (executableIndex < 0) return null
|
||||
for (let index = executableIndex + 1; index < parts.length; index += 1) {
|
||||
const part = parts[index]
|
||||
if (!part.startsWith('-')) return safeLabelValue(part.replace(/^['"]|['"]$/g, ''))
|
||||
if (SEARCH_OPTIONS_WITH_VALUE.has(part)) index += 1
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getCommandText(input: unknown): string | null {
|
||||
const direct = getInputStringAny(input, ['command', 'cmd'])
|
||||
@@ -103,8 +153,57 @@ function getPrimaryIntent(block: ToolGroupBlock): GroupedSummaryIntent {
|
||||
return primary
|
||||
}
|
||||
|
||||
function formatSpecificIntentTitle(block: ToolGroupBlock, intent: GroupedSummaryIntent, t: Translator): string | null {
|
||||
const matching = block.tools.filter((tool) => inferGroupedSummaryIntent(tool) === intent)
|
||||
const described = matching
|
||||
.map((tool) => safeLabelValue(tool.tool.description))
|
||||
.find((value): value is string => value !== null)
|
||||
if (described) return described
|
||||
|
||||
if (intent === 'inspect-files' || intent === 'modify-files') {
|
||||
for (const tool of matching) {
|
||||
const target = safeLabelValue(getInputStringAny(tool.tool.input, ['file_path', 'path', 'file', 'filePath', 'notebook_path']))
|
||||
if (target) {
|
||||
return t(intent === 'modify-files' ? 'toolGroup.friendly.editTarget' : 'toolGroup.friendly.inspectTarget', {
|
||||
target: basename(target)
|
||||
})
|
||||
}
|
||||
if (intent === 'inspect-files') {
|
||||
const command = getCommandText(tool.tool.input)
|
||||
const commandTarget = command ? getInspectionCommandTarget(command) : null
|
||||
if (commandTarget) return t('toolGroup.friendly.inspectTarget', { target: basename(commandTarget) })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === 'search-content') {
|
||||
for (const tool of matching) {
|
||||
const pattern = safeLabelValue(getInputStringAny(tool.tool.input, ['pattern', 'query']))
|
||||
if (pattern) return t('toolGroup.friendly.searchTarget', { target: pattern })
|
||||
const command = getCommandText(tool.tool.input)
|
||||
const commandPattern = command ? getSearchCommandPattern(command) : null
|
||||
if (commandPattern) return t('toolGroup.friendly.searchTarget', { target: commandPattern })
|
||||
}
|
||||
}
|
||||
|
||||
if (intent === 'run-project-command') {
|
||||
for (const tool of matching) {
|
||||
const command = safeLabelValue(getCommandText(tool.tool.input))
|
||||
if (command && SAFE_PROJECT_COMMAND_RE.test(command)) {
|
||||
return t('toolGroup.friendly.runTarget', { target: command })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
export function formatGroupedHeaderTitle(block: ToolGroupBlock, t: Translator): string {
|
||||
const activityTitle = safeLabelValue(block.activityTitle ?? null)
|
||||
if (activityTitle) return activityTitle
|
||||
const primaryIntent = getPrimaryIntent(block)
|
||||
const specificTitle = formatSpecificIntentTitle(block, primaryIntent, t)
|
||||
if (specificTitle) return specificTitle
|
||||
if (primaryIntent === 'generic-tool') {
|
||||
return t('toolGroup.title')
|
||||
}
|
||||
|
||||
@@ -442,6 +442,10 @@ export default {
|
||||
'tool.requestUserInput.notePlaceholder': 'Add a note…',
|
||||
'tool.requestUserInput.popupBlocked': 'Could not open the sign-in page. Allow popups and try again.',
|
||||
'toolGroup.title': 'Tool activity',
|
||||
'toolGroup.friendly.inspectTarget': 'Inspect {target}',
|
||||
'toolGroup.friendly.searchTarget': 'Search “{target}”',
|
||||
'toolGroup.friendly.runTarget': 'Run {target}',
|
||||
'toolGroup.friendly.editTarget': 'Edit {target}',
|
||||
'toolGroup.primary.fileTargets': '{target} +{n}',
|
||||
'toolGroup.primary.commandTargets': '{target} +{n}',
|
||||
'toolGroup.primary.searchTargets': '{target} +{n}',
|
||||
|
||||
@@ -446,6 +446,10 @@ export default {
|
||||
'tool.requestUserInput.notePlaceholder': '添加备注…',
|
||||
'tool.requestUserInput.popupBlocked': '无法打开登录页面。请允许弹出窗口后重试。',
|
||||
'toolGroup.title': '工具活动',
|
||||
'toolGroup.friendly.inspectTarget': '检查 {target}',
|
||||
'toolGroup.friendly.searchTarget': '搜索“{target}”',
|
||||
'toolGroup.friendly.runTarget': '运行 {target}',
|
||||
'toolGroup.friendly.editTarget': '编辑 {target}',
|
||||
'toolGroup.primary.fileTargets': '{target} 等 +{n}',
|
||||
'toolGroup.primary.commandTargets': '{target} 等 +{n}',
|
||||
'toolGroup.primary.searchTargets': '{target} 等 +{n}',
|
||||
|
||||
Reference in New Issue
Block a user