Files
qwen-code/packages/web-shell/client/components/messages/tools/SubAgentPanel.test.tsx
T
Sergey BessonovandShaojin Wen 588db8b137 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]>
2026-11-07 23:38:51 +08:00

335 lines
11 KiB
TypeScript

// @vitest-environment jsdom
import { afterEach, describe, expect, it, vi } from 'vitest';
import { act } from 'react';
import { createRoot, type Root } from 'react-dom/client';
import { I18nProvider } from '../../../i18n';
import type { ACPToolCall } from '../../../adapters/types';
import { WebShellCustomizationProvider } from '../../../customization';
import { TranscriptRenderModeProvider } from '../../../transcriptRenderMode';
import { formatTimestamp } from '../../MessageTimestamp';
// SubAgentPanel pulls in ToolGroup, which reads both todo contexts.
vi.mock('../../../WebShellContexts', async () => {
const { createContext } = await import('react');
return {
TodoTimelineContext: createContext(new Map()),
TodoDetailContext: createContext(new Map()),
};
});
const { SubAgentPanel } = await import('./SubAgentPanel');
(
globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }
).IS_REACT_ACT_ENVIRONMENT = true;
const mounted: Array<{ root: Root; container: HTMLElement }> = [];
afterEach(() => {
for (const { root, container } of mounted.splice(0)) {
act(() => root.unmount());
container.remove();
}
});
function renderPanel(
tool: ACPToolCall,
options: {
compactThinking?: boolean;
hideHeader?: boolean;
renderMode?: 'interactive' | 'document';
} = {},
): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<TranscriptRenderModeProvider
value={options.renderMode ?? 'interactive'}
>
<WebShellCustomizationProvider
value={{ compactThinking: options.compactThinking }}
>
<SubAgentPanel
tool={tool}
defaultExpanded
inline
hideHeader={options.hideHeader ?? true}
/>
</WebShellCustomizationProvider>
</TranscriptRenderModeProvider>
</I18nProvider>,
);
});
mounted.push({ root, container });
return container;
}
function makeAgentWithSubTool(subTool: ACPToolCall): ACPToolCall {
return {
callId: 'agent-1',
toolName: 'Task',
status: 'completed',
args: { description: 'demo agent' },
subTools: [subTool],
};
}
describe('SubAgentPanel sub-tool timestamps', () => {
it('shows pending result processing on the original agent header', () => {
const container = renderPanel(
{
callId: 'agent-1',
toolName: 'Task',
status: 'completed',
backgroundResultPending: true,
},
{ hideHeader: false },
);
expect(container.textContent).toContain('Awaiting processing');
});
it('marks a failed sub-tool with an error icon instead of text', () => {
const container = renderPanel(
makeAgentWithSubTool({
callId: 'sub-1',
toolName: 'Read',
status: 'failed',
}),
);
const errorIcon = container.querySelector('[class*="iconError"]');
expect(errorIcon).not.toBeNull();
expect(errorIcon?.querySelector('svg')).not.toBeNull();
expect(container.textContent).not.toContain('Failed');
});
it('renders completed result content through assistant markdown', () => {
const container = renderPanel({
callId: 'agent-1',
toolName: 'Task',
status: 'completed',
rawOutput: {
type: 'task_execution',
result: '**done**',
},
});
const markdown = container.querySelector(
'[data-markdown-source="assistant"]',
);
expect(markdown).not.toBeNull();
expect(markdown?.querySelector('strong')?.textContent).toBe('done');
});
it('renders each sub-tool start time, like the main transcript rows', () => {
// A past date so formatTimestamp always renders the dated form; the
// expectation is derived from the same formatter, so it matches
// regardless of the test machine's clock or timezone.
const startTime = new Date('2020-01-02T03:04:05').getTime();
const container = renderPanel(
makeAgentWithSubTool({
callId: 'sub-1',
toolName: 'Read',
status: 'completed',
startTime,
}),
);
expect(container.textContent).toContain(formatTimestamp(startTime));
});
it('renders a sub-tool without a start time unchanged (no time shown)', () => {
const reference = new Date('2020-01-02T03:04:05').getTime();
const container = renderPanel(
makeAgentWithSubTool({
callId: 'sub-1',
toolName: 'Read',
status: 'completed',
}),
);
expect(container.textContent).not.toContain(formatTimestamp(reference));
});
it('keeps sub-tools expandable while hiding their collapsed output summary', () => {
const container = renderPanel(
makeAgentWithSubTool({
callId: 'sub-1',
toolName: 'Shell',
status: 'completed',
args: { command: 'npm test' },
content: [
{
type: 'content',
content: { text: 'first line\nsecond line' },
},
],
}),
);
const row = Array.from(container.querySelectorAll('[role="button"]')).find(
(el) => el.textContent?.includes('Shell'),
) as HTMLElement | undefined;
expect(row).toBeDefined();
expect(row!.textContent).toContain('npm test');
expect(container.textContent).not.toContain('first line');
act(() => row!.click());
expect(container.textContent).toContain('first line');
expect(container.textContent).toContain('second line');
});
it('shows the conclusion and the step list together when complete', () => {
const container = renderPanel({
callId: 'agent-1',
toolName: 'Task',
status: 'completed',
rawOutput: { type: 'task_execution', result: 'all references found' },
subTools: [{ callId: 'sub-1', toolName: 'Grep', status: 'completed' }],
});
// No tab switching: both sections render at once, captioned.
expect(container.textContent).toContain('all references found');
expect(container.textContent).toContain('Grep');
expect(container.textContent).toContain('Result');
expect(container.textContent).toContain('Tools (1)');
});
it('shows steps and the live stream together while running', () => {
const container = renderPanel({
callId: 'agent-1',
toolName: 'Task',
status: 'in_progress',
subContent: 'scanning for usages…',
subTools: [{ callId: 'sub-1', toolName: 'Grep', status: 'in_progress' }],
});
expect(container.textContent).toContain('Grep');
expect(container.textContent).toContain('scanning for usages…');
// The live stream renders as a <pre>; while running it must be present.
expect(container.querySelector('[class*="stream"]')).not.toBeNull();
// The running flow is uncaptioned — no conclusion exists yet.
expect(container.textContent).not.toContain('Result');
});
it('keeps the live stream fully expanded in compact document mode', () => {
const container = renderPanel(
{
callId: 'agent-1',
toolName: 'Task',
status: 'in_progress',
subContent: Array.from(
{ length: 20 },
(_, index) => `stream line ${index}`,
).join('\n'),
},
{ compactThinking: true, renderMode: 'document' },
);
const stream = container.querySelector('pre[class*="stream"]');
expect(stream).not.toBeNull();
expect(stream?.className).not.toContain('streamCollapsed');
expect(container.querySelector('[aria-expanded]')).toBeNull();
});
it('renders a completed agent stream text as the conclusion, not the live stream', () => {
// The conclusion-first invariant: once complete, subContent is the
// conclusion (assistant markdown), never the running <pre> stream.
const container = renderPanel({
callId: 'agent-1',
toolName: 'Task',
status: 'completed',
subContent: 'the final answer',
});
const conclusion = container.querySelector(
'[data-markdown-source="assistant"]',
);
expect(conclusion).not.toBeNull();
expect(conclusion?.textContent).toContain('the final answer');
expect(container.querySelector('[class*="stream"]')).toBeNull();
});
it('always scroll-caps the step list, regardless of compactThinking', () => {
// The tabs are gone, so the conclusion renders above the steps; the step
// list carries the scroll cap unconditionally (previously compact-only)
// so a long list can never push the conclusion off-screen. The test runs
// with the default (non-compact) customization.
const container = renderPanel({
callId: 'agent-1',
toolName: 'Task',
status: 'completed',
subTools: [{ callId: 'sub-1', toolName: 'Grep', status: 'completed' }],
});
const stepWindow = container.querySelector('[class*="scrollWindow"]');
expect(stepWindow).not.toBeNull();
expect(stepWindow?.className).toContain('tools');
});
it('hides non-standard sub-tool details until the row is expanded', () => {
const container = renderPanel(
makeAgentWithSubTool({
callId: 'sub-1',
toolName: 'list_directory',
status: 'completed',
rawOutput: 'src\npackage.json',
}),
);
const row = Array.from(container.querySelectorAll('[role="button"]')).find(
(el) => el.textContent?.includes('ListFiles'),
) as HTMLElement | undefined;
expect(row).toBeDefined();
expect(container.textContent).not.toContain('package.json');
act(() => row!.click());
expect(container.textContent).toContain('Result');
expect(container.textContent).toContain('package.json');
});
});
describe('SubAgentPanel agent path', () => {
it('keeps the path on the name title without rendering it as a chip', () => {
// Nothing in the projection can produce a path deeper than `/root/<name>`
// today: the nested sub-content fallback was unreachable, so a visible
// nested-path chip claimed a capability the wire never delivers. The path
// stays available on hover, where it is accurate at any depth.
const container = renderPanel(
{
callId: 'agent-1',
toolName: 'Task',
status: 'completed',
args: { subagent_type: 'security' },
agentPath: '/root/security/deps',
},
{ hideHeader: false },
);
expect(container.textContent).not.toContain('/root/security/deps');
expect(
container.querySelector('[title="/root/security/deps"]'),
).not.toBeNull();
});
it('keeps a depth-1 path off the visible header but on the name title', () => {
// At depth 1 the path's last segment *is* the type name the header already
// shows, so rendering both is noise; hover still gives the full path.
const container = renderPanel(
{
callId: 'agent-1',
toolName: 'Task',
status: 'completed',
args: { subagent_type: 'general-purpose' },
agentPath: '/root/general-purpose',
},
{ hideHeader: false },
);
expect(container.textContent).not.toContain('/root/general-purpose');
expect(
container.querySelector('[title="/root/general-purpose"]'),
).not.toBeNull();
});
it('renders no path chrome when the daemon stamped no identity', () => {
const container = renderPanel(
{ callId: 'agent-1', toolName: 'Task', status: 'completed' },
{ hideHeader: false },
);
expect(container.querySelector('[title^="/root"]')).toBeNull();
expect(container.textContent).not.toContain('/root');
});
});