import { describe, expect, it } from 'vitest';
import type { ChatMessage } from '../chat/types/types';
import {
buildRenderableMessageItems,
getLiveProcessGroups,
type RenderableMessageItem,
} from './processGrouping';
const baseTime = Date.parse('2026-05-18T08:00:00.000Z');
function timestamp(offsetMs: number): string {
return new Date(baseTime + offsetMs).toISOString();
}
function user(id: string, content = 'Do the work'): ChatMessage {
return {
id,
type: 'user',
content,
timestamp: timestamp(0),
};
}
function assistant(id: string, content: string, offsetMs = 1000): ChatMessage {
return {
id,
type: 'assistant',
content,
timestamp: timestamp(offsetMs),
};
}
function tool(
id: string,
toolName: string,
input: Record<string, unknown> = {},
offsetMs = 500,
toolResult: ChatMessage['toolResult'] = { content: 'ok' },
): ChatMessage {
return {
id,
type: 'assistant',
content: '',
timestamp: timestamp(offsetMs),
isToolUse: true,
toolName,
toolId: id,
toolInput: JSON.stringify(input),
toolResult,
};
}
function processAttachments(item: RenderableMessageItem | undefined) {
return [
...(item?.beforeProcessAttachments || []),
...(item?.afterProcessAttachments || []),
];
}
describe('processGrouping', () => {
it('hides ordinary live tools while keeping assistant prose visible', () => {
const messages = [
user('u1'),
assistant('a1', 'I will inspect the files.', 100),
tool('read-1', 'Read', { file_path: '/repo/src/App.tsx' }, 200),
tool('grep-1', 'Grep', { pattern: 'ProcessTrace' }, 300),
];
const items = buildRenderableMessageItems(messages, { isAssistantWorking: true });
const groups = getLiveProcessGroups(messages, { isAssistantWorking: true });
expect(items.map((item) => item.message.id)).toEqual(['u1', 'a1']);
expect(groups).toHaveLength(1);
expect(groups[0].afterOriginalIndex).toBe(1);
expect(groups[0].detailMessages.map((message) => message.toolName)).toEqual(['Read', 'Grep']);
});
it('summarizes Read and Grep tools as explored files and searches', () => {
const messages = [
user('u1'),
tool('read-1', 'Read', { file_path: '/repo/src/App.tsx' }, 100),
tool('read-2', 'Read', { file_path: '/repo/src/index.tsx' }, 200),
tool('grep-1', 'Grep', { pattern: 'MessagesPaneV2' }, 300),
assistant('a1', 'Here is what I found.', 400),
];
const items = buildRenderableMessageItems(messages);
const assistantItem = items.find((item) => item.message.id === 'a1');
expect(items.map((item) => item.message.id)).toEqual(['u1', 'a1']);
const attachment = processAttachments(assistantItem)[0];
expect(attachment?.processDetailMessages.map((message) => message.id)).toEqual([
'read-1',
'read-2',
'grep-1',
]);
expect(attachment?.processSummary.exploredFileCount).toBe(2);
expect(attachment?.processSummary.ragSearchCount).toBe(1);
expect(attachment?.processSummary.toolCallCount).toBe(3);
expect(attachment?.inlineImages).toEqual([]);
});
it('surfaces tool-result images outside the collapsed trace', () => {
const imageDataUrl = 'data:image/jpeg;base64,/9j/4AAQ';
const messages = [
user('u1'),
tool(
'read-1',
'Read',
{ file_path: '/Users/me/Downloads/3D.jpg' },
100,
{
content: '[Image file]',
images: [{ data: imageDataUrl, name: '3D.jpg', mimeType: 'image/jpeg' }],
} as ChatMessage['toolResult'],
),
assistant('a1', 'This is a 3D surface plot.', 200),
];
const items = buildRenderableMessageItems(messages);
const attachment = processAttachments(items.find((item) => item.message.id === 'a1'))[0];
expect(attachment?.inlineImages).toEqual([
{
data: imageDataUrl,
name: '3D.jpg',
mimeType: 'image/jpeg',
source: 'tool_result',
toolId: 'read-1',
},
]);
});
it('summarizes edit tools with unique edited file count', () => {
const messages = [
user('u1'),
tool('edit-1', 'Edit', { file_path: '/repo/src/App.tsx' }, 100),
tool('write-1', 'Write', { file_path: '/repo/src/App.tsx' }, 200),
tool('patch-1', 'ApplyPatch', { file_path: '/repo/src/styles.css' }, 300),
assistant('a1', 'Updated the UI.', 400),
];
const items = buildRenderableMessageItems(messages);
const summary = processAttachments(items.find((item) => item.message.id === 'a1'))[0]?.processSummary;
expect(summary?.editedFileCount).toBe(2);
expect(summary?.toolCallCount).toBe(3);
});
it('summarizes Bash tools as command activity', () => {
const messages = [
user('u1'),
tool('bash-1', 'Bash', { command: 'npm test' }, 100),
tool('bash-2', 'Bash', { command: 'npm run lint' }, 200),
assistant('a1', 'Checks are done.', 300),
];
const items = buildRenderableMessageItems(messages);
const summary = processAttachments(items.find((item) => item.message.id === 'a1'))[0]?.processSummary;
expect(summary?.commandCount).toBe(2);
expect(summary?.toolCallCount).toBe(2);
});
it('folds ordinary failed tools into process summaries and counts errors', () => {
const failedResult = {
content: '<tool_use_error>InputValidationError: missing file_path</tool_use_error>',
isError: true,
errorCode: 'tool_execution_failed',
};
const messages = [
user('u1'),
tool('edit-1', 'Edit', { file_path: '/repo/src/App.tsx' }, 100, failedResult),
tool('grep-1', 'Grep', { pattern: 'Footer' }, 200, failedResult),
tool('bash-1', 'Bash', { command: 'npm run build' }, 300, failedResult),
assistant('a1', 'I will retry with corrected inputs.', 400),
];
const items = buildRenderableMessageItems(messages);
const assistantItem = items.find((item) => item.message.id === 'a1');
const attachment = processAttachments(assistantItem)[0];
expect(items.map((item) => item.message.id)).toEqual(['u1', 'a1']);
expect(attachment?.processDetailMessages.map((message) => message.id)).toEqual([
'edit-1',
'grep-1',
'bash-1',
]);
expect(attachment?.processSummary.toolCallCount).toBe(3);
expect(attachment?.processSummary.toolErrorCount).toBe(3);
expect(attachment?.processSummary.editedFileCount).toBe(1);
expect(attachment?.processSummary.ragSearchCount).toBe(1);
expect(attachment?.processSummary.commandCount).toBe(1);
});
it('keeps completed process summaries segmented at their original assistant positions', () => {
const messages = [
user('u1'),
assistant('a1', 'First I will inspect files.', 100),
tool('read-1', 'Read', { file_path: '/repo/src/App.tsx' }, 200),
assistant('a2', 'Now I will run checks.', 300),
tool('bash-1', 'Bash', { command: 'npm test' }, 400),
assistant('a3', 'Done.', 500),
];
const items = buildRenderableMessageItems(messages);
const firstAssistant = items.find((item) => item.message.id === 'a1');
const secondAssistant = items.find((item) => item.message.id === 'a2');
const thirdAssistant = items.find((item) => item.message.id === 'a3');
expect(items.map((item) => item.message.id)).toEqual(['u1', 'a1', 'a2', 'a3']);
expect(firstAssistant?.afterProcessAttachments).toHaveLength(1);
expect(firstAssistant?.afterProcessAttachments[0].processSummary.exploredFileCount).toBe(1);
expect(secondAssistant?.afterProcessAttachments).toHaveLength(1);
expect(secondAssistant?.afterProcessAttachments[0].processSummary.commandCount).toBe(1);
expect(thirdAssistant?.beforeProcessAttachments).toHaveLength(0);
expect(processAttachments(thirdAssistant)).toHaveLength(0);
});
it('attaches completed run duration after the user turn finishes', () => {
const messages: ChatMessage[] = [
user('u1'),
assistant('a1', 'I finished the work.', 5000),
{
id: 'summary-1',
type: 'system',
content: 'Process summary',
timestamp: timestamp(81000),
isAgentActivitySummary: true,
durationMs: 80000,
state: 'completed',
},
];
const items = buildRenderableMessageItems(messages);
const userItem = items.find((item) => item.message.id === 'u1');
expect(items.map((item) => item.message.id)).toEqual(['u1', 'a1']);
expect(userItem?.afterRunAttachment?.durationMs).toBe(80000);
});
it('attaches a leading completed process segment before the next assistant message', () => {
const messages = [
user('u1'),
tool('read-1', 'Read', { file_path: '/repo/src/App.tsx' }, 100),
assistant('a1', 'Here is what I found.', 200),
];
const items = buildRenderableMessageItems(messages);
const assistantItem = items.find((item) => item.message.id === 'a1');
expect(items.map((item) => item.message.id)).toEqual(['u1', 'a1']);
expect(assistantItem?.beforeProcessAttachments).toHaveLength(1);
expect(assistantItem?.beforeProcessAttachments[0].processSummary.exploredFileCount).toBe(1);
expect(assistantItem?.afterProcessAttachments).toHaveLength(0);
});
it('does not hide user-visible prompts, plan exits, permissions, or errors', () => {
const permissionError = {
content: 'Permission required',
isError: true,
errorCode: 'permission_required',
};
const messages = [
user('u1'),
tool('ask-1', 'AskUserQuestion', { question: 'Continue?' }, 100),
tool('plan-1', 'ExitPlanMode', { plan: 'Do it' }, 200),
tool('denied-1', 'Bash', { command: 'rm file' }, 300, permissionError),
{
id: 'error-1',
type: 'error',
content: 'Something failed',
timestamp: timestamp(400),
},
assistant('a1', 'Waiting on you.', 500),
];
const items = buildRenderableMessageItems(messages);
expect(items.map((item) => item.message.id)).toEqual([
'u1',
'ask-1',
'plan-1',
'denied-1',
'error-1',
'a1',
]);
expect(processAttachments(items.find((item) => item.message.id === 'a1'))).toHaveLength(0);
});
it('folds plan-mode side-effect denials into the process row', () => {
const planModeDeny = {
content: 'Plan mode denies side-effecting tool bash.',
isError: true,
errorCode: 'permission_denied',
};
const messages = [
user('u1'),
tool('plan-deny-1', 'bash', { command: 'cd .' }, 100, planModeDeny),
assistant('a1', 'I will continue with a plan.', 200),
];
const items = buildRenderableMessageItems(messages);
const assistantItem = items.find((item) => item.message.id === 'a1');
const attachments = processAttachments(assistantItem);
expect(items.map((item) => item.message.id)).toEqual(['u1', 'a1']);
expect(attachments).toHaveLength(1);
expect(attachments[0].processDetailMessages.map((message) => message.id)).toEqual(['plan-deny-1']);
expect(attachments[0].processSummary.toolErrorCount).toBe(1);
});
});