diff --git a/apps/web/tests/auto-review-denial.e2e.ts b/apps/web/tests/auto-review-denial.e2e.ts index 58371631a9..d967b4cb9b 100644 --- a/apps/web/tests/auto-review-denial.e2e.ts +++ b/apps/web/tests/auto-review-denial.e2e.ts @@ -119,13 +119,16 @@ describe.skipIf(MODE === 'record')('web e2e: cold Auto-review denial', () => { await ledger.locator('table[data-scroll-ready="true"]').waitFor({ timeout: 15_000 }) const nativeRecord = ledger.locator('tr[data-kind="tool"]').filter({ hasText: 'mystery' }) const innerRecord = ledger.locator('tr[data-kind="subtool"]').filter({ hasText: 'bash' }) - await expect.poll(() => nativeRecord.textContent()).toContain('AUTO_REVIEW_DENIED') - await expect.poll(() => innerRecord.textContent()).toContain('AUTO_REVIEW_DENIED') + await expect.poll(() => nativeRecord.textContent()).toContain('Error: Auto review rejected tool "mystery"; its body was not executed') + await expect.poll(() => innerRecord.textContent()).toContain('Error: Auto review rejected tool "bash"; its body was not executed') await innerRecord.click() - await page.getByRole('tab', { name: 'Result', exact: true }).click() - await page.getByRole('tabpanel', { name: 'Result' }) - .getByText('AutoReviewDeniedError: AUTO_REVIEW_DENIED', { exact: true }) - .waitFor({ state: 'visible' }) + for (const tab of ['Summary', 'Result']) { + await page.getByRole('tab', { name: tab, exact: true }).click() + const panel = page.getByRole('tabpanel', { name: tab }) + await panel.getByText('Error: Auto review rejected tool "bash"; its body was not executed', { exact: true }) + .waitFor({ state: 'visible' }) + expect(await panel.getByText('AutoReviewDeniedError: AUTO_REVIEW_DENIED', { exact: true }).count()).toBe(0) + } await captureAutoReviewState(page, 'deny-trajectory') const trajectory = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index bb680650bd..ea67520fe4 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/README.i18n.yaml @@ -24,8 +24,8 @@ en: 0f4ee2189324ac06 zh: 12e76fed22c09bc1 /deepseek-ai-dsh-client-ui-trajectory/understand-the-implementation: - en: 0eaa2a895454e053 - zh: 6fb5c2682698fca9 + en: 0d22ad8a6bfc0eab + zh: 3221c4dd5874c585 /deepseek-ai-dsh-client-ui-trajectory/understand-the-implementation/virtual-rows: en: c85e342bc7eee4e6 zh: 9feab66ef1755b94 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index b63301d743..102bda3ea4 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -59,7 +59,9 @@ The view is a pure projection: Trajectory-owned Definitions assemble business re User, steering, and context inputs and request prompt changes share their request's Step group in log order, before the request separator and assistant output. -Native and nested PTC Tool results retain their raw structured error details and presentation metadata. The Result tab stacks the live canonical value above the rendered content, with one scroll area for the entire panel and no section height limits. Both JSON trees preview long strings up to 12 lines and allow expansion. In-flight PTC calls show “Running…” until a result is available. Historical, failed, evicted, and unloaded raw results show “Raw results are only available for live tool calls” while rendered content remains available. There is no result-mode preference. The inspector subscribes only to the selected result while the Result tab is open, releasing it on selection changes or disposal; Session unload and connection loss clear the displayed value. JSON primitives, including `null`, remain inspectable. Summary previews continue to show rendered content. Failed records show the error code in the ledger and the error name and code in the inspector. +Native and nested PTC Tool results retain their raw structured error details and presentation metadata. The Result tab stacks the live canonical value above the rendered content, with one scroll area for the entire panel and no section height limits. Both JSON trees preview long strings up to 12 lines and allow expansion. In-flight PTC calls show “Running…” until a result is available. Historical, failed, evicted, and unloaded raw results show “Raw results are only available for live tool calls” while rendered content remains available. There is no result-mode preference. The inspector subscribes only to the selected result while the Result tab is open, releasing it on selection changes or disposal; Session unload and connection loss clear the displayed value. JSON primitives, including `null`, remain inspectable. Summary previews continue to show rendered content. + +Failed records use non-empty recorded result text for ledger previews, inspector output, and output copying, preserving whitespace-only text. JSON failure output stays as recorded text in Summary and the rendered section of Result. With that text present, structured error names and codes are neither displayed nor separately indexed for search. Only failures without any non-empty text block fall back to the error code in the ledger and the error name and code in the inspector; image content remains visible beside that fallback. Tool records begin at durable tool/call events and retain complete argument text with a lazy `args` view, reused by paired results. Chat's transient preparing stage does not create Trajectory tool rows or alter historical tool timing. diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 3f5f9ccfc0..3c57058729 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -59,7 +59,9 @@ Trajectory 标签页让你以按轮次组织的事件记录表和交互式时间 用户消息、中途引导、上下文输入及请求提示词变更按日志顺序放在所属请求的 Step 分组内,位于请求分隔行和助手输出之前。 -原生与嵌套 PTC 工具结果保留原始结构化错误详情和展示元数据。结果标签页从上到下展示实时规范值与渲染内容,整个面板共用一个滚动区域,不限制各 section 的高度。两侧 JSON 树的长字符串均预览最多 12 行,并可展开。执行中的 PTC 调用在结果可用前显示“运行中”。历史、失败、已淘汰及已卸载的原始结果显示“原始结果仅在实时工具调用中可用”,渲染内容仍可查看。不再保留结果模式偏好。检查器仅在结果标签页打开时订阅当前选中的结果,在切换选择或销毁时释放;Session 卸载及连接断开会清除已展示的值。包括 `null` 在内的 JSON 基本值均可查看。概述预览仍显示渲染内容。失败记录在记录表中显示错误代码,在检查器中显示错误名称和代码。 +原生与嵌套 PTC 工具结果保留原始结构化错误详情和展示元数据。结果标签页从上到下展示实时规范值与渲染内容,整个面板共用一个滚动区域,不限制各 section 的高度。两侧 JSON 树的长字符串均预览最多 12 行,并可展开。执行中的 PTC 调用在结果可用前显示“运行中”。历史、失败、已淘汰及已卸载的原始结果显示“原始结果仅在实时工具调用中可用”,渲染内容仍可查看。不再保留结果模式偏好。检查器仅在结果标签页打开时订阅当前选中的结果,在切换选择或销毁时释放;Session 卸载及连接断开会清除已展示的值。包括 `null` 在内的 JSON 基本值均可查看。概述预览仍显示渲染内容。 + +失败记录在记录表预览、检查器输出和输出复制中使用已记录的非空结果文本,并保留仅含空白字符的文本。JSON 失败输出在“概述”和“结果”的渲染区域保持原文。存在这类文本时,结构化错误名称和代码既不显示,也不单独纳入搜索索引。仅在失败结果没有任何非空文本块时,记录表才回退到错误代码,检查器才回退到错误名称和代码;图片内容仍显示在回退信息旁边。 工具记录从持久 tool/call 事件开始,保留完整参数文本和由配对结果复用的懒计算 `args` 视图。Chat 的瞬态 preparing 阶段不创建 Trajectory 工具行,也不改变历史工具计时。 diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 15d9ca3515..6b7acc0dca 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -1502,7 +1502,7 @@ function ToolOutputBlocks({ }: { blocks: readonly TrajectorySourceBlock[] error: boolean - /** Failure name and code preserved beside image-only error content. */ + /** Failure name and code shown only when no recorded text block is non-empty. */ errorDetail?: string | undefined preview: boolean renderSlot: PropsRenderSlots<'conversation.trajectory.images'>['renderSlot'] @@ -1516,6 +1516,7 @@ function ToolOutputBlocks({ ].filter((value): value is string => value !== undefined).join(' ')} > {error && errorDetail !== undefined && errorDetail !== '' + && !blocks.some(block => block.type === 'text' && block.content !== '') &&
{errorDetail}
} {blocks.map((block, index) => ( block.attachment !== undefined @@ -1743,7 +1744,7 @@ function RecordPayload({ const payloadClass = preview ? css.jsonPreview : css.jsonPayload const payloadClassName = error ? `${payloadClass} ${css.errorPayload}` : payloadClass - const json = parseJsonContainer(value) + const json = error ? undefined : parseJsonContainer(value) const singleTextResult = direction === 'output' && record.cell.outputBlocks?.length === 1 && record.cell.outputBlocks[0]?.type === 'text' diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index a3d2a017cb..06c9015a89 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -1072,14 +1072,14 @@ function summarizeResult( node: ToolResultNode, t: TrajectoryTranslate, ): Pick { - if (node.isError) { - return { result: node.error?.code ?? 'error' } - } for (const block of node.content) { if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') { return { result: '', resultPreviewMarkdown: block.text } } } + if (node.isError) { + return { result: node.error?.code ?? 'error' } + } const images = imageBlockCount(node.content) if (images > 0) return { result: t('layout.imageCount', { count: images }) } return { result: t('record.noOutput') } @@ -1097,16 +1097,17 @@ function resultAsText( } function detailResult(node: ToolResultNode, t: TrajectoryTranslate): string { + const text = node.content + .filter(block => block.type === 'text' && typeof block.text === 'string') + .map(block => block.type === 'text' ? block.text : '') + .join('\n') + if (text !== '' && (!node.isError || node.content.some(block => + block.type === 'text' && typeof block.text === 'string' && block.text !== ''))) return text if (node.isError) { return node.error === undefined ? 'error' : `${node.error.name}: ${node.error.code}` } - const text = node.content - .filter(block => block.type === 'text' && typeof block.text === 'string') - .map(block => block.type === 'text' ? block.text : '') - .join('\n') - if (text !== '') return text const images = imageBlockCount(node.content) if (images > 0) return t('layout.imageCount', { count: images }) if ( diff --git a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx index a17a907aec..a92d13659a 100644 --- a/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/code-inspector.client.spec.tsx @@ -2,7 +2,10 @@ /** PTC source, output, and independent wrapping through the trajectory inspector. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, waitFor, within } from '@testing-library/react' +import { PartialArguments } from '@deepseek-ai/dsh-util-values' +import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-conversation/client' import { TrajectoryTable } from '../src/client/TrajectoryTable.tsx' +import { deriveTrajectoryLayout } from '../src/client/layout.ts' import type { TrajectoryCellProps } from '../src/client/trajectory-record.ts' import { t } from './locale.client.ts' @@ -34,7 +37,112 @@ function Harness({ cell = CELL, getDefault = () => false, setDefault = () => {} /> } +function failedCell(name: string, texts: readonly string[], nested: boolean): TrajectoryCellProps { + const argsRaw = name === 'run_code' ? ARGS : '{"file_path":"missing.txt"}' + const result: ToolResultNode = { + kind: 'tool-result', seq: 1, time: 1_000, callId: 'failed-call', name, + args: PartialArguments.fromText(argsRaw), call: { name, argsRaw }, callTime: 500, + content: texts.map(text => ({ type: 'text', text })), isError: true, + error: { name: 'ToolError', code: 'TOOL_FAILED' }, subCalls: [], + } + const turns = deriveTrajectoryLayout({ + nodes: nested ? [{ ...result, callId: 'parent', name: 'run_code', isError: false, subCalls: [result] }] : [result], + partial: null, runningCalls: [], + }, t) + return turns.flatMap(turn => turn.groups.flatMap(group => group.cells)) + .find(cell => cell.kind === (nested ? 'subtool' : 'tool'))! +} + describe('PTC trajectory inspector', () => { + it.each([ + ['run_code', false], ['read', false], ['read', true], + ] as const)('keeps failed %s JSON output as raw text (nested: %s)', (name, nested) => { + const output = '{ "reason" : "missing", "steps": [1, 2] }\n' + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /TOOL/ })) + for (const tab of ['Summary', 'Result']) { + fireEvent.click(ui.getByRole('tab', { name: tab })) + const panel = ui.getByRole('tabpanel') + expect(within(panel).queryByRole('tree', { name: 'Result JSON' })).toBeNull() + expect([...panel.querySelectorAll('pre')].filter(pre => pre.textContent === output)).toHaveLength(1) + } + }) + + it.each([ + ['run_code', false], ['read', false], ['read', true], + ] as const)('keeps error details for empty %s blocks (nested: %s)', async (name, nested) => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + const cell = failedCell(name, ['', ''], nested) + expect(cell.outputDetail).toBe('ToolError: TOOL_FAILED') + const view = render() + const ui = within(view.container) + expect(ui.getByRole('row', { name: /TOOL/ }).textContent).toContain('TOOL_FAILED') + fireEvent.click(ui.getByRole('row', { name: /TOOL/ })) + for (const tab of ['Summary', 'Result']) { + fireEvent.click(ui.getByRole('tab', { name: tab })) + expect(ui.getByRole('tabpanel').textContent).toContain('ToolError: TOOL_FAILED') + } + if (name === 'run_code') { + fireEvent.click(ui.getByRole('tab', { name: 'Summary' })) + fireEvent.click(ui.getByRole('button', { name: 'Copy output' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith('ToolError: TOOL_FAILED') }) + } + }) + + it.each([ + ['run_code', false], ['read', false], ['read', true], + ] as const)('keeps whitespace-only %s failure text verbatim (nested: %s)', (name, nested) => { + const output = ' \n\t ' + const cell = failedCell(name, [output], nested) + expect(cell.resultPreviewMarkdown).toBe(output) + expect(cell.outputDetail).toBe(output) + const view = render() + const ui = within(view.container) + fireEvent.click(ui.getByRole('row', { name: /TOOL/ })) + for (const tab of ['Summary', 'Result']) { + fireEvent.click(ui.getByRole('tab', { name: tab })) + const panel = ui.getByRole('tabpanel') + expect(panel.textContent).not.toContain('ToolError: TOOL_FAILED') + expect([...panel.querySelectorAll('pre')].filter(pre => pre.textContent === output)).toHaveLength(1) + } + }) + + it.each(['run_code', 'read'])('preserves the recorded %s failure in the ledger and inspector', async (name) => { + const writeText = vi.fn().mockResolvedValue(undefined) + vi.stubGlobal('navigator', { clipboard: { writeText } }) + const output = name === 'run_code' + ? "Error: code run failed (exception): Identifier 'value' has already been declared\nCaptured output:\nstep one\n" + : 'Error: missing.txt does not exist\nCheck the file path.\n' + const argsRaw = name === 'run_code' ? ARGS : '{"file_path":"missing.txt"}' + const turns = deriveTrajectoryLayout({ + nodes: [{ + kind: 'tool-result', seq: 1, time: 1_000, callId: 'failed-call', name, + args: PartialArguments.fromText(argsRaw), call: { name, argsRaw }, callTime: 500, + content: [{ type: 'text', text: output }], isError: true, + error: { name: 'CodeRunFailedError', code: 'CODE_RUN_FAILED' }, subCalls: [], + }], + partial: null, runningCalls: [], + }, t) + const cell = turns.flatMap(turn => turn.groups.flatMap(group => group.cells))[0]! + const view = render() + const ui = within(view.container) + expect(ui.getByRole('row', { name: /TOOL/ }).textContent).toContain(output.split('\n')[0]) + fireEvent.click(ui.getByRole('row', { name: /TOOL/ })) + for (const tab of ['Summary', 'Result']) { + fireEvent.click(ui.getByRole('tab', { name: tab })) + const panel = ui.getByRole('tabpanel') + expect(panel.textContent).not.toContain('CodeRunFailedError: CODE_RUN_FAILED') + expect([...panel.querySelectorAll('pre')].filter(pre => pre.textContent === output)).toHaveLength(1) + } + if (name === 'run_code') { + fireEvent.click(ui.getByRole('tab', { name: 'Summary' })) + fireEvent.click(ui.getByRole('button', { name: 'Copy output' })) + await waitFor(() => { expect(writeText).toHaveBeenLastCalledWith(output) }) + } + }) + it('shows the description, numbered source, original arguments, and verbatim output', () => { const view = render() const ui = within(view.container) diff --git a/packages/client/ui-trajectory/tests/layout.client.spec.tsx b/packages/client/ui-trajectory/tests/layout.client.spec.tsx index 8816ec1ce2..246f16cb9b 100644 --- a/packages/client/ui-trajectory/tests/layout.client.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.client.spec.tsx @@ -603,6 +603,32 @@ describe('run_code sub-dispatch cells', () => { expect(cells[3]).toMatchObject({ timeSeconds: 0.5 }) }) + it.each([true, false])('retains every failed sub-call text block with structured error present: %s', (withError) => { + const sub = { + ...settledSub(1, 'read', 6_300, 7_300), + isError: true, + ...(withError ? { error: { name: 'ToolError', code: 'ENOENT' } } : {}), + content: [{ type: 'text' as const, text: 'missing.txt does not exist' }, { type: 'text' as const, text: 'Check the file path.\n' }], + } + const turns = deriveTrajectoryLayout({ nodes: withSubCalls([sub]), partial: null, runningCalls: [] }) + const cell = turns[0]!.groups.flatMap(group => group.cells).find(cell => cell.kind === 'subtool') + expect(cell).toMatchObject({ + isError: true, resultPreviewMarkdown: 'missing.txt does not exist', + outputDetail: 'missing.txt does not exist\nCheck the file path.\n', + outputBlocks: sub.content.map(block => ({ type: 'text', content: block.text })), + }) + }) + + it('keeps structured failure details when an interrupted tool has no output', () => { + const sub = { + ...settledSub(1, 'read', 6_300, 7_300), + isError: true, error: { name: 'Interrupted', code: 'interrupted' }, content: [], + } + const turns = deriveTrajectoryLayout({ nodes: withSubCalls([sub]), partial: null, runningCalls: [] }) + expect(turns[0]!.groups.flatMap(group => group.cells).find(cell => cell.kind === 'subtool')) + .toMatchObject({ isError: true, result: 'interrupted', outputDetail: 'Interrupted: interrupted' }) + }) + it('a running (unsettled) sub-call renders a subtool cell with blank time', () => { const running = { phase: 'start' as const, args: PartialArguments.fromText('{"pattern":"x"}'), callId: 'p1:code:1', name: 'grep', argsRaw: '{"pattern":"x"}', diff --git a/snapshots/web/auto-review-denial/ui.expected.md b/snapshots/web/auto-review-denial/ui.expected.md index 027aea16e2..f9b78c5d2e 100644 --- a/snapshots/web/auto-review-denial/ui.expected.md +++ b/snapshots/web/auto-review-denial/ui.expected.md @@ -85,7 +85,7 @@ - cell "(tool call only)" - 'row "TOOL, mystery {\"secret\":\"hidden-input\"}"': - cell "TOOL" - - 'cell "mystery{\"secret\":\"hidden-input\"} → AUTO_REVIEW_DENIED"' + - 'cell "mystery{\"secret\":\"hidden-input\"} → Error: Auto review rejected tool \"mystery\"; its body was not executed"' - row "Request 2, ASSISTANT, (tool call only)": - 'cell "Request #2 ASSISTANT"': - 'button "Request #2"' @@ -96,7 +96,7 @@ - cell "run_codeCatch an Auto-denied inner call → denial caught" - 'row "SUBTOOL, bash {\"command\":\"echo hidden-input-ptc\"}" [selected]': - cell "SUBTOOL" - - 'cell "bash{\"command\":\"echo hidden-input-ptc\"} → AUTO_REVIEW_DENIED"' + - 'cell "bash{\"command\":\"echo hidden-input-ptc\"} → Error: Auto review rejected tool \"bash\"; its body was not executed"' - complementary "Event details": - separator "Resize event details" - text: SUBTOOL Turn 1 · Step 2 @@ -113,7 +113,7 @@ - paragraph: Raw results are only available for live tool calls - region "Rendered result": - heading "Rendered result" [level=3] - - text: "AutoReviewDeniedError: AUTO_REVIEW_DENIED Error: Auto review rejected tool \"bash\"; its body was not executed" + - text: "Error: Auto review rejected tool \"bash\"; its body was not executed" - textbox "Message or run a task, / commands, @ files or sessions" - button "Add files or run commands" - 'button "Access mode, current: Auto review"': Auto review