feat(web): make grouped tool summaries specific (#1134)

This commit is contained in:
SSU-WEI HUANG
2026-07-23 08:41:02 +08:00
committed by GitHub
parent 7688756827
commit e69ca0782f
7 changed files with 229 additions and 7 deletions
+27
View File
@@ -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([
+7
View File
@@ -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')
}
+4
View File
@@ -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}',
+4
View File
@@ -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}',