mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
feat(web-shell): show tool arguments and result in expanded cards (#15435)
* feat(web-shell): show tool arguments and result in expanded cards Cards outside the built-in detail renderers expanded to the same 80-character result summary shown while collapsed, so the chevron promised details it never delivered. Render the call's arguments and result instead, pretty-printing a JSON object or array and falling back to plain text, with the trajectory inspector's 4000/40 000 caps and a Show more action. Fixes #15280 * fix(web-shell): address tool card details review findings * test(web-shell): cover tool card review findings * fix(web-shell): preserve tool card JSON payload fidelity * fix(web-shell): sanitize tool payloads and cancelled results * fix(web-shell): bound tool payloads by render mode * fix(web-shell): memoize tool card payload formatting * fix(web-shell): size tool card JSON by render cap and scope its rules * fix(web-templates): raise document export budget for tool cards * fix(web-shell): address round 3 tool card review suggestions * fix(web-shell): gate Show more on escaped payload length * fix(web-shell): stop rendering the cancelled input dump as a result A cancelled call that recorded no output carries the daemon's redacted input dump in rawOutput.text, written there as the same string as reason. cancelledOutputText accepted it as output, so the expanded card showed the call's own arguments twice, once as Result. Skip a text candidate that equals reason while keeping output/stdout. * test(web-shell): pin the arguments escape guard and the inline cap Both behaviours were unpinned: replacing the arguments-side sanitized-length comparison with the raw length, or lowering MAX_TOOL_PAYLOAD_LENGTH to 400, left the suites green. JSON.stringify escapes C0 bytes but not the bidi controls, so an object argument carrying U+202E is the reachable shape where escaping moves the payload past the cap. --------- Co-authored-by: Shaojin Wen <[email protected]>
This commit is contained in:
1 parent
5b44cfad71
commit
588db8b137
7 files changed
+1088
-33
No files matched your search
@@ -11,7 +11,9 @@ import type { ACPToolCall } from '../../adapters/types';
|
||||
import {
|
||||
extractText,
|
||||
isShellToolName,
|
||||
MAX_TOOL_PAYLOAD_LENGTH,
|
||||
sanitizeControlChars,
|
||||
stringifyValue,
|
||||
truncateText,
|
||||
} from '../messages/toolFormatting';
|
||||
import { extractDiff, fencedCodeBlock } from '../messages/ToolGroup';
|
||||
@@ -20,24 +22,12 @@ import { parseShellLiveOutput } from '../messages/tools/shellLiveOutput';
|
||||
import styles from './tool-call-content.module.css';
|
||||
|
||||
/** The longest stretch of one argument or result put in the DOM by default. */
|
||||
export const MAX_DETAIL_LENGTH = 4000;
|
||||
export const MAX_DETAIL_LENGTH = MAX_TOOL_PAYLOAD_LENGTH;
|
||||
|
||||
export { stringifyValue };
|
||||
|
||||
const EDIT_TOOL = /^(edit|editfile|write|write_file|writefile)$/;
|
||||
|
||||
export function stringifyValue(value: unknown): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
const text = typeof value === 'string' ? value : safeJson(value);
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/** Valid JSON renders as a code block; anything else stays plain text. */
|
||||
export function ToolCallDetail({
|
||||
text,
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from '../../adapters/toolClassification';
|
||||
import { transcriptBlocksToDaemonMessages } from '../../adapters/transcriptToMessages';
|
||||
import { getTranslator, I18nProvider } from '../../i18n';
|
||||
import { sanitizeControlChars } from './toolFormatting';
|
||||
import {
|
||||
WebShellCustomizationProvider,
|
||||
type MarkdownRenderContext,
|
||||
@@ -1052,7 +1053,13 @@ describe('tool row rendering', () => {
|
||||
undefined,
|
||||
'document',
|
||||
);
|
||||
expect(container.textContent).toContain(text);
|
||||
// Valid JSON is pretty-printed for display; other text is shown exactly
|
||||
// as recorded, including its trailing whitespace.
|
||||
const expected =
|
||||
toolName === 'tool_call'
|
||||
? JSON.stringify(JSON.parse(text) as unknown, null, 2)
|
||||
: text;
|
||||
expect(container.textContent).toContain(expected);
|
||||
expect(container.querySelector('iframe')).toBeNull();
|
||||
},
|
||||
);
|
||||
@@ -3228,3 +3235,596 @@ describe('pending edit approval rows', () => {
|
||||
expect(handedBack.textContent).toContain('handed back content');
|
||||
});
|
||||
});
|
||||
|
||||
describe('generic tool card details', () => {
|
||||
it('pretty-prints arguments and a compact JSON result on expansion', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__github__pull_request_read',
|
||||
args: { method: 'get_check_runs', owner: 'QwenLM' },
|
||||
rawOutput: '{"total_count":38,"check_runs":[{"name":"smoke"}]}',
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const text = container.textContent ?? '';
|
||||
expect(text).toContain('Arguments');
|
||||
expect(text).toContain('Result');
|
||||
expect(text).toContain('"method": "get_check_runs"');
|
||||
expect(text).toContain('"total_count": 38');
|
||||
expect(text).not.toContain('"total_count":38');
|
||||
});
|
||||
|
||||
it('keeps a non-JSON result as plain text', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__echo',
|
||||
rawOutput: 'line one\nline two',
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('line one');
|
||||
expect(container.textContent).toContain('line two');
|
||||
expect(container.querySelector('[class*="payloadText"]')).not.toBeNull();
|
||||
expect(container.querySelector('[class*="payloadJson"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the failure text in the Result block', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__fail',
|
||||
status: 'failed',
|
||||
content: [{ type: 'content', content: { type: 'text', text: 'boom' } }],
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('Result');
|
||||
expect(container.textContent).toContain('boom');
|
||||
});
|
||||
|
||||
it('offers Show more for a payload over the inline cap', () => {
|
||||
const tail = 'TAIL-MARKER';
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__dump',
|
||||
rawOutput: `${'x'.repeat(4200)}${tail}`,
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).not.toContain(tail);
|
||||
const more = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Show more',
|
||||
);
|
||||
expect(more).toBeDefined();
|
||||
act(() => more?.click());
|
||||
expect(container.textContent).toContain(tail);
|
||||
});
|
||||
|
||||
it('renders a payload under the inline cap whole without Show more', () => {
|
||||
const tail = 'SHORT-TAIL';
|
||||
const rawOutput = `${'a'.repeat(3900 - tail.length)}${tail}`;
|
||||
expect(rawOutput).toHaveLength(3900);
|
||||
|
||||
const container = renderToolLine(
|
||||
makeTool({ toolName: 'mcp__demo__short', rawOutput }),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain(tail);
|
||||
expect(container.textContent).not.toContain('Content truncated');
|
||||
expect(
|
||||
[...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Show more',
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('offers Show more when pretty-printing grows a payload past the cap', () => {
|
||||
const tail = 'TAIL-MARKER';
|
||||
const items = [
|
||||
...Array.from({ length: 160 }, (_, i) => ({ id: i, name: 'x' })),
|
||||
{ id: 160, name: tail },
|
||||
];
|
||||
const compact = JSON.stringify(items);
|
||||
// Guard the fixture: the raw payload fits inline, the formatted one does
|
||||
// not, so the button must follow the formatted length.
|
||||
expect(compact.length).toBeLessThanOrEqual(4000);
|
||||
expect(JSON.stringify(items, null, 2).length).toBeGreaterThan(4000);
|
||||
|
||||
const container = renderToolLine(
|
||||
makeTool({ toolName: 'mcp__demo__grow', rawOutput: compact }),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).not.toContain(tail);
|
||||
const more = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Show more',
|
||||
);
|
||||
expect(more).toBeDefined();
|
||||
act(() => more?.click());
|
||||
expect(container.textContent).toContain(tail);
|
||||
});
|
||||
|
||||
it('stops at the expanded cap and keeps the truncation notice', () => {
|
||||
const midMarker = 'MID-MARKER';
|
||||
const pastCap = 'PAST-40K-MARKER';
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__huge',
|
||||
rawOutput: `${'x'.repeat(39_900)}${midMarker}${'x'.repeat(100)}${pastCap}`,
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).not.toContain(midMarker);
|
||||
const more = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Show more',
|
||||
);
|
||||
expect(more).toBeDefined();
|
||||
act(() => more?.click());
|
||||
expect(container.textContent).toContain(midMarker);
|
||||
expect(container.textContent).not.toContain(pastCap);
|
||||
expect(container.textContent).toContain('Content truncated');
|
||||
});
|
||||
|
||||
it('keeps the header description out of the expanded body', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__inspect',
|
||||
title: 'Inspect check runs',
|
||||
args: { method: 'get_check_runs' },
|
||||
rawOutput: '{"total_count":38}',
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const occurrences = (container.textContent ?? '').split(
|
||||
'Inspect check runs',
|
||||
).length;
|
||||
expect(occurrences - 1).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a long description in the expanded card detail', () => {
|
||||
const title =
|
||||
`Inspect check runs ${'and report failures '.repeat(4)}`.trim();
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__inspect',
|
||||
title,
|
||||
args: { method: 'get_check_runs' },
|
||||
rawOutput: '{"total_count":38}',
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const occurrences = (container.textContent ?? '').split(title).length;
|
||||
expect(occurrences - 1).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a long search description in the expanded card detail', () => {
|
||||
const title =
|
||||
`Search the repository ${'for matching files '.repeat(4)}`.trim();
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'grep_search',
|
||||
title,
|
||||
args: { pattern: 'needle' },
|
||||
rawOutput: 'No matches found',
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('[class*="expandedCardDetail"]')?.textContent,
|
||||
).toContain(title);
|
||||
});
|
||||
|
||||
it('keeps a malformed todo description in the expanded card detail', () => {
|
||||
const description = 'Malformed todo payload';
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'todo_write',
|
||||
title: description,
|
||||
args: { todos: 'not-a-list' },
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const occurrences = (container.textContent ?? '').split(description).length;
|
||||
expect(occurrences - 1).toBe(1);
|
||||
});
|
||||
|
||||
it('keeps a short description when a custom header omits it', () => {
|
||||
const description = 'Inspect check runs';
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__inspect',
|
||||
title: description,
|
||||
args: { method: 'get_check_runs' },
|
||||
rawOutput: '{"total_count":38}',
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
{ renderToolHeaderExtra: () => <span>custom header</span> },
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('[class*="expandedCardDetail"]')?.textContent,
|
||||
).toContain(description);
|
||||
});
|
||||
|
||||
it('keeps a short description in a single-tool document detail', () => {
|
||||
const description = 'Inspect check runs';
|
||||
const container = renderToolGroup(
|
||||
[
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__inspect',
|
||||
title: description,
|
||||
args: { method: 'get_check_runs' },
|
||||
rawOutput: '{"total_count":38}',
|
||||
}),
|
||||
],
|
||||
{},
|
||||
undefined,
|
||||
'document',
|
||||
);
|
||||
|
||||
expect(
|
||||
container.querySelector('[class*="expandedCardDetail"]')?.textContent,
|
||||
).toContain(description);
|
||||
});
|
||||
|
||||
it('keeps a long description when a single-tool card hides its header', () => {
|
||||
const description = `Inspect check runs ${'and report failures '.repeat(8)}`;
|
||||
const container = renderToolGroup(
|
||||
[
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__inspect',
|
||||
title: description,
|
||||
args: { method: 'get_check_runs' },
|
||||
rawOutput: '{"total_count":38}',
|
||||
}),
|
||||
],
|
||||
{},
|
||||
undefined,
|
||||
'document',
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain(description.trim());
|
||||
});
|
||||
|
||||
it('does not render an empty Arguments section', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({ toolName: 'mcp__demo__empty', args: {}, rawOutput: 'ok' }),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).not.toContain('Arguments');
|
||||
expect(container.textContent).toContain('Result');
|
||||
});
|
||||
|
||||
it('joins every text content block in the Result', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__multi',
|
||||
content: [
|
||||
{ type: 'content', content: { type: 'text', text: 'first block' } },
|
||||
{ type: 'content', content: { type: 'text', text: 'second block' } },
|
||||
],
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('first block');
|
||||
expect(container.textContent).toContain('second block');
|
||||
});
|
||||
|
||||
it('shows a waiting Result for a pending call with arguments', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__ping',
|
||||
status: 'in_progress',
|
||||
args: { method: 'ping' },
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('Result');
|
||||
expect(container.textContent).toContain('Waiting for output…');
|
||||
});
|
||||
|
||||
it('shows a waiting Result for a pending call with no payload', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({ toolName: 'mcp__demo__slow', status: 'in_progress' }),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const occurrences = (container.textContent ?? '').split(
|
||||
'Waiting for output…',
|
||||
).length;
|
||||
expect(occurrences - 1).toBe(1);
|
||||
expect(container.textContent).toContain('Result');
|
||||
});
|
||||
|
||||
it('sanitizes control characters in generic payloads', () => {
|
||||
const raw = 'a\u202eb\x1bc';
|
||||
const container = renderToolLine(
|
||||
makeTool({ toolName: 'mcp__demo__unsafe', rawOutput: raw }),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const text = container.textContent ?? '';
|
||||
expect(text).toContain('\\u202e');
|
||||
expect(text).toContain('\\u001b');
|
||||
expect(text).not.toContain('\u202e');
|
||||
expect(text).not.toContain('\x1b');
|
||||
});
|
||||
|
||||
it('sanitizes control characters in generic JSON payloads', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__unsafe-json',
|
||||
rawOutput: '{"n":"a\u202eb"}',
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const text = container.textContent ?? '';
|
||||
expect(text).toContain('\\u202e');
|
||||
expect(text).not.toContain('\u202e');
|
||||
});
|
||||
|
||||
it('offers Show more when escaping grows the payload past the cap', () => {
|
||||
const tail = 'NUL-TAIL';
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__nul',
|
||||
rawOutput: `${'\u0000'.repeat(2000)}${tail}`,
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const payload = container.querySelector('[class*="payloadText"]');
|
||||
expect(payload).not.toBeNull();
|
||||
expect(payload?.textContent?.length ?? 0).toBeLessThanOrEqual(4003);
|
||||
expect(container.textContent).toContain('Content truncated');
|
||||
expect(container.textContent).not.toContain(tail);
|
||||
|
||||
const more = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Show more',
|
||||
);
|
||||
expect(more).toBeDefined();
|
||||
act(() => more?.click());
|
||||
expect(container.textContent).toContain(tail);
|
||||
});
|
||||
|
||||
it('does not repeat a cancelled call input as the result', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__ping',
|
||||
status: 'completed',
|
||||
wasCancelled: true,
|
||||
args: { method: 'ping' },
|
||||
rawOutput: {
|
||||
status: 'cancelled',
|
||||
reason: '{"method":"ping"}',
|
||||
text: 'partial',
|
||||
},
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const text = container.textContent ?? '';
|
||||
expect(text).toContain('partial');
|
||||
expect(text).not.toContain('"reason"');
|
||||
expect(text.split('"method": "ping"').length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it('does not render the cancelled input dump as the result', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__ping',
|
||||
status: 'completed',
|
||||
wasCancelled: true,
|
||||
args: { method: 'ping' },
|
||||
rawOutput: {
|
||||
status: 'cancelled',
|
||||
reason: '{"method":"ping"}',
|
||||
text: '{"method":"ping"}',
|
||||
},
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const text = container.textContent ?? '';
|
||||
expect(text).toContain('Cancelled');
|
||||
expect(text.split('"method": "ping"').length - 1).toBe(1);
|
||||
});
|
||||
|
||||
it.each(['output', 'stdout'] as const)(
|
||||
'keeps cancelled %s output visible',
|
||||
(key) => {
|
||||
const output = `partial-${key}`;
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__cancel-output',
|
||||
status: 'completed',
|
||||
wasCancelled: true,
|
||||
rawOutput: {
|
||||
status: 'cancelled',
|
||||
reason: '{"method":"ping"}',
|
||||
[key]: output,
|
||||
},
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
const text = container.textContent ?? '';
|
||||
expect(text).toContain(output);
|
||||
expect(text).not.toContain('"reason"');
|
||||
},
|
||||
);
|
||||
|
||||
it('keeps a cancelled string output visible', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__cancel-string',
|
||||
status: 'completed',
|
||||
wasCancelled: true,
|
||||
rawOutput: 'partial-string',
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('partial-string');
|
||||
});
|
||||
|
||||
it('shows Cancelled when a cancelled call has no output', () => {
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__cancel-empty',
|
||||
status: 'completed',
|
||||
wasCancelled: true,
|
||||
rawOutput: {},
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain('Cancelled');
|
||||
expect(container.textContent).not.toContain('Waiting for output…');
|
||||
});
|
||||
|
||||
it('offers Show more for a large arguments payload', () => {
|
||||
const tail = 'ARG-TAIL';
|
||||
const container = renderToolLine(
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__bigargs',
|
||||
args: { body: `${'y'.repeat(4200)}${tail}` },
|
||||
}),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).not.toContain(tail);
|
||||
const more = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Show more',
|
||||
);
|
||||
expect(more).toBeDefined();
|
||||
act(() => more?.click());
|
||||
expect(container.textContent).toContain(tail);
|
||||
expect(
|
||||
[...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Show more',
|
||||
),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it('offers Show more when escaping grows the arguments past the cap', () => {
|
||||
const tail = 'ARG-ESCAPED-TAIL';
|
||||
// JSON.stringify escapes C0 bytes but leaves the bidi controls that
|
||||
// sanitizeControlChars renders as `\u202e`, so this payload only crosses
|
||||
// the inline cap once escaping is counted.
|
||||
const args = { note: `${'\u202e'.repeat(500)}${'a'.repeat(3000)}${tail}` };
|
||||
const raw = JSON.stringify(args, null, 2);
|
||||
expect(raw.length).toBeLessThanOrEqual(4000);
|
||||
expect(sanitizeControlChars(raw).length).toBeGreaterThan(4000);
|
||||
|
||||
const container = renderToolLine(
|
||||
makeTool({ toolName: 'mcp__demo__bidiargs', args }),
|
||||
{ forceExpanded: true },
|
||||
);
|
||||
|
||||
expect(container.textContent).not.toContain(tail);
|
||||
const more = [...container.querySelectorAll('button')].find(
|
||||
(button) => button.textContent === 'Show more',
|
||||
);
|
||||
expect(more).toBeDefined();
|
||||
act(() => more?.click());
|
||||
expect(container.textContent).toContain(tail);
|
||||
});
|
||||
|
||||
it('bounds generic arguments in document mode', () => {
|
||||
const tail = 'ARG-DOCUMENT-TAIL';
|
||||
const container = renderToolGroup(
|
||||
[
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__bigargs',
|
||||
args: { content: `${'x'.repeat(50_000)}${tail}` },
|
||||
rawOutput: 'ok',
|
||||
}),
|
||||
],
|
||||
{},
|
||||
undefined,
|
||||
'document',
|
||||
);
|
||||
|
||||
expect(container.textContent).not.toContain(tail);
|
||||
expect(container.textContent).toContain('Content truncated');
|
||||
});
|
||||
|
||||
it('keeps document-mode JSON formatting above the interactive budget', () => {
|
||||
const items = Array.from({ length: 1200 }, (_, i) => ({
|
||||
id: i,
|
||||
name: 'x'.repeat(30),
|
||||
}));
|
||||
const raw = JSON.stringify(items);
|
||||
const formatted = JSON.stringify(items, null, 2);
|
||||
expect(raw.length).toBeGreaterThan(40_000);
|
||||
expect(formatted.length).toBeLessThan(100_000);
|
||||
|
||||
const container = renderToolGroup(
|
||||
[
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__docjson',
|
||||
rawOutput: raw,
|
||||
}),
|
||||
],
|
||||
{},
|
||||
undefined,
|
||||
'document',
|
||||
);
|
||||
|
||||
expect(container.querySelector('[class*="payloadJson"]')).not.toBeNull();
|
||||
expect(container.querySelector('[class*="payloadText"]')).toBeNull();
|
||||
});
|
||||
|
||||
it('shows the full payload without Show more in document mode', () => {
|
||||
const tail = 'DOCUMENT-TAIL';
|
||||
const container = renderToolGroup(
|
||||
[
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__dump',
|
||||
rawOutput: `${'x'.repeat(4200)}${tail}`,
|
||||
}),
|
||||
],
|
||||
{},
|
||||
undefined,
|
||||
'document',
|
||||
);
|
||||
|
||||
expect(container.textContent).toContain(tail);
|
||||
expect(container.textContent).not.toContain('Show more');
|
||||
});
|
||||
|
||||
it('keeps highlighting for a many-line JSON result within the char budget', () => {
|
||||
const items = Array.from({ length: 400 }, (_, i) => ({ id: i }));
|
||||
const container = renderToolGroup(
|
||||
[
|
||||
makeTool({
|
||||
toolName: 'mcp__demo__many',
|
||||
rawOutput: JSON.stringify(items),
|
||||
}),
|
||||
],
|
||||
{},
|
||||
undefined,
|
||||
'document',
|
||||
);
|
||||
|
||||
// Pretty-printed, the payload spans well over 1000 lines but stays within
|
||||
// the render cap; it must render as a highlighted JSON block, not fall back
|
||||
// to the plain-text container.
|
||||
expect(container.querySelector('[class*="payloadJson"]')).not.toBeNull();
|
||||
expect(container.querySelector('[class*="payloadText"]')).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -53,7 +53,12 @@ import {
|
||||
import {
|
||||
extractText,
|
||||
hasRecordedOutput,
|
||||
EXPANDED_TOOL_PAYLOAD_LENGTH,
|
||||
formatJsonPayload,
|
||||
formatTokenCount,
|
||||
MAX_TOOL_PAYLOAD_LENGTH,
|
||||
sanitizeControlChars,
|
||||
stringifyValue,
|
||||
getAgentCancellationReason,
|
||||
getAgentCurrentToolHint,
|
||||
getAgentDescription,
|
||||
@@ -141,9 +146,9 @@ function openMonitorDetailsOnce(
|
||||
|
||||
// Tools whose expanded row renders a kind-specific detail view (shell output /
|
||||
// diff / file content / Q&A). Must stay in sync with the renderers in
|
||||
// ToolLine's lineDetail block below. Tools NOT in this set have nothing extra
|
||||
// to show when expanded, so they keep their one-line result summary instead of
|
||||
// hiding it behind an empty detail area.
|
||||
// ToolLine's lineDetail block below. Tools NOT in this set render the call's
|
||||
// own arguments and recorded output in their expanded card; the collapsed row
|
||||
// still shows the one-line summary from getToolResultSummary.
|
||||
function hasDetailView(tool: ACPToolCall): boolean {
|
||||
const name = tool.toolName.toLowerCase();
|
||||
return (
|
||||
@@ -228,6 +233,7 @@ function isTruncatedSessionDiff(raw: Record<string, unknown>): boolean {
|
||||
const DESCRIPTION_EXPAND_THRESHOLD = 60;
|
||||
const MAX_MARKDOWN_READ_CHARS = 200_000;
|
||||
const MAX_MARKDOWN_READ_LINES = 1000;
|
||||
const MAX_DOCUMENT_JSON_PAYLOAD_LENGTH = 100_000;
|
||||
const READ_LANGUAGE_ALIASES: Record<string, string> = {
|
||||
cjs: 'javascript',
|
||||
cts: 'typescript',
|
||||
@@ -356,6 +362,176 @@ function ToolExpandedCard({
|
||||
);
|
||||
}
|
||||
|
||||
function ToolPayloadBlock({
|
||||
payload,
|
||||
limit,
|
||||
}: {
|
||||
payload: { text: string; json: boolean };
|
||||
limit: number;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const renderMode = useTranscriptRenderMode();
|
||||
// Escape before applying the cap: control-character escapes can expand a
|
||||
// payload up to six characters per input character.
|
||||
const escaped = sanitizeControlChars(truncateText(payload.text, limit));
|
||||
const shown = escaped.length > limit ? truncateText(escaped, limit) : escaped;
|
||||
const truncated = payload.text.length > limit || escaped.length > limit;
|
||||
// Highlight a canonical JSON payload as long as it fits the Markdown budget.
|
||||
// The former 1000-line read-file guard is deliberately not applied here: a
|
||||
// tool result is one logical value, not a file preview, and can be short in
|
||||
// characters yet span many lines once pretty-printed (e.g. a compact array of
|
||||
// records). formatJsonPayload already caps the formatted text by render mode,
|
||||
// and the read-file line budget lives on to guard ExpandedReadContent below.
|
||||
const renderAsMarkdown =
|
||||
payload.json && shown.length <= MAX_MARKDOWN_READ_CHARS;
|
||||
return (
|
||||
<>
|
||||
{renderAsMarkdown ? (
|
||||
<div className={styles.payloadJson}>
|
||||
<Markdown content={fencedCodeBlock('json', shown)} />
|
||||
</div>
|
||||
) : (
|
||||
<pre className={styles.payloadText}>
|
||||
{payload.json ? shown : renderWithSessionLinks(shown, renderMode)}
|
||||
</pre>
|
||||
)}
|
||||
{truncated && (
|
||||
<p className={styles.payloadTruncated}>
|
||||
{t('trajectory.inspector.truncated')}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function toolResultText(tool: ACPToolCall, includeExtractText = true): string {
|
||||
const texts: string[] = [];
|
||||
for (const block of tool.content ?? []) {
|
||||
if (block.type === 'content' && typeof block.content?.text === 'string') {
|
||||
texts.push(block.content.text);
|
||||
}
|
||||
}
|
||||
if (texts.length > 0) return texts.join('\n');
|
||||
return includeExtractText ? (extractText(tool) ?? '') : '';
|
||||
}
|
||||
|
||||
function cancelledOutputText(rawOutput: unknown): string {
|
||||
if (typeof rawOutput === 'string') return rawOutput;
|
||||
if (
|
||||
rawOutput === null ||
|
||||
typeof rawOutput !== 'object' ||
|
||||
Array.isArray(rawOutput)
|
||||
) {
|
||||
return '';
|
||||
}
|
||||
const record = rawOutput as Record<string, unknown>;
|
||||
for (const key of ['text', 'output', 'stdout'] as const) {
|
||||
const value = record[key];
|
||||
if (typeof value !== 'string') continue;
|
||||
// A cancelled call that recorded no output carries the redacted input dump
|
||||
// in `text`, written there as the same string as `reason`; it is not output.
|
||||
if (key === 'text' && value === record['reason']) continue;
|
||||
return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function GenericToolDetails({
|
||||
tool,
|
||||
pending,
|
||||
cancelled,
|
||||
}: {
|
||||
tool: ACPToolCall;
|
||||
pending: boolean;
|
||||
cancelled: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const argumentsText = useMemo(
|
||||
() =>
|
||||
tool.args && Object.keys(tool.args).length > 0
|
||||
? stringifyValue(tool.args)
|
||||
: '',
|
||||
[tool],
|
||||
);
|
||||
const resultText = useMemo(
|
||||
() =>
|
||||
toolResultText(tool, !tool.wasCancelled) ||
|
||||
(tool.wasCancelled
|
||||
? cancelledOutputText(tool.rawOutput)
|
||||
: stringifyValue(tool.rawOutput)) ||
|
||||
'',
|
||||
[tool],
|
||||
);
|
||||
const renderMode = useTranscriptRenderMode();
|
||||
const documentMode = renderMode === 'document';
|
||||
const formatBudget = documentMode
|
||||
? MAX_DOCUMENT_JSON_PAYLOAD_LENGTH
|
||||
: EXPANDED_TOOL_PAYLOAD_LENGTH;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
const limit = documentMode
|
||||
? Number.POSITIVE_INFINITY
|
||||
: expanded
|
||||
? EXPANDED_TOOL_PAYLOAD_LENGTH
|
||||
: MAX_TOOL_PAYLOAD_LENGTH;
|
||||
const argumentsLimit = documentMode ? EXPANDED_TOOL_PAYLOAD_LENGTH : limit;
|
||||
// Pretty-printing grows a compact payload, and the blocks truncate what they
|
||||
// render, so the "Show more" decision has to use the formatted lengths.
|
||||
const argumentsPayload = useMemo(
|
||||
() => formatJsonPayload(argumentsText, formatBudget, formatBudget),
|
||||
[argumentsText, formatBudget],
|
||||
);
|
||||
const resultPayload = useMemo(
|
||||
() => formatJsonPayload(resultText, formatBudget, formatBudget),
|
||||
[resultText, formatBudget],
|
||||
);
|
||||
const argumentsNeedsExpansion = useMemo(
|
||||
() =>
|
||||
!documentMode &&
|
||||
sanitizeControlChars(argumentsPayload.text).length >
|
||||
MAX_TOOL_PAYLOAD_LENGTH,
|
||||
[argumentsPayload.text, documentMode],
|
||||
);
|
||||
const resultNeedsExpansion = useMemo(
|
||||
() =>
|
||||
!documentMode &&
|
||||
sanitizeControlChars(resultPayload.text).length > MAX_TOOL_PAYLOAD_LENGTH,
|
||||
[resultPayload.text, documentMode],
|
||||
);
|
||||
const canExpand =
|
||||
!expanded && (argumentsNeedsExpansion || resultNeedsExpansion);
|
||||
return (
|
||||
<div className={styles.payloadSections}>
|
||||
{argumentsText && (
|
||||
<section className={styles.payloadSection}>
|
||||
<div className={styles.payloadLabel}>{t('turnCalls.arguments')}</div>
|
||||
<ToolPayloadBlock payload={argumentsPayload} limit={argumentsLimit} />
|
||||
</section>
|
||||
)}
|
||||
{(resultText || pending || cancelled) && (
|
||||
<section className={styles.payloadSection}>
|
||||
<div className={styles.payloadLabel}>{t('turnCalls.result')}</div>
|
||||
{resultText ? (
|
||||
<ToolPayloadBlock payload={resultPayload} limit={limit} />
|
||||
) : cancelled ? (
|
||||
<p className={styles.payloadEmpty}>{t('shell.result.cancelled')}</p>
|
||||
) : (
|
||||
<p className={styles.payloadEmpty}>{t('shell.result.waiting')}</p>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
{canExpand && (
|
||||
<button
|
||||
type="button"
|
||||
className={styles.payloadMore}
|
||||
onClick={() => setExpanded(true)}
|
||||
>
|
||||
{t('trajectory.inspector.expand')}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Collapsed by default: the diff of this todo_write call (just-completed and
|
||||
// just-started items), expanding to the full list on click. The per-snapshot
|
||||
// diff comes from the timeline context, so this is isolated in its own
|
||||
@@ -1133,7 +1309,8 @@ export const ToolLine = memo(function ToolLine({
|
||||
const transcriptRenderMode = useTranscriptRenderMode();
|
||||
const subagentDetails = useSubagentDetails();
|
||||
const monitorDetails = useMonitorDetails();
|
||||
const { hostOwnsEditDiffPreview } = useWebShellCustomization();
|
||||
const { hostOwnsEditDiffPreview, renderToolHeaderExtra } =
|
||||
useWebShellCustomization();
|
||||
const monitorDetailsAvailable = monitorDetails !== undefined;
|
||||
const mcpApp = getMcpAppDisplay(tool.rawOutput);
|
||||
const mcpAppAllowed = useMcpAppAdmission(mcpApp);
|
||||
@@ -1440,8 +1617,8 @@ export const ToolLine = memo(function ToolLine({
|
||||
);
|
||||
};
|
||||
// Whether the expanded row renders a kind-specific detail view. When it does
|
||||
// not (e.g. grep/glob/web_fetch with a long description), keep the result
|
||||
// summary visible instead of replacing it with an empty detail area.
|
||||
// not, the expanded card renders the call's own arguments and recorded
|
||||
// output; the collapsed row still shows the one-line result summary.
|
||||
const detailView = hasDetailView(tool);
|
||||
const showDescriptionInDetail = expanded && descExpandable;
|
||||
const useMarkdownDetail = isRead || isAdvisor;
|
||||
@@ -1451,6 +1628,31 @@ export const ToolLine = memo(function ToolLine({
|
||||
// Contentless tools still get a titled card when the user opens them.
|
||||
const showExpandedSummaryPanel =
|
||||
expanded && !detailView && (!isTodo || (!hasTodoList && !result));
|
||||
const showGenericDetails = !mcpApp && showExpandedSummaryPanel;
|
||||
// A card outside the built-in detail renderers shows the call itself rather
|
||||
// than repeating the header summary. The values are already redacted at the
|
||||
// daemon boundary and carried by the ACP tool call; no extra read is needed.
|
||||
const hasGenericArguments =
|
||||
showGenericDetails &&
|
||||
Boolean(tool.args && Object.keys(tool.args).length > 0);
|
||||
const hasContentText =
|
||||
tool.content?.some(
|
||||
(block) =>
|
||||
block.type === 'content' &&
|
||||
typeof block.content?.text === 'string' &&
|
||||
block.content.text.length > 0,
|
||||
) ?? false;
|
||||
const hasRawOutput =
|
||||
tool.rawOutput !== undefined &&
|
||||
tool.rawOutput !== null &&
|
||||
(typeof tool.rawOutput !== 'string' || tool.rawOutput.length > 0);
|
||||
const hasGenericResult =
|
||||
showGenericDetails && (hasContentText || hasRawOutput);
|
||||
const hasGenericPayload =
|
||||
hasGenericArguments ||
|
||||
hasGenericResult ||
|
||||
(showGenericDetails &&
|
||||
(isActiveToolStatus(tool.status) || tool.wasCancelled === true));
|
||||
|
||||
return (
|
||||
<div className={styles.line} data-transcript-tool-call-id={tool.callId}>
|
||||
@@ -1576,17 +1778,25 @@ export const ToolLine = memo(function ToolLine({
|
||||
{!mcpApp && showExpandedSummaryPanel && (
|
||||
<ToolExpandedCard
|
||||
title={displayName}
|
||||
detail={expandedCardDetail}
|
||||
detail={
|
||||
hasGenericPayload &&
|
||||
!showDescriptionInDetail &&
|
||||
!hideHeader &&
|
||||
!isTodo &&
|
||||
!renderToolHeaderExtra
|
||||
? undefined
|
||||
: expandedCardDetail
|
||||
}
|
||||
status={tool.status}
|
||||
action={filePreviewAction}
|
||||
>
|
||||
{result && (
|
||||
<div
|
||||
className={`${styles.lineOutput} ${styles.expandedLineOutput}`}
|
||||
>
|
||||
{renderWithSessionLinks(result, transcriptRenderMode)}
|
||||
</div>
|
||||
)}
|
||||
{hasGenericPayload ? (
|
||||
<GenericToolDetails
|
||||
tool={tool}
|
||||
pending={isActiveToolStatus(tool.status)}
|
||||
cancelled={tool.wasCancelled === true}
|
||||
/>
|
||||
) : null}
|
||||
</ToolExpandedCard>
|
||||
)}
|
||||
{!mcpApp &&
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { ACPToolCall } from '../../adapters/types';
|
||||
import {
|
||||
EXPANDED_TOOL_PAYLOAD_LENGTH,
|
||||
extractRawOutputText,
|
||||
formatJsonPayload,
|
||||
formatToolDisplayName,
|
||||
getAgentCurrentToolHint,
|
||||
getSubagentDetailsUnavailableReason,
|
||||
@@ -609,6 +611,85 @@ describe('toolFormatting', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatJsonPayload', () => {
|
||||
it('pretty-prints a compact object', () => {
|
||||
expect(formatJsonPayload('{"a":1,"b":[2]}')).toEqual({
|
||||
text: '{\n "a": 1,\n "b": [\n 2\n ]\n}',
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('pretty-prints an array of objects', () => {
|
||||
const formatted = formatJsonPayload('[{"id":1}]');
|
||||
expect(formatted.json).toBe(true);
|
||||
expect(formatted.text).toBe('[\n {\n "id": 1\n }\n]');
|
||||
});
|
||||
|
||||
it('keeps an already indented document as JSON', () => {
|
||||
expect(formatJsonPayload('{\n "a": 1\n}')).toEqual({
|
||||
text: '{\n "a": 1\n}',
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves JSON Lines exactly as recorded', () => {
|
||||
const text = '{"a":1}\n{"b":2}';
|
||||
expect(formatJsonPayload(text)).toEqual({ text, json: false });
|
||||
});
|
||||
|
||||
it('leaves a mixed JSON-lines payload untouched', () => {
|
||||
const text = '{"a":1}\nnot json';
|
||||
expect(formatJsonPayload(text)).toEqual({ text, json: false });
|
||||
});
|
||||
|
||||
it.each(['hello', '42', '"text"', 'null', '{"a":'])(
|
||||
'leaves %s untouched',
|
||||
(text) => {
|
||||
expect(formatJsonPayload(text)).toEqual({ text, json: false });
|
||||
},
|
||||
);
|
||||
|
||||
it('leaves non-canonical number literals as recorded', () => {
|
||||
const text =
|
||||
'{"n":1.2345678901234567890,"id":12345678901234567890,"neg":-0,"exp":1e3,"amount":1.10,"tiny":1e-400,"huge":1e400}';
|
||||
expect(formatJsonPayload(text)).toEqual({ text, json: false });
|
||||
});
|
||||
|
||||
it('leaves duplicate keys and their order as recorded', () => {
|
||||
const text = '{"a":1,"a":2,"500":"server error","200":"ok"}';
|
||||
expect(formatJsonPayload(text)).toEqual({ text, json: false });
|
||||
});
|
||||
|
||||
it('preserves escaped string contents', () => {
|
||||
const text = '{"s":"a\\"b}{","n":1}';
|
||||
expect(formatJsonPayload(text)).toEqual({
|
||||
text: '{\n "s": "a\\"b}{",\n "n": 1\n}',
|
||||
json: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('leaves deeply nested small JSON unformatted', () => {
|
||||
const text = '{"a":'.repeat(40) + '1' + '}'.repeat(40);
|
||||
expect(JSON.stringify(JSON.parse(text), null, 2).length).toBeLessThan(
|
||||
EXPANDED_TOOL_PAYLOAD_LENGTH,
|
||||
);
|
||||
expect(formatJsonPayload(text)).toEqual({ text, json: false });
|
||||
});
|
||||
|
||||
it('falls back when formatted output exceeds the render cap', () => {
|
||||
const text = '{"a":1}';
|
||||
expect(formatJsonPayload(text, 5)).toEqual({ text, json: false });
|
||||
});
|
||||
|
||||
it('leaves an oversized payload alone instead of parsing it', () => {
|
||||
const text = JSON.stringify({
|
||||
value: 'x'.repeat(EXPANDED_TOOL_PAYLOAD_LENGTH),
|
||||
});
|
||||
expect(text.length).toBeGreaterThan(EXPANDED_TOOL_PAYLOAD_LENGTH);
|
||||
expect(formatJsonPayload(text)).toEqual({ text, json: false });
|
||||
});
|
||||
});
|
||||
|
||||
describe('subagent detail availability', () => {
|
||||
it.each([
|
||||
[false, 'pending', false, 'subagent.creating'],
|
||||
|
||||
@@ -206,6 +206,107 @@ export function truncateText(text: string, max: number): string {
|
||||
return text.slice(0, max) + '...';
|
||||
}
|
||||
|
||||
/** Longest payload stretch a transcript card renders before "Show more". */
|
||||
export const MAX_TOOL_PAYLOAD_LENGTH = 4000;
|
||||
|
||||
/** Ceiling after "Show more", matching the trajectory inspector. */
|
||||
export const EXPANDED_TOOL_PAYLOAD_LENGTH = 40_000;
|
||||
|
||||
export function stringifyValue(value: unknown): string {
|
||||
if (value === undefined || value === null) return '';
|
||||
const text = typeof value === 'string' ? value : safeJson(value);
|
||||
return text.trim();
|
||||
}
|
||||
|
||||
function safeJson(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2) ?? '';
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-indents a canonical JSON object or array for display. The input must be
|
||||
* exactly its compact JSON form or its two-space form; anything else — plain
|
||||
* text, a primitive, a truncated document, JSON Lines, a mixed JSON/text log
|
||||
* or non-canonical JSON — is returned as recorded so the caller can fall back
|
||||
* to plain text. Formatting therefore never rewrites literals, duplicate
|
||||
* keys, key order or string escapes.
|
||||
*
|
||||
* These rules are scoped to the transcript tool card. The sibling
|
||||
* ToolCallDetail used by the turn-calls / trajectory panes still fences via a
|
||||
* bare JSON.parse, so identical bytes can render differently per surface;
|
||||
* lifting one shared Arguments/Result renderer is tracked as a follow-up
|
||||
* rather than widened into this change.
|
||||
*/
|
||||
export function formatJsonPayload(
|
||||
text: string,
|
||||
maxFormattedLength = EXPANDED_TOOL_PAYLOAD_LENGTH,
|
||||
maxInputLength = EXPANDED_TOOL_PAYLOAD_LENGTH,
|
||||
): {
|
||||
text: string;
|
||||
json: boolean;
|
||||
} {
|
||||
// Pretty-printing can expand a compact payload well past the render cap, and
|
||||
// parsing a multi-megabyte string is not worth it for output the card will
|
||||
// never show in full.
|
||||
if (text.length > maxInputLength) {
|
||||
return { text, json: false };
|
||||
}
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return { text, json: false };
|
||||
|
||||
const document = formatJsonDocument(trimmed, maxFormattedLength);
|
||||
if (document !== undefined) return { text: document, json: true };
|
||||
|
||||
return { text, json: false };
|
||||
}
|
||||
|
||||
const MAX_JSON_FORMAT_DEPTH = 32;
|
||||
|
||||
function formatJsonDocument(
|
||||
text: string,
|
||||
maxFormattedLength: number,
|
||||
): string | undefined {
|
||||
if (!/^[[{]/.test(text)) return undefined;
|
||||
let value: unknown;
|
||||
try {
|
||||
value = JSON.parse(text);
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
if (value === null || typeof value !== 'object') return undefined;
|
||||
if (exceedsJsonDepth(value, MAX_JSON_FORMAT_DEPTH)) return undefined;
|
||||
|
||||
// Compare against both canonical forms; only an exact match is safe to
|
||||
// reformat, because anything else would rewrite recorded literals or keys.
|
||||
const pretty = JSON.stringify(value, null, 2);
|
||||
if (text === pretty) return text;
|
||||
|
||||
const compact = JSON.stringify(value);
|
||||
if (text !== compact || pretty.length > maxFormattedLength) return undefined;
|
||||
return pretty;
|
||||
}
|
||||
|
||||
function exceedsJsonDepth(root: unknown, maxDepth: number): boolean {
|
||||
const stack: Array<[unknown, number]> = [[root, 1]];
|
||||
while (stack.length > 0) {
|
||||
const current = stack.pop();
|
||||
if (!current) break;
|
||||
const [value, depth] = current;
|
||||
if (depth > maxDepth) return true;
|
||||
if (Array.isArray(value)) {
|
||||
for (const entry of value) stack.push([entry, depth + 1]);
|
||||
} else if (value !== null && typeof value === 'object') {
|
||||
for (const entry of Object.values(value)) {
|
||||
stack.push([entry, depth + 1]);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// The tool-header description is shown single-line (CSS-ellipsised) when the
|
||||
// row is collapsed and fully wrapped when it is expanded, so we keep the whole
|
||||
// string rather than hard-capping it at a line's worth of characters. A
|
||||
|
||||
@@ -261,7 +261,7 @@ describe('SubAgentPanel sub-tool timestamps', () => {
|
||||
expect(stepWindow?.className).toContain('tools');
|
||||
});
|
||||
|
||||
it('hides non-standard sub-tool summaries until the row is expanded', () => {
|
||||
it('hides non-standard sub-tool details until the row is expanded', () => {
|
||||
const container = renderPanel(
|
||||
makeAgentWithSubTool({
|
||||
callId: 'sub-1',
|
||||
@@ -275,9 +275,10 @@ describe('SubAgentPanel sub-tool timestamps', () => {
|
||||
) as HTMLElement | undefined;
|
||||
|
||||
expect(row).toBeDefined();
|
||||
expect(container.textContent).not.toContain('2 item(s)');
|
||||
expect(container.textContent).not.toContain('package.json');
|
||||
act(() => row!.click());
|
||||
expect(container.textContent).toContain('2 item(s)');
|
||||
expect(container.textContent).toContain('Result');
|
||||
expect(container.textContent).toContain('package.json');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -623,6 +623,78 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.payloadSections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.payloadSection {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.payloadLabel {
|
||||
margin-bottom: 4px;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.payloadJson {
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.payloadText {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
border-radius: 5px;
|
||||
background: var(--muted);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
:global([data-transcript-render-mode='document']) .payloadJson,
|
||||
:global([data-transcript-render-mode='document']) .payloadText {
|
||||
max-height: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.payloadEmpty,
|
||||
.payloadTruncated {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.payloadTruncated {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.payloadMore {
|
||||
align-self: flex-start;
|
||||
margin-top: 2px;
|
||||
padding: 4px 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.payloadMore:hover {
|
||||
background: var(--muted);
|
||||
}
|
||||
|
||||
.expandedAgentCard {
|
||||
background: var(--secondary);
|
||||
border: 0.5px solid var(--border);
|
||||
|
||||
Reference in new issue
Block a user