Makelore 2.0 initial clean snapshot
This commit is contained in:
641
tests/unit/task-visualization.test.ts
Normal file
641
tests/unit/task-visualization.test.ts
Normal file
@@ -0,0 +1,641 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { deriveTaskSteps, parseSubagentCompletionInfo } from '@/pages/Chat/task-visualization';
|
||||
import {
|
||||
formatVisibleChatTranscript,
|
||||
getMessageVisibilityKey,
|
||||
getToolVisibilityId,
|
||||
} from '@/pages/Chat/chat-transcript';
|
||||
import { stripProcessMessagePrefix } from '@/pages/Chat/message-utils';
|
||||
import type { RawMessage, ToolStatus } from '@/types/chat';
|
||||
|
||||
describe('deriveTaskSteps', () => {
|
||||
it('builds running steps from streaming thinking and tool status', () => {
|
||||
const streamingTools: ToolStatus[] = [
|
||||
{
|
||||
name: 'web_search',
|
||||
status: 'running',
|
||||
updatedAt: Date.now(),
|
||||
summary: 'Searching docs',
|
||||
},
|
||||
];
|
||||
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Compare a few approaches before coding.' },
|
||||
{ type: 'tool_use', id: 'tool-1', name: 'web_search', input: { query: 'opencode task list' } },
|
||||
],
|
||||
},
|
||||
streamingTools,
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'stream-thinking-0',
|
||||
label: 'Thinking',
|
||||
status: 'running',
|
||||
kind: 'thinking',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
label: 'web_search',
|
||||
status: 'running',
|
||||
kind: 'tool',
|
||||
visibilityId: getToolVisibilityId('assistant-streaming', 'tool-1', 0),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps completed tool steps visible while a later tool is still streaming', () => {
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'assistant-history',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'tool-read', name: 'read', input: { filePath: '/tmp/a.md' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
streamingMessage: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'tool-grep', name: 'grep', input: { pattern: 'TODO' } },
|
||||
],
|
||||
},
|
||||
streamingTools: [
|
||||
{
|
||||
toolCallId: 'tool-grep',
|
||||
name: 'grep',
|
||||
status: 'running',
|
||||
updatedAt: Date.now(),
|
||||
summary: 'Scanning files',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'tool-read',
|
||||
label: 'read',
|
||||
status: 'completed',
|
||||
kind: 'tool',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'tool-grep',
|
||||
label: 'grep',
|
||||
status: 'running',
|
||||
kind: 'tool',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('upgrades a completed historical tool step when streaming status reports a later state', () => {
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'assistant-history',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'tool-read', name: 'read', input: { filePath: '/tmp/a.md' } },
|
||||
],
|
||||
},
|
||||
],
|
||||
streamingMessage: null,
|
||||
streamingTools: [
|
||||
{
|
||||
toolCallId: 'tool-read',
|
||||
name: 'read',
|
||||
status: 'error',
|
||||
updatedAt: Date.now(),
|
||||
summary: 'Permission denied',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'tool-read',
|
||||
label: 'read',
|
||||
status: 'error',
|
||||
kind: 'tool',
|
||||
detail: expect.stringContaining('"filePath": "/tmp/a.md"'),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps all steps when the execution graph exceeds the previous max length', () => {
|
||||
const messages: RawMessage[] = Array.from({ length: 9 }, (_, index) => ({
|
||||
role: 'assistant',
|
||||
id: `assistant-${index}`,
|
||||
content: [
|
||||
{ type: 'tool_use', id: `tool-${index}`, name: `read_${index}`, input: { filePath: `/tmp/${index}.md` } },
|
||||
],
|
||||
}));
|
||||
|
||||
const steps = deriveTaskSteps({
|
||||
messages,
|
||||
streamingMessage: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'tool-live', name: 'grep_live', input: { pattern: 'TODO' } },
|
||||
],
|
||||
},
|
||||
streamingTools: [
|
||||
{
|
||||
toolCallId: 'tool-live',
|
||||
name: 'grep_live',
|
||||
status: 'running',
|
||||
updatedAt: Date.now(),
|
||||
summary: 'Scanning current workspace',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(steps).toHaveLength(10);
|
||||
expect(steps[0]).toEqual(expect.objectContaining({
|
||||
id: 'tool-0',
|
||||
label: 'read_0',
|
||||
status: 'completed',
|
||||
}));
|
||||
expect(steps.at(-1)).toEqual(expect.objectContaining({
|
||||
id: 'tool-live',
|
||||
label: 'grep_live',
|
||||
status: 'running',
|
||||
}));
|
||||
});
|
||||
|
||||
it('keeps recent completed steps from assistant history', () => {
|
||||
const messages: RawMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'assistant-1',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Reviewing the code path.' },
|
||||
{ type: 'tool_use', id: 'tool-2', name: 'read_file', input: { path: 'src/App.tsx' } },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const steps = deriveTaskSteps({
|
||||
messages,
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'history-thinking-assistant-1-0',
|
||||
label: 'Thinking',
|
||||
status: 'completed',
|
||||
kind: 'thinking',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'tool-2',
|
||||
visibilityId: getToolVisibilityId('assistant-1', 'tool-2', 0),
|
||||
label: 'read_file',
|
||||
status: 'completed',
|
||||
kind: 'tool',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes historical and streaming thinking at the ExecutionGraph derivation boundary', () => {
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [{
|
||||
id: 'history',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'historical thought' },
|
||||
{ type: 'text', text: 'historical narration' },
|
||||
{ type: 'tool_use', id: 'read-1', name: 'read', input: { file: 'README.md' } },
|
||||
],
|
||||
}],
|
||||
streamingMessage: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'stream thought' },
|
||||
{ type: 'text', text: 'stream narration' },
|
||||
],
|
||||
},
|
||||
streamingTools: [],
|
||||
includeThinking: false,
|
||||
});
|
||||
|
||||
expect(steps.some((step) => step.kind === 'thinking')).toBe(false);
|
||||
expect(steps.some((step) => step.kind === 'message')).toBe(true);
|
||||
expect(steps.some((step) => step.kind === 'tool' && step.id === 'read-1')).toBe(true);
|
||||
});
|
||||
|
||||
it('uses the transcript-wide fallback index for historical tools without IDs', () => {
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [{
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: '',
|
||||
name: 'read',
|
||||
input: { file: 'README.md' },
|
||||
}],
|
||||
}],
|
||||
messageIndexOffset: 4,
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
visibilityId: getToolVisibilityId('assistant-4', undefined, 0),
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('shares one fallback visibility ID between an ID-less streaming graph and transcript export', () => {
|
||||
const streamingMessage: RawMessage = {
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: '',
|
||||
name: 'read',
|
||||
input: { visible: 'stream detail' },
|
||||
}],
|
||||
};
|
||||
const streamingMessageKey = getMessageVisibilityKey(streamingMessage, 0);
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage,
|
||||
streamingMessageKey,
|
||||
streamingTools: [],
|
||||
});
|
||||
const visibilityId = steps.find((step) => step.kind === 'tool')?.visibilityId;
|
||||
|
||||
expect(visibilityId).toBe(getToolVisibilityId(streamingMessageKey, undefined, 0));
|
||||
expect(formatVisibleChatTranscript([streamingMessage], {
|
||||
showThinking: false,
|
||||
showTimestamps: false,
|
||||
expandedToolIds: new Set(visibilityId ? [visibilityId] : []),
|
||||
})).toContain('stream detail');
|
||||
});
|
||||
|
||||
it('uses the matched streaming tool input as the shared visible graph and export detail', () => {
|
||||
const streamingMessage: RawMessage = {
|
||||
id: 'assistant-stream',
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: 'tool-read',
|
||||
name: 'read',
|
||||
input: { marker: 'RAW-UNSEEN-TOKEN' },
|
||||
}],
|
||||
};
|
||||
const messageKey = getMessageVisibilityKey(streamingMessage, 0);
|
||||
const visibilityId = getToolVisibilityId(messageKey, 'tool-read', 0);
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage,
|
||||
streamingMessageKey: messageKey,
|
||||
streamingTools: [{
|
||||
toolCallId: 'tool-read',
|
||||
name: 'read',
|
||||
status: 'running',
|
||||
updatedAt: 1,
|
||||
summary: 'Visible status title',
|
||||
}],
|
||||
});
|
||||
const graphDetail = steps.find((step) => step.visibilityId === visibilityId)?.detail;
|
||||
const markdown = formatVisibleChatTranscript([streamingMessage], {
|
||||
showThinking: false,
|
||||
showTimestamps: false,
|
||||
expandedToolIds: new Set([visibilityId]),
|
||||
});
|
||||
|
||||
expect(graphDetail).toContain('RAW-UNSEEN-TOKEN');
|
||||
expect(graphDetail).not.toContain('Visible status title');
|
||||
expect(markdown).toContain('RAW-UNSEEN-TOKEN');
|
||||
});
|
||||
|
||||
it('matches reordered same-name streaming tools by call ID before position', () => {
|
||||
const streamingMessage: RawMessage = {
|
||||
id: 'message-same-name',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'call-a', name: 'read', input: { marker: 'A-INPUT' } },
|
||||
{ type: 'tool_use', id: 'call-b', name: 'read', input: { marker: 'B-INPUT' } },
|
||||
],
|
||||
};
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage,
|
||||
streamingMessageKey: 'message-same-name',
|
||||
streamingTools: [
|
||||
{
|
||||
toolCallId: 'call-b',
|
||||
name: 'read',
|
||||
status: 'running',
|
||||
updatedAt: 2,
|
||||
},
|
||||
{
|
||||
toolCallId: 'call-a',
|
||||
name: 'read',
|
||||
status: 'running',
|
||||
updatedAt: 1,
|
||||
},
|
||||
],
|
||||
}).filter((step) => step.kind === 'tool');
|
||||
|
||||
expect(steps).toHaveLength(2);
|
||||
expect(steps.find((step) =>
|
||||
step.visibilityId === getToolVisibilityId('message-same-name', 'call-a', 0))
|
||||
?.detail).toContain('A-INPUT');
|
||||
expect(steps.find((step) =>
|
||||
step.visibilityId === getToolVisibilityId('message-same-name', 'call-b', 1))
|
||||
?.detail).toContain('B-INPUT');
|
||||
});
|
||||
|
||||
it('uses the matched message call ID when status has only a different event ID', () => {
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage: {
|
||||
id: 'message-event-id',
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: 'call-read',
|
||||
name: 'read',
|
||||
input: { marker: 'READ-INPUT' },
|
||||
}],
|
||||
},
|
||||
streamingMessageKey: 'message-event-id',
|
||||
streamingTools: [{
|
||||
id: 'part-read',
|
||||
name: 'read',
|
||||
status: 'running',
|
||||
updatedAt: 1,
|
||||
}],
|
||||
}).filter((step) => step.kind === 'tool');
|
||||
|
||||
expect(steps).toHaveLength(1);
|
||||
expect(steps[0]).toEqual(expect.objectContaining({
|
||||
id: 'call-read',
|
||||
visibilityId: getToolVisibilityId('message-event-id', 'call-read', 0),
|
||||
detail: expect.stringContaining('READ-INPUT'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('preserves meaningful whitespace in the shared tool input detail', () => {
|
||||
const streamingMessage: RawMessage = {
|
||||
id: 'message-whitespace',
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: 'call-write',
|
||||
name: 'write',
|
||||
input: {
|
||||
code: 'const answer = 42;',
|
||||
path: 'Private Notes/file.txt',
|
||||
},
|
||||
}],
|
||||
};
|
||||
const visibilityId = getToolVisibilityId(
|
||||
'message-whitespace',
|
||||
'call-write',
|
||||
0,
|
||||
);
|
||||
const detail = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage,
|
||||
streamingMessageKey: 'message-whitespace',
|
||||
streamingTools: [{
|
||||
toolCallId: 'call-write',
|
||||
name: 'write',
|
||||
status: 'running',
|
||||
updatedAt: 1,
|
||||
}],
|
||||
}).find((step) => step.visibilityId === visibilityId)?.detail;
|
||||
const markdown = formatVisibleChatTranscript([streamingMessage], {
|
||||
showThinking: false,
|
||||
showTimestamps: false,
|
||||
expandedToolIds: new Set([visibilityId]),
|
||||
});
|
||||
|
||||
expect(detail).toContain('const answer = 42;');
|
||||
expect(detail).toContain('Private Notes/file.txt');
|
||||
expect(markdown).toContain('const answer = 42;');
|
||||
expect(markdown).toContain('Private Notes/file.txt');
|
||||
});
|
||||
|
||||
it('preserves leading and trailing whitespace in string tool input details', () => {
|
||||
const detail = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage: {
|
||||
id: 'message-string-whitespace',
|
||||
role: 'assistant',
|
||||
content: [{
|
||||
type: 'tool_use',
|
||||
id: 'call-write',
|
||||
name: 'write',
|
||||
input: ' indented code ',
|
||||
}],
|
||||
},
|
||||
streamingMessageKey: 'message-string-whitespace',
|
||||
streamingTools: [],
|
||||
}).find((step) => step.kind === 'tool')?.detail;
|
||||
|
||||
expect(detail).toBe(' indented code ');
|
||||
});
|
||||
|
||||
it('uses the matched message-tool index when ID-less streaming statuses arrive out of order', () => {
|
||||
const streamingMessage: RawMessage = {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: '', name: 'read', input: { file: 'read.md' } },
|
||||
{ type: 'tool_use', id: '', name: 'write', input: { file: 'write.md' } },
|
||||
],
|
||||
};
|
||||
const streamingMessageKey = getMessageVisibilityKey(streamingMessage, 0);
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage,
|
||||
streamingMessageKey,
|
||||
streamingTools: [
|
||||
{ name: 'write', status: 'running', updatedAt: 2 },
|
||||
{ name: 'read', status: 'running', updatedAt: 1 },
|
||||
],
|
||||
});
|
||||
|
||||
expect(steps.find((step) => step.label === 'write')?.visibilityId)
|
||||
.toBe(getToolVisibilityId(streamingMessageKey, undefined, 1));
|
||||
expect(steps.find((step) => step.label === 'read')?.visibilityId)
|
||||
.toBe(getToolVisibilityId(streamingMessageKey, undefined, 0));
|
||||
});
|
||||
|
||||
it('splits cumulative streaming thinking into separate execution steps', () => {
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'thinking', thinking: 'Reviewing X.' },
|
||||
{ type: 'thinking', thinking: 'Reviewing X. Comparing Y.' },
|
||||
{ type: 'thinking', thinking: 'Reviewing X. Comparing Y. Drafting answer.' },
|
||||
],
|
||||
},
|
||||
streamingTools: [],
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'stream-thinking-0',
|
||||
detail: 'Reviewing X.',
|
||||
status: 'completed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'stream-thinking-1',
|
||||
detail: 'Comparing Y.',
|
||||
status: 'completed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'stream-thinking-2',
|
||||
detail: 'Drafting answer.',
|
||||
status: 'running',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps earlier reply segments in the graph when the last streaming segment is rendered separately', () => {
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [],
|
||||
streamingMessage: {
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'text', text: 'Checked X.' },
|
||||
{ type: 'text', text: 'Checked X. Checked Snowball.' },
|
||||
{ type: 'text', text: 'Checked X. Checked Snowball. Here is the summary.' },
|
||||
],
|
||||
},
|
||||
streamingTools: [],
|
||||
omitLastStreamingMessageSegment: true,
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'stream-message-0',
|
||||
detail: 'Checked X.',
|
||||
status: 'completed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'stream-message-1',
|
||||
detail: 'Checked Snowball.',
|
||||
status: 'completed',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('folds earlier reply segments into the graph but leaves the final answer for the chat bubble', () => {
|
||||
const steps = deriveTaskSteps({
|
||||
messages: [
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'assistant-reply',
|
||||
content: [
|
||||
{ type: 'text', text: 'Checked X.' },
|
||||
{ type: 'text', text: 'Checked X. Checked Snowball.' },
|
||||
{ type: 'text', text: 'Checked X. Checked Snowball. Here is the summary.' },
|
||||
],
|
||||
},
|
||||
],
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'history-message-assistant-reply-0',
|
||||
detail: 'Checked X.',
|
||||
status: 'completed',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'history-message-assistant-reply-1',
|
||||
detail: 'Checked Snowball.',
|
||||
status: 'completed',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('strips folded process narration from the final reply text', () => {
|
||||
expect(stripProcessMessagePrefix(
|
||||
'Checked X. Checked Snowball. Here is the summary.',
|
||||
['Checked X.', 'Checked Snowball.'],
|
||||
)).toBe('Here is the summary.');
|
||||
});
|
||||
|
||||
it('builds a branch for spawned subagents', () => {
|
||||
const messages: RawMessage[] = [
|
||||
{
|
||||
role: 'assistant',
|
||||
id: 'assistant-2',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'spawn-1',
|
||||
name: 'sessions_spawn',
|
||||
input: { agentId: 'coder', task: 'inspect repo' },
|
||||
},
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'yield-1',
|
||||
name: 'sessions_yield',
|
||||
input: { message: 'wait coder finishes' },
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const steps = deriveTaskSteps({
|
||||
messages,
|
||||
streamingMessage: null,
|
||||
streamingTools: [],
|
||||
});
|
||||
|
||||
expect(steps).toEqual([
|
||||
expect.objectContaining({
|
||||
id: 'spawn-1',
|
||||
label: 'sessions_spawn',
|
||||
depth: 1,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'spawn-1:branch',
|
||||
label: 'coder run',
|
||||
depth: 2,
|
||||
parentId: 'spawn-1',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
id: 'yield-1',
|
||||
label: 'sessions_yield',
|
||||
depth: 3,
|
||||
parentId: 'spawn-1:branch',
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it('parses internal subagent completion events from injected user messages', () => {
|
||||
const info = parseSubagentCompletionInfo({
|
||||
role: 'user',
|
||||
content: [{
|
||||
type: 'text',
|
||||
text: `[Internal task completion event]
|
||||
source: subagent
|
||||
session_key: agent:coder:subagent:child-123
|
||||
session_id: child-session-id
|
||||
status: completed successfully`,
|
||||
}],
|
||||
} as RawMessage);
|
||||
|
||||
expect(info).toEqual({
|
||||
sessionKey: 'agent:coder:subagent:child-123',
|
||||
sessionId: 'child-session-id',
|
||||
agentId: 'coder',
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user