Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
231 changes: 227 additions & 4 deletions packages/web-shell/client/components/messages/ToolGroup.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,30 +27,71 @@ afterEach(() => {
}
});

function makeShellTool(output: string): ACPToolCall {
function makeShellTool(
output: string,
status: ACPToolCall['status'] = 'completed',
): ACPToolCall {
return {
callId: 'call-shell-1',
toolName: 'Shell',
status: 'completed',
status,
rawOutput: { output },
};
}

function renderShellTool(output: string): HTMLElement {
function renderTool(tool: ACPToolCall): HTMLElement {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
act(() => {
root.render(
<I18nProvider language="en">
<ToolGroup tools={[makeShellTool(output)]} />
<ToolGroup tools={[tool]} />
</I18nProvider>,
);
});
mounted.push({ root, container });
return container;
}

function renderShellTool(output: string): HTMLElement {
const container = renderTool(makeShellTool(output));
// Completed tools collapse to a one-line summary by default; open the row so
// the assertions below can inspect the bash-output view.
const chevron = [...container.querySelectorAll('span')].find(
(s) => s.textContent === '▸',
);
if (chevron?.parentElement) click(chevron.parentElement);
return container;
}

function makeShellCommandTool(command: string): ACPToolCall {
return {
callId: 'call-shell-cmd',
toolName: 'run_shell_command',
status: 'completed',
args: { command },
rawOutput: { output: 'done' },
};
}

function makeAgentTool(status: ACPToolCall['status']): ACPToolCall {
return {
callId: 'agent-1',
toolName: 'task',
status,
args: { description: 'agentDescMarker' },
subTools: [
{
callId: 'agent-1-sub-1',
toolName: 'Read',
status: 'completed',
args: { file_path: '/ws/SubToolMarker.ts' },
},
],
};
}

function getExpandButton(container: HTMLElement): HTMLButtonElement {
const button = container.querySelector('button');
expect(button).not.toBeNull();
Expand Down Expand Up @@ -124,3 +165,185 @@ describe('shell tool output expand toggle', () => {
expect(button.getAttribute('aria-expanded')).toBe('false');
});
});

describe('tool description expand toggle', () => {
it('relocates the full command from the header into a wrapped block on expand', () => {
const command = `npm run build && npm run test && npm run lint -- ${'x'.repeat(
80,
)}`;
const container = renderTool(makeShellCommandTool(command));

Comment thread
wenshao marked this conversation as resolved.
Resolved
// textContent alone can't prove relocation (it concatenates the whole
// subtree, so the command is present in either state). Assert the DOM move
// instead: collapsed, the full command lives in the header's single-line
// arg <span> (CSS-ellipsised); expanded, it is no longer in any leaf <span>
// but reflowed into the wrapped block below.
const commandInLeafSpan = () =>
[...container.querySelectorAll('span')].some(
(s) => s.textContent === command,
);

expect(container.textContent).toContain('▸');
expect(commandInLeafSpan()).toBe(true);

const chevron = [...container.querySelectorAll('span')].find(
(s) => s.textContent === '▸',
);
click(chevron!.parentElement!);

expect(container.textContent).toContain('▾');
expect(commandInLeafSpan()).toBe(false);
expect(container.textContent).toContain(command); // still present, in the block
});

it('keeps the result summary when expanding a long-description tool with no detail view', () => {
// glob with a long pattern: descExpandable but no kind-specific renderer.
const pattern = `**/${'x'.repeat(80)}/*.ts`;
const container = renderTool({
callId: 'call-glob',
toolName: 'glob',
status: 'completed',
args: { pattern },
rawOutput: 'a.ts\nb.ts\nc.ts',
});

const chevron = [...container.querySelectorAll('span')].find(
(s) => s.textContent === '▸',
);
expect(chevron).toBeTruthy(); // long pattern → expandable
expect(container.textContent).toContain('matching file'); // summary, collapsed

click(chevron!.parentElement!);

// Expanded: the summary must NOT be lost (no detail view replaces it), and
// the full pattern is reflowed into the block.
expect(container.textContent).toContain('matching file');
expect(container.textContent).toContain(pattern);
});
});

describe('auto-collapse on finish', () => {
it('collapses a completed tool to its summary by default', () => {
const container = renderTool(makeShellTool('a\nb\nc\nd'));
// The expanded bash <pre> is not rendered until the user opens the row.
expect(container.querySelector('pre')).toBeNull();
expect(container.textContent).toContain('▸');
});

it('keeps a running tool expanded so streaming output stays visible', () => {
const container = renderTool(
makeShellTool('streaming output', 'in_progress'),
);
expect(container.querySelector('pre')?.textContent).toContain('streaming');
});

it('keeps a failed tool expanded so the error stays visible', () => {
const container = renderTool(
makeShellTool('error: boom\n at step 1', 'failed'),
);
expect(container.querySelector('pre')?.textContent).toContain('boom');
});

it('auto-collapses a running tool when it transitions to completed', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });

const output = 'line1\nline2\nline3\nline4';
act(() => {
root.render(
<I18nProvider language="en">
<ToolGroup tools={[makeShellTool(output, 'in_progress')]} />
</I18nProvider>,
);
});
// Running → expanded: the bash output is visible.
expect(container.querySelector('pre')).not.toBeNull();

// Same callId, now finished → the row collapses on its own.
act(() => {
root.render(
<I18nProvider language="en">
<ToolGroup tools={[makeShellTool(output, 'completed')]} />
</I18nProvider>,
);
});
expect(container.querySelector('pre')).toBeNull();
});

it('does not collapse an agent the user expanded when it completes', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });

const render = (status: ACPToolCall['status']) =>
act(() => {
root.render(
<I18nProvider language="en">
<ToolGroup tools={[makeAgentTool(status)]} />
</I18nProvider>,
);
});

render('in_progress');
// Agents start collapsed: the sub-tool panel is hidden.
expect(container.textContent).not.toContain('SubToolMarker');

// Expand by clicking the agent's summary row.
const summaryLabel = [...container.querySelectorAll('span')].find(
(s) => s.textContent === 'task:',
);
expect(summaryLabel).toBeTruthy();
click(summaryLabel!.parentElement!);
expect(container.textContent).toContain('SubToolMarker');

// Completion must NOT yank the panel shut: the collapse-on-finish effect
// is scoped to non-agent tools.
render('completed');
expect(container.textContent).toContain('SubToolMarker');
});

it('keeps a tool the user manually expanded open when it completes', () => {
const container = document.createElement('div');
document.body.appendChild(container);
const root = createRoot(container);
mounted.push({ root, container });

// Long command → the row is expandable/clickable even while running.
const command = `echo ${'z'.repeat(80)}`;
const renderStatus = (status: ACPToolCall['status']) =>
act(() => {
root.render(
<I18nProvider language="en">
<ToolGroup
tools={[
{
callId: 'call-usertoggle',
toolName: 'run_shell_command',
status,
args: { command },
rawOutput: { output: 'done' },
},
]}
/>
</I18nProvider>,
);
});

renderStatus('in_progress');
const chevron = () =>
[...container.querySelectorAll('span')].find(
(s) => s.textContent === '▾' || s.textContent === '▸',
);
// Toggle twice → marks the row user-controlled, ending in the expanded state.
click(chevron()!.parentElement!);
click(chevron()!.parentElement!);
expect(container.textContent).toContain('▾');

// Completion must NOT override the user's explicit expand.
renderStatus('completed');
expect(container.textContent).toContain('▾');
});
});
Loading
Loading