qwen-code/packages/web-shell/client/adapters/transcriptToMessages.test.ts
ytahdn 40340ef505
fix(serve): classify interrupted model stream errors (#6422)
* fix(serve): classify interrupted model streams

* fix(serve): address interrupted stream review

* test(webui): cover legacy terminated turn error fallback

* fix(web-shell): preserve error message data shape

* test(daemon): cover turn error fallback boundaries

* fix(web-shell): preserve classified error data

---------

Co-authored-by: ytahdn <ytahdn@gmail.com>
2026-07-07 13:38:56 +00:00

3153 lines
94 KiB
TypeScript

import { describe, expect, it } from 'vitest';
import type {
DaemonShellTranscriptBlock,
DaemonStatusTranscriptBlock,
DaemonTextTranscriptBlock,
DaemonToolTranscriptBlock,
DaemonTranscriptBlock,
DaemonUserShellTranscriptBlock,
} from '@qwen-code/sdk/daemon';
import { transcriptBlocksToDaemonMessages } from './transcriptToMessages.js';
function textBlock(
id: string,
kind: 'user' | 'assistant' | 'thought',
text: string,
createdAt: number,
streaming = false,
overrides: Partial<DaemonTextTranscriptBlock> = {},
): DaemonTextTranscriptBlock {
return {
id,
kind,
text,
streaming,
clientReceivedAt: createdAt,
createdAt,
updatedAt: createdAt,
...overrides,
};
}
function statusBlock(
id: string,
text: string,
createdAt: number,
overrides: Partial<DaemonStatusTranscriptBlock> = {},
): DaemonStatusTranscriptBlock {
return {
id,
kind: 'status',
text,
clientReceivedAt: createdAt,
createdAt,
updatedAt: createdAt,
...overrides,
};
}
function promptCancelledBlock(
id: string,
createdAt: number,
): DaemonTranscriptBlock {
return {
id,
kind: 'prompt_cancelled',
clientReceivedAt: createdAt,
createdAt,
updatedAt: createdAt,
};
}
function shellBlock(
id: string,
text: string,
createdAt: number,
overrides: Partial<DaemonShellTranscriptBlock> = {},
): DaemonShellTranscriptBlock {
return {
id,
kind: 'shell',
text,
clientReceivedAt: createdAt,
createdAt,
updatedAt: createdAt,
...overrides,
};
}
function userShellBlock(
id: string,
text: string,
command: string,
createdAt: number,
overrides: Partial<DaemonUserShellTranscriptBlock> = {},
): DaemonUserShellTranscriptBlock {
return {
id,
kind: 'user_shell',
text,
command,
clientReceivedAt: createdAt,
createdAt,
updatedAt: createdAt,
...overrides,
};
}
function toolBlock(
id: string,
toolCallId: string,
status: string,
createdAt: number,
overrides: Partial<DaemonToolTranscriptBlock> = {},
): DaemonToolTranscriptBlock {
return {
id,
kind: 'tool',
toolCallId,
title: overrides.title ?? 'Tool',
status,
toolName: overrides.toolName ?? 'Read',
toolKind: overrides.toolKind,
preview: overrides.preview ?? { kind: 'generic' },
rawInput: overrides.rawInput,
rawOutput: overrides.rawOutput,
content: overrides.content,
locations: overrides.locations,
details: overrides.details,
parentToolCallId: overrides.parentToolCallId,
subagentType: overrides.subagentType,
clientReceivedAt: createdAt,
createdAt,
updatedAt: overrides.updatedAt ?? createdAt,
};
}
describe('transcriptBlocksToDaemonMessages', () => {
it('preserves user source metadata', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('user-1', 'user', 'scheduled prompt', 1, false, {
meta: { source: 'cron' },
}),
]);
expect(messages[0]).toMatchObject({
id: 'user-1',
role: 'user',
content: 'scheduled prompt',
source: 'cron',
});
});
it('hides background task notifications by metadata', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock(
'bg-1',
'assistant',
'Background agent "general-purpose: 查询百度云活动信息" completed.',
1,
false,
{
meta: {
source: 'background_notification',
qwenDiscreteMessage: true,
backgroundTask: { taskId: 'task-1', status: 'completed' },
},
},
),
textBlock('assistant-1', 'assistant', '正常回复', 2),
]);
expect(messages).toEqual([
{
id: 'assistant-1',
role: 'assistant',
content: '正常回复',
isStreaming: false,
timestamp: 2,
},
]);
});
it('renders daemon plan status blocks as plan messages', () => {
const plan = {
sessionUpdate: 'plan',
entries: [
{
content: '检查项目结构',
priority: 'medium',
status: 'pending',
},
{
content: '运行类型检查',
priority: 'high',
status: 'in_progress',
},
],
};
const messages = transcriptBlocksToDaemonMessages([
statusBlock('plan-1', `plan: ${JSON.stringify(plan)}`, 1),
]);
expect(messages).toEqual([
{
id: 'plan-1',
role: 'plan',
timestamp: 1,
todos: [
{
id: 'plan-0',
content: '检查项目结构',
priority: 'medium',
status: 'pending',
},
{
id: 'plan-1',
content: '运行类型检查',
priority: 'high',
status: 'in_progress',
},
],
},
]);
});
it('localizes structured session branch status blocks', () => {
const messages = transcriptBlocksToDaemonMessages(
[
statusBlock('branch-1', 'Branched conversation "old"', 1, {
source: 'session_branched',
data: {
sourceSessionId: 'source',
newSessionId: 'new',
displayName: 'support-branch-new3 (Branch 2)',
},
}),
],
{
labels: {
branchSuccess: (name) =>
`已复制会话,新会话名称为: "${name}",当前已切换到新的会话。`,
},
},
);
expect(messages).toEqual([
expect.objectContaining({
role: 'system',
content:
'已复制会话,新会话名称为: "support-branch-new3 (Branch 2)",当前已切换到新的会话。',
source: 'session_branched',
data: {
sourceSessionId: 'source',
newSessionId: 'new',
displayName: 'support-branch-new3 (Branch 2)',
},
}),
]);
});
it('localizes structured mid-turn inserted status blocks', () => {
const messages = transcriptBlocksToDaemonMessages(
[
statusBlock('mid-1', 'Inserted message: hello', 1, {
source: 'mid_turn_message_injected',
data: { sessionId: 's1', messages: ['你好'] },
}),
],
{
labels: {
midTurnInserted: (message) => `已插入消息:${message}`,
},
},
);
expect(messages).toEqual([
expect.objectContaining({
role: 'system',
content: '已插入消息:你好',
source: 'mid_turn_message_injected',
data: { sessionId: 's1', messages: ['你好'] },
}),
]);
});
it('ignores daemon plan entries without content', () => {
const plan = {
sessionUpdate: 'plan',
entries: [
{ content: '', priority: 'high', status: 'in_progress' },
{ content: '运行类型检查', priority: 'high', status: 'in_progress' },
],
};
const messages = transcriptBlocksToDaemonMessages([
statusBlock('plan-1', `plan: ${JSON.stringify(plan)}`, 1),
]);
expect(messages).toEqual([
{
id: 'plan-1',
role: 'plan',
timestamp: 1,
todos: [
{
id: 'plan-1',
content: '运行类型检查',
priority: 'high',
status: 'in_progress',
},
],
},
]);
});
it('keeps each TodoWrite call as a distinct tool entry with its own todos', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('todo-1', 'todo-call-1', 'completed', 1, {
title: 'Update Todos',
toolName: 'TodoWrite',
rawInput: {
todos: [
{
content: '检查项目结构',
priority: 'medium',
status: 'completed',
},
],
},
}),
toolBlock('todo-2', 'todo-call-2', 'completed', 2, {
title: 'Update Todos',
toolName: 'TodoWrite',
rawInput: {
todos: [
{
content: '运行类型检查',
priority: 'high',
status: 'in_progress',
},
],
},
}),
]);
// Each TodoWrite update stands alone in its own group (it renders as a
// self-contained collapsible checklist), rather than merging with adjacent
// tool calls.
expect(messages).toEqual([
{
id: 'tg-todo-1',
role: 'tool_group',
timestamp: 1,
tools: [
expect.objectContaining({
callId: 'todo-call-1',
toolName: 'TodoWrite',
}),
],
},
{
id: 'tg-todo-2',
role: 'tool_group',
timestamp: 2,
tools: [
expect.objectContaining({
callId: 'todo-call-2',
toolName: 'TodoWrite',
}),
],
},
]);
});
it('merges adjacent top-level tool blocks into one tool_group', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }),
toolBlock('t2', 'tc2', 'completed', 2, { toolName: 'Grep' }),
]);
expect(messages).toEqual([
{
id: 'tg-t1',
role: 'tool_group',
timestamp: 1,
tools: [
expect.objectContaining({ callId: 'tc1', toolName: 'Read' }),
expect.objectContaining({ callId: 'tc2', toolName: 'Grep' }),
],
},
]);
});
it('starts a new tool_group after intervening assistant text', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }),
textBlock('a1', 'assistant', 'found it, editing now', 2),
toolBlock('t2', 'tc2', 'completed', 3, { toolName: 'Edit' }),
]);
expect(messages).toMatchObject([
{ role: 'tool_group', tools: [{ callId: 'tc1' }] },
{ role: 'assistant', content: 'found it, editing now' },
{ role: 'tool_group', tools: [{ callId: 'tc2' }] },
]);
});
it('carries token usage from an assistant block onto the message', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'final answer', 1, false, {
usage: { inputTokens: 200, outputTokens: 80 },
}),
]);
expect(messages).toMatchObject([
{
role: 'assistant',
content: 'final answer',
usage: { inputTokens: 200, outputTokens: 80 },
},
]);
});
it('sums usage when consecutive assistant blocks merge into one message', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'hi ', 1, false, {
usage: { inputTokens: 100, outputTokens: 40 },
}),
textBlock('a2', 'assistant', 'there', 2, false, {
usage: { inputTokens: 20, outputTokens: 8 },
}),
]);
expect(messages).toMatchObject([
{
role: 'assistant',
content: 'hi there',
usage: { inputTokens: 120, outputTokens: 48 },
},
]);
});
it('leaves usage undefined when no assistant block reports it', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'no usage here', 1),
]);
expect((messages[0] as { usage?: unknown }).usage).toBeUndefined();
});
it('carries cached-read tokens through onto the message', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'answer', 1, false, {
usage: { inputTokens: 200, outputTokens: 80, cachedTokens: 150 },
}),
]);
expect(messages).toMatchObject([
{
role: 'assistant',
usage: { inputTokens: 200, outputTokens: 80, cachedTokens: 150 },
},
]);
});
it('starts a new tool_group after an intervening thought block', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }),
textBlock('th1', 'thought', 'next I should grep', 2),
toolBlock('t2', 'tc2', 'completed', 3, { toolName: 'Grep' }),
]);
expect(messages).toMatchObject([
{ role: 'tool_group', tools: [{ callId: 'tc1' }] },
{ role: 'thinking', content: 'next I should grep' },
{ role: 'tool_group', tools: [{ callId: 'tc2' }] },
]);
});
it('keeps merged groups intact when a member tool completes later', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'in_progress', 1, { toolName: 'Read' }),
toolBlock('t2', 'tc2', 'in_progress', 2, { toolName: 'Grep' }),
toolBlock('t1-done', 'tc1', 'completed', 3, {
toolName: 'Read',
updatedAt: 4,
}),
]);
expect(messages).toHaveLength(1);
const tools =
messages[0].role === 'tool_group' ? messages[0].tools : undefined;
expect(tools).toHaveLength(2);
expect(tools?.[0]).toMatchObject({ callId: 'tc1', status: 'completed' });
expect(tools?.[1]).toMatchObject({ callId: 'tc2', status: 'in_progress' });
});
it('never merges subagent calls into or after a regular tool_group', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }),
toolBlock('agent-1', 'agent-call-1', 'in_progress', 2, {
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('t2', 'tc2', 'completed', 3, { toolName: 'Grep' }),
]);
expect(messages).toMatchObject([
{ role: 'tool_group', tools: [{ callId: 'tc1' }] },
{ role: 'tool_group', tools: [{ callId: 'agent-call-1' }] },
{ role: 'tool_group', tools: [{ callId: 'tc2' }] },
]);
});
it('never merges todo_write updates into or after a regular tool_group', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'Read' }),
toolBlock('todo-1', 'todo-call-1', 'completed', 2, {
toolName: 'todo_write',
toolKind: 'think',
rawInput: { todos: [{ id: '1', content: 'A', status: 'in_progress' }] },
}),
toolBlock('t2', 'tc2', 'completed', 3, { toolName: 'Edit' }),
]);
expect(messages).toMatchObject([
{ role: 'tool_group', tools: [{ callId: 'tc1' }] },
{ role: 'tool_group', tools: [{ callId: 'todo-call-1' }] },
{ role: 'tool_group', tools: [{ callId: 'tc2' }] },
]);
});
it('does not merge real tool calls into synthetic raw-shell groups', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('u1', 'user', 'run it', 1),
shellBlock('sh1', 'raw shell output\n', 2),
toolBlock('t1', 'tc1', 'completed', 3, { toolName: 'Read' }),
]);
expect(messages).toMatchObject([
{ role: 'user', content: 'run it' },
{ id: 'sh1', role: 'tool_group', tools: [{ toolName: 'shell' }] },
{ id: 'tg-t1', role: 'tool_group', tools: [{ callId: 'tc1' }] },
]);
});
it('preserves tool block titles on message tool calls', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('ask-1', 'ask-call-1', 'completed', 1, {
toolName: 'AskUserQuestion',
title: 'Ask user 2 questions',
rawOutput:
'User has provided the following answers:\n\n**班级**: 一班\n**学号**: 001',
}),
]);
expect(messages).toMatchObject([
{
role: 'tool_group',
tools: [
{
callId: 'ask-call-1',
toolName: 'AskUserQuestion',
title: 'Ask user 2 questions',
},
],
},
]);
});
it('renders insight progress messages', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock(
'insight-1',
'assistant',
'{"insight_progress":{"stage":"scan","progress":0.5,"detail":"reading"}}',
1,
),
]);
expect(messages).toEqual([
{
id: 'insight-1-ip',
role: 'insight_progress',
timestamp: 1,
stage: 'scan',
progress: 0.5,
detail: 'reading',
},
]);
});
it('renders terminal insight messages with surrounding text', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock(
'insight-1',
'assistant',
'before {"insight_ready":{"path":"/tmp/report.md"}} middle {"insight_error":{"error":"boom"}} after',
1,
),
]);
expect(messages).toEqual([
{
id: 'insight-1-t-0',
role: 'assistant',
content: 'before',
timestamp: 1,
},
{
id: 'insight-1-ir-0',
role: 'insight_ready',
path: '/tmp/report.md',
timestamp: 1,
},
{
id: 'insight-1-t-2',
role: 'assistant',
content: 'middle',
timestamp: 1,
},
{
id: 'insight-1-ie-0',
role: 'insight_error',
error: 'boom',
timestamp: 1,
},
{
id: 'insight-1-t-4',
role: 'assistant',
content: 'after',
timestamp: 1,
},
]);
});
it('keeps malformed insight JSON as assistant text', () => {
const content = 'before {"insight_ready": bad} after';
const messages = transcriptBlocksToDaemonMessages([
textBlock('insight-1', 'assistant', content, 1),
]);
expect(messages).toMatchObject([
{ id: 'insight-1', role: 'assistant', content },
]);
});
it('keeps parented assistant chunks inside a subagent', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: 分析项目',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
textBlock('assistant-sub', 'assistant', 'subagent output', 20, true, {
parentToolCallId: 'agent-1',
}),
toolBlock('read-sub', 'read-1', 'completed', 30, {
title: 'Read file',
toolName: 'Read',
parentToolCallId: 'agent-1',
}),
toolBlock('agent-end', 'agent-1', 'completed', 40, {
title: 'Agent: 分析项目',
toolName: 'agent',
rawOutput: { type: 'task_execution' },
}),
textBlock('assistant-main', 'assistant', 'main output', 50, false),
]);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
role: 'tool_group',
tools: [
{
callId: 'agent-1',
status: 'completed',
subContent: 'subagent output',
subTools: [{ callId: 'read-1', status: 'completed' }],
},
],
});
expect(messages[1]).toMatchObject({
id: 'assistant-main',
role: 'assistant',
content: 'main output',
});
});
it('keeps parented compacted replay subagent content nested', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-completed', 'agent-1', 'completed', 10, {
title: 'Agent: 查询官网活动',
toolName: 'agent',
rawOutput: {
type: 'task_execution',
result: 'subagent final answer',
},
updatedAt: 100,
}),
textBlock('sub-thought', 'thought', 'subagent thinking', 20, false, {
parentToolCallId: 'agent-1',
}),
textBlock('sub-assistant', 'assistant', 'subagent answer', 30, false, {
parentToolCallId: 'agent-1',
}),
toolBlock('sub-fetch', 'fetch-1', 'completed', 40, {
title: 'WebFetch',
toolName: 'WebFetch',
parentToolCallId: 'agent-1',
}),
textBlock('main-assistant', 'assistant', 'main answer', 110),
]);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
role: 'tool_group',
tools: [
{
callId: 'agent-1',
status: 'completed',
subContent: 'subagent thinkingsubagent answer',
subTools: [{ callId: 'fetch-1', status: 'completed' }],
},
],
});
expect(messages[1]).toMatchObject({
id: 'main-assistant',
role: 'assistant',
content: 'main answer',
});
expect(
messages.some(
(message) =>
message.role === 'assistant' &&
message.content.includes('subagent answer'),
),
).toBe(false);
});
it('keeps parallel top-level subagents as sibling tool messages', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-1', 'agent-call-1', 'in_progress', 10, {
title: 'Agent: Correctness review agent',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-2', 'agent-call-2', 'in_progress', 20, {
title: 'Agent: Security review agent',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-3', 'agent-call-3', 'in_progress', 30, {
title: 'Agent: Performance review agent',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
]);
expect(messages).toHaveLength(3);
expect(messages).toMatchObject([
{
role: 'tool_group',
tools: [{ callId: 'agent-call-1' }],
},
{
role: 'tool_group',
tools: [{ callId: 'agent-call-2' }],
},
{
role: 'tool_group',
tools: [{ callId: 'agent-call-3' }],
},
]);
expect(
messages[0]?.role === 'tool_group' && messages[0].tools[0],
).not.toHaveProperty('subTools');
expect(
messages[1]?.role === 'tool_group' && messages[1].tools[0],
).not.toHaveProperty('subTools');
expect(
messages[2]?.role === 'tool_group' && messages[2].tools[0],
).not.toHaveProperty('subTools');
});
it('merges parallel subagent completion by callId instead of stack order', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-1-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: first',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-2-start', 'agent-2', 'in_progress', 20, {
title: 'Agent: second',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-1-end', 'agent-1', 'completed', 30, {
title: 'Agent: first done',
toolName: 'agent',
rawOutput: {
type: 'task_execution',
result: 'first result',
},
updatedAt: 35,
}),
toolBlock('agent-2-end', 'agent-2', 'completed', 40, {
title: 'Agent: second done',
toolName: 'agent',
rawOutput: {
type: 'task_execution',
result: 'second result',
},
updatedAt: 45,
}),
]);
expect(messages).toHaveLength(2);
expect(messages).toMatchObject([
{
id: 'tg-agent-1-start',
role: 'tool_group',
tools: [
{
callId: 'agent-1',
status: 'completed',
title: 'Agent: first done',
rawOutput: { result: 'first result' },
},
],
},
{
id: 'tg-agent-2-start',
role: 'tool_group',
tools: [
{
callId: 'agent-2',
status: 'completed',
title: 'Agent: second done',
rawOutput: { result: 'second result' },
},
],
},
]);
});
it('merges cancelled parallel subagent by callId without closing sibling', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-1-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: first',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-2-start', 'agent-2', 'in_progress', 20, {
title: 'Agent: second',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-1-cancel', 'agent-1', 'cancelled', 30, {
title: 'Agent: first',
toolName: 'agent',
details: 'Agent was cancelled by user',
updatedAt: 35,
}),
toolBlock('agent-2-end', 'agent-2', 'completed', 40, {
title: 'Agent: second done',
toolName: 'agent',
rawOutput: {
type: 'task_execution',
result: 'second result',
},
updatedAt: 45,
}),
]);
expect(messages).toHaveLength(2);
expect(messages).toMatchObject([
{
id: 'tg-agent-1-start',
role: 'tool_group',
tools: [
{
callId: 'agent-1',
status: 'completed',
endTime: 35,
rawOutput: {
status: 'cancelled',
reason: 'Agent was cancelled by user',
text: 'Agent was cancelled by user',
},
},
],
},
{
id: 'tg-agent-2-start',
role: 'tool_group',
tools: [
{
callId: 'agent-2',
status: 'completed',
title: 'Agent: second done',
endTime: 45,
rawOutput: { result: 'second result' },
},
],
},
]);
});
it('keeps background subagent launches pending and resumes main thread', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: Correctness review',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-background', 'agent-1', 'completed', 20, {
title: 'Agent: Correctness review',
toolName: 'agent',
rawOutput: {
type: 'task_execution',
taskDescription: 'Agent 1: Correctness review',
status: 'background',
},
updatedAt: 25,
}),
textBlock('thought-main', 'thought', 'wait for background agent', 30),
textBlock('assistant-main', 'assistant', 'main continues', 40),
]);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
id: 'tg-agent-start',
role: 'tool_group',
tools: [
{
callId: 'agent-1',
status: 'pending',
rawOutput: {
status: 'background',
taskDescription: 'Agent 1: Correctness review',
},
},
],
});
expect(
messages[0].role === 'tool_group'
? messages[0].tools[0].endTime
: undefined,
).toBeUndefined();
expect(messages[1]).toMatchObject({
id: 'thought-main',
role: 'thinking',
content: 'wait for background agent',
});
expect(messages[2]).toMatchObject({
id: 'assistant-main',
role: 'assistant',
content: 'main continues',
});
});
it('keeps merged background subagent blocks from capturing main-thread text', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-background', 'agent-1', 'completed', 20, {
title: 'Agent: Correctness review',
toolName: 'agent',
rawInput: {
description: 'Agent 1: Correctness review',
subagent_type: 'general-purpose',
run_in_background: true,
},
rawOutput: {
type: 'task_execution',
taskDescription: 'Agent 1: Correctness review',
status: 'background',
},
updatedAt: 25,
}),
textBlock('thought-main', 'thought', 'server diff looks clean', 30),
textBlock('assistant-main', 'assistant', 'waiting for agents', 40),
]);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
id: 'tg-agent-background',
role: 'tool_group',
tools: [
{
callId: 'agent-1',
status: 'pending',
rawOutput: {
status: 'background',
taskDescription: 'Agent 1: Correctness review',
},
},
],
});
expect(messages[1]).toMatchObject({
id: 'thought-main',
role: 'thinking',
content: 'server diff looks clean',
});
expect(messages[2]).toMatchObject({
id: 'assistant-main',
role: 'assistant',
content: 'waiting for agents',
});
});
it('keeps unparented assistant text in the main transcript', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-1', 'agent-call-1', 'in_progress', 10, {
title: 'Agent: first',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-2', 'agent-call-2', 'in_progress', 20, {
title: 'Agent: second',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
textBlock('a1', 'assistant', 'unparented stream', 30),
]);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: 'tool_group',
tools: [{ callId: 'agent-call-1' }],
});
expect(messages[1]).toMatchObject({
role: 'tool_group',
tools: [{ callId: 'agent-call-2' }],
});
const assistant = messages.find((message) => message.role === 'assistant');
expect(assistant).toMatchObject({ content: 'unparented stream' });
expect(
messages[1]?.role === 'tool_group' && messages[1].tools[0],
).not.toHaveProperty('subContent');
});
it('merges streaming assistant chunks into one message', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'hello ', 1, true),
textBlock('a2', 'assistant', 'world', 2, false),
]);
expect(messages).toEqual([
{
id: 'a1',
role: 'assistant',
content: 'hello world',
isStreaming: false,
timestamp: 1,
},
]);
});
it('creates standalone user_shell message for user shell output', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('u1', 'user', '! ls', 1),
userShellBlock('sh1', 'file1.ts\nfile2.ts\n', 'ls', 2),
statusBlock('st1', 'Shell command exited with code 0', 3),
]);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({ role: 'user', content: '! ls' });
expect(messages[1]).toMatchObject({
id: 'sh1',
role: 'user_shell',
command: 'ls',
output: 'file1.ts\nfile2.ts\n',
});
expect(messages[2]).toMatchObject({
role: 'system',
content: 'Shell command exited with code 0',
});
});
it('filters language_changed daemon debug noise', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'debug-1',
kind: 'debug',
text:
'language_changed (unrecognized daemon event): ' +
'{"sessionId":"dd699cc0-6ef7-4882-92d9-1076ac5b87e9",' +
'"language":"en","outputLanguage":"English","refreshed":true}',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
} as DaemonTranscriptBlock,
]);
expect(messages).toEqual([]);
});
it('filters SDK model switch status noise', () => {
const messages = transcriptBlocksToDaemonMessages([
statusBlock('st1', 'Model switched: qwen3-coder-plus(openai)', 1),
]);
expect(messages).toEqual([]);
});
it('preserves structured status source and data', () => {
const data = {
kind: 'set',
condition: 'ship goal sync',
setAt: 1234,
};
const messages = transcriptBlocksToDaemonMessages([
statusBlock('goal-1', '', 1, {
source: 'goal',
data,
}),
]);
expect(messages).toEqual([
{
id: 'goal-1',
role: 'system',
content: '',
variant: 'info',
source: 'goal',
data,
timestamp: 1,
},
]);
});
it('appends shell output to preceding tool_group', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, {
toolName: 'bash',
toolKind: 'execute',
}),
shellBlock('sh1', 'output text', 2),
]);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
role: 'tool_group',
tools: [
{
callId: 'tc1',
rawOutput: 'output text',
},
],
});
});
it('keeps shell output attached when the tool later completes', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'in_progress', 1, {
toolName: 'bash',
toolKind: 'execute',
}),
shellBlock('sh1', 'output text', 2),
toolBlock('t2', 'tc1', 'completed', 3, {
toolName: 'bash',
toolKind: 'execute',
title: 'Shell complete',
updatedAt: 4,
}),
]);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool).toMatchObject({
callId: 'tc1',
title: 'Shell complete',
status: 'completed',
rawOutput: 'output text',
endTime: 4,
});
});
it('does not stringify structured raw output before shell text', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, {
toolName: 'bash',
toolKind: 'execute',
rawOutput: { type: 'structured' },
}),
shellBlock('sh1', 'output text', 2),
]);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool?.rawOutput).toBe('output text');
});
it('concatenates multiple shell blocks after user message', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('u1', 'user', '! find .', 1),
shellBlock('sh1', 'chunk1\n', 2),
shellBlock('sh2', 'chunk2\n', 3),
]);
expect(messages).toHaveLength(2);
expect(messages[1]).toMatchObject({
role: 'tool_group',
tools: [{ rawOutput: 'chunk1\nchunk2\n' }],
});
});
it('attaches shell output to the running execute tool in a merged group', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc-bash', 'running', 1, {
toolName: 'bash',
toolKind: 'execute',
}),
toolBlock('t2', 'tc-read', 'completed', 2, { toolName: 'Read' }),
shellBlock('sh1', 'bash output', 3),
]);
expect(messages).toHaveLength(1);
const tools =
messages[0].role === 'tool_group' ? messages[0].tools : undefined;
expect(tools?.[0]).toMatchObject({
callId: 'tc-bash',
rawOutput: 'bash output',
});
expect(tools?.[1]?.rawOutput).toBeUndefined();
});
it('attaches shell output to the most recent running execute tool', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc-bash-1', 'completed', 1, {
toolName: 'bash',
toolKind: 'execute',
rawOutput: 'first output',
}),
toolBlock('t2', 'tc-bash-2', 'running', 2, {
toolName: 'bash',
toolKind: 'execute',
}),
shellBlock('sh1', 'second output', 3),
]);
expect(messages).toHaveLength(1);
const tools =
messages[0].role === 'tool_group' ? messages[0].tools : undefined;
expect(tools?.[0]).toMatchObject({
callId: 'tc-bash-1',
rawOutput: 'first output',
});
expect(tools?.[1]).toMatchObject({
callId: 'tc-bash-2',
rawOutput: 'second output',
});
});
it('falls back to the last execute tool when every status is terminal', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc-bash', 'completed', 1, {
toolName: 'bash',
toolKind: 'execute',
}),
toolBlock('t2', 'tc-read', 'completed', 2, { toolName: 'Read' }),
shellBlock('sh1', 'replayed output', 3),
]);
expect(messages).toHaveLength(1);
const tools =
messages[0].role === 'tool_group' ? messages[0].tools : undefined;
expect(tools?.[0]).toMatchObject({
callId: 'tc-bash',
rawOutput: 'replayed output',
});
expect(tools?.[1]?.rawOutput).toBeUndefined();
});
it('merges thought across interleaved tool blocks but splits content after tools', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('t1', 'thought', 'thinking part 1', 1),
toolBlock('tool1', 'tc1', 'completed', 2, { toolName: 'Read' }),
textBlock('t2', 'thought', ' thinking part 2', 3),
textBlock('a1', 'assistant', 'response part 1', 4),
toolBlock('tool2', 'tc2', 'completed', 5, { toolName: 'Edit' }),
textBlock('a2', 'assistant', ' response part 2', 6),
]);
expect(messages).toMatchObject([
{ role: 'thinking', content: 'thinking part 1' },
{ role: 'tool_group', tools: [{ callId: 'tc1' }] },
{ role: 'thinking', content: ' thinking part 2' },
{ role: 'assistant', content: 'response part 1' },
{ role: 'tool_group', tools: [{ callId: 'tc2' }] },
{ role: 'assistant', content: ' response part 2' },
]);
});
it('keeps thought blocks after tool groups in transcript order', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('t1', 'thought', 'first thought', 1),
toolBlock('tool1', 'tc1', 'completed', 2, { toolName: 'Read' }),
textBlock('t2', 'thought', 'second thought', 3),
toolBlock('tool2', 'tc2', 'completed', 4, { toolName: 'ListFiles' }),
textBlock('t3', 'thought', 'third thought', 5),
toolBlock('tool3', 'tc3', 'completed', 6, { toolName: 'Glob' }),
textBlock('t4', 'thought', 'final thought', 7),
]);
expect(messages).toMatchObject([
{ role: 'thinking', content: 'first thought' },
{ role: 'tool_group', tools: [{ callId: 'tc1' }] },
{ role: 'thinking', content: 'second thought' },
{ role: 'tool_group', tools: [{ callId: 'tc2' }] },
{ role: 'thinking', content: 'third thought' },
{ role: 'tool_group', tools: [{ callId: 'tc3' }] },
{ role: 'thinking', content: 'final thought' },
]);
});
it('preserves title, args, and rawOutput on completed parallel agents', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-1', 'call-1', 'completed', 10, {
title: 'Agent: 分析项目核心原理',
toolName: 'agent',
rawInput: {
description: '分析项目核心原理',
prompt: '请分析...',
subagent_type: 'general-purpose',
run_in_background: true,
},
rawOutput: {
type: 'task_execution',
subagentName: 'general-purpose',
taskDescription: '分析项目核心原理',
status: 'background',
},
updatedAt: 11,
}),
toolBlock('agent-2', 'call-2', 'completed', 10, {
title: 'Agent: 分析项目使用场景和性能',
toolName: 'agent',
rawInput: {
description: '分析项目使用场景和性能',
prompt: '请分析...',
subagent_type: 'general-purpose',
run_in_background: true,
},
rawOutput: {
type: 'task_execution',
subagentName: 'general-purpose',
taskDescription: '分析项目使用场景和性能',
status: 'background',
},
updatedAt: 11,
}),
]);
expect(messages).toHaveLength(2);
const tool1 =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
const tool2 =
messages[1].role === 'tool_group' ? messages[1].tools[0] : undefined;
expect(tool1).toMatchObject({
callId: 'call-1',
title: 'Agent: 分析项目核心原理',
args: { description: '分析项目核心原理' },
rawOutput: { taskDescription: '分析项目核心原理' },
});
expect(tool2).toMatchObject({
callId: 'call-2',
title: 'Agent: 分析项目使用场景和性能',
args: { description: '分析项目使用场景和性能' },
rawOutput: { taskDescription: '分析项目使用场景和性能' },
});
});
it('keeps cancelled subagent tools complete with cancellation reason', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: Analyze TanStack Virtual architecture',
toolName: 'agent',
rawInput: {
description: 'Analyze TanStack Virtual architecture',
subagent_type: 'general-purpose',
},
}),
toolBlock('agent-end', 'agent-1', 'cancelled', 20, {
title: 'Agent: Analyze TanStack Virtual architecture',
toolName: 'agent',
details: 'Agent was cancelled by user',
updatedAt: 30,
}),
]);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
role: 'tool_group',
tools: [
{
callId: 'agent-1',
status: 'completed',
endTime: 30,
rawOutput: {
status: 'cancelled',
reason: 'Agent was cancelled by user',
text: 'Agent was cancelled by user',
},
},
],
});
});
it('does not merge assistant text across user message boundaries', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'first reply', 1),
toolBlock('tool1', 'tc1', 'completed', 2, { toolName: 'Read' }),
textBlock('u1', 'user', 'follow up', 3),
textBlock('a2', 'assistant', 'second reply', 4),
]);
const assistantMsgs = messages.filter((m) => m.role === 'assistant');
expect(assistantMsgs).toHaveLength(2);
expect(assistantMsgs[0]).toMatchObject({ content: 'first reply' });
expect(assistantMsgs[1]).toMatchObject({ content: 'second reply' });
});
it('skips non-agent permission blocks', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'let me run that', 1),
{
id: 'perm-1',
kind: 'permission',
requestId: 'req-1',
sessionId: 'sess-1',
title: 'Allow Bash?',
options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }],
toolCall: { toolCallId: 'tc-1', rawInput: { command: 'ls' } },
preview: { kind: 'generic' as const },
clientReceivedAt: 2,
createdAt: 2,
updatedAt: 2,
},
textBlock('a2', 'assistant', ' done', 3),
]);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
role: 'assistant',
content: 'let me run that done',
});
});
it('does not synthesize a generic tool card for AskUserQuestion permissions', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'I will ask for student info.', 1),
{
id: 'perm-ask-1',
kind: 'permission',
requestId: 'req-ask-1',
sessionId: 'sess-1',
title: 'Ask user 4 questions',
options: [{ optionId: 'proceed_once', label: 'Submit', raw: {} }],
toolCall: {
toolCallId: 'ask-call-1',
kind: 'think',
status: 'pending',
title: 'Ask user 4 questions',
rawInput: {
questions: [
{
header: '姓名',
question: '请输入学生的姓名:',
options: [{ label: '张三', description: '示例姓名' }],
},
],
},
},
preview: { kind: 'generic' as const },
clientReceivedAt: 2,
createdAt: 2,
updatedAt: 2,
},
]);
expect(messages).toHaveLength(1);
expect(messages[0]).toMatchObject({
role: 'assistant',
content: 'I will ask for student info.',
});
});
it('uses AskUserQuestion permission title for the completed tool block', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'perm-ask-1',
kind: 'permission',
requestId: 'req-ask-1',
sessionId: 'sess-1',
title: 'Ask user 4 questions',
options: [{ optionId: 'proceed_once', label: 'Submit', raw: {} }],
toolCall: {
toolCallId: 'ask-call-1',
kind: 'think',
status: 'pending',
title: 'Ask user 4 questions',
rawInput: {
questions: [
{
header: '姓名',
question: '请输入学生的姓名:',
options: [{ label: '张三', description: '示例姓名' }],
},
],
},
},
preview: { kind: 'generic' as const },
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 2,
resolved: 'selected:proceed_once',
},
toolBlock('ask-tool-1', 'ask-call-1', 'completed', 3, {
toolName: 'ask_user_question',
title: 'ask_user_question',
rawOutput: 'User has provided the following answers:\n\n**姓名**: 张三',
}),
]);
expect(messages).toMatchObject([
{
role: 'tool_group',
tools: [
{
callId: 'ask-call-1',
title: 'Ask user 4 questions',
args: {
questions: [
{
header: '姓名',
question: '请输入学生的姓名:',
options: [{ label: '张三', description: '示例姓名' }],
},
],
},
rawOutput:
'User has provided the following answers:\n\n**姓名**: 张三',
},
],
},
]);
});
it('uses text content as raw output when a tool has no raw output', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('ask-failed', 'ask-call-failed', 'failed', 1, {
toolName: 'ask_user_question',
title: 'ask_user_question',
content: [
{
type: 'content',
content: {
type: 'text',
text: 'Question 1: "options" must contain between 2 and 4 options.',
},
},
] as DaemonToolTranscriptBlock['content'],
}),
]);
expect(messages).toMatchObject([
{
role: 'tool_group',
tools: [
{
callId: 'ask-call-failed',
status: 'failed',
rawOutput:
'Question 1: "options" must contain between 2 and 4 options.',
},
],
},
]);
});
it('prefers AskUserQuestion failure text over echoed input', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('ask-failed', 'ask-call-failed', 'failed', 1, {
toolName: 'ask_user_question',
title: 'ask_user_question',
rawInput: {
questions: [
{
header: '学生姓名',
question: '请输入学生姓名',
options: [
{
label: '输入姓名',
description: '请输入学生的完整姓名',
},
],
},
],
},
rawOutput: JSON.stringify({
questions: [
{
header: '学生姓名',
question: '请输入学生姓名',
},
],
}),
details: JSON.stringify({
questions: [
{
header: '学生姓名',
question: '请输入学生姓名',
},
],
}),
content: [
{
type: 'content',
content: {
type: 'text',
text: 'Question 1: "options" must contain between 2 and 4 options.',
},
},
] as DaemonToolTranscriptBlock['content'],
}),
]);
expect(messages).toMatchObject([
{
role: 'tool_group',
tools: [
{
callId: 'ask-call-failed',
status: 'failed',
rawOutput:
'Question 1: "options" must contain between 2 and 4 options.',
},
],
},
]);
});
it('does not render pending permission blocks as tool messages', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'perm-agent-1',
kind: 'permission',
requestId: 'req-agent-1',
sessionId: 'sess-1',
title: '查询阿里云官网活动',
options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'agent-call-1',
kind: 'other',
status: 'pending',
title: '查询阿里云官网活动',
rawInput: {
description: '查询阿里云官网活动',
prompt: '请查询阿里云官网当前的活动信息。',
subagent_type: 'general-purpose',
},
},
preview: { kind: 'generic' as const },
clientReceivedAt: 2,
createdAt: 2,
updatedAt: 2,
},
{
id: 'perm-agent-2',
kind: 'permission',
requestId: 'req-agent-2',
sessionId: 'sess-1',
title: '查询百度云官网活动',
options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'agent-call-2',
kind: 'other',
status: 'pending',
title: '查询百度云官网活动',
rawInput: {
description: '查询百度云官网活动',
prompt: '请查询百度云官网当前的活动信息。',
subagent_type: 'general-purpose',
},
},
preview: { kind: 'generic' as const },
clientReceivedAt: 2,
createdAt: 2,
updatedAt: 2,
},
]);
expect(messages).toEqual([]);
});
it('merges the real subagent tool update after a permission block', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'perm-agent-1',
kind: 'permission',
requestId: 'req-agent-1',
sessionId: 'sess-1',
title: 'Agent A',
options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'agent-1',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
},
preview: { kind: 'generic' as const },
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
toolBlock('t1', 'agent-1', 'in_progress', 2, {
toolName: 'Agent',
title: 'Agent A running',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
}),
toolBlock('t2', 'sub-a1', 'completed', 3, {
toolName: 'web_fetch',
parentToolCallId: 'agent-1',
rawOutput: 'data-a',
}),
]);
expect(messages).toHaveLength(1);
const agent =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agent).toMatchObject({
callId: 'agent-1',
toolName: 'Agent',
title: 'Agent A running',
status: 'in_progress',
});
expect(agent?.subTools).toHaveLength(1);
expect(agent?.subTools?.[0]).toMatchObject({ callId: 'sub-a1' });
});
it('does not duplicate a permission block when the synthetic agent tool already exists', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'agent-1', 'confirming', 1, {
toolName: 'Agent',
title: 'Agent A',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
}),
{
id: 'perm-agent-1',
kind: 'permission',
requestId: 'req-agent-1',
sessionId: 'sess-1',
title: 'Agent A',
options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'agent-1',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
},
preview: { kind: 'generic' as const },
clientReceivedAt: 2,
createdAt: 2,
updatedAt: 2,
},
toolBlock('t2', 'sub-a1', 'completed', 3, {
toolName: 'web_fetch',
parentToolCallId: 'agent-1',
rawOutput: 'data-a',
}),
]);
expect(messages).toHaveLength(1);
const agent =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agent).toMatchObject({
callId: 'agent-1',
toolName: 'Agent',
title: 'Agent A',
status: 'pending',
});
expect(agent?.subTools).toHaveLength(1);
expect(agent?.subTools?.[0]).toMatchObject({ callId: 'sub-a1' });
});
it('keeps existing subagent status after approved permission resolves', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'agent-1', 'in_progress', 1, {
toolName: 'Agent',
title: 'Agent A',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
}),
{
id: 'perm-agent-1',
kind: 'permission',
requestId: 'req-agent-1',
sessionId: 'sess-1',
title: 'Agent A',
options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'agent-1',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
},
preview: { kind: 'generic' as const },
resolved: 'selected:allow',
clientReceivedAt: 2,
createdAt: 2,
updatedAt: 2,
},
]);
const agent =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agent).toMatchObject({
callId: 'agent-1',
status: 'in_progress',
});
});
it('uses resolved approved permission as agent placeholder until final update', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'perm-1',
kind: 'permission',
requestId: 'req-1',
sessionId: 'sess-1',
title: 'Agent A',
options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'agent-1',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
},
preview: { kind: 'generic' as const },
resolved: 'selected:allow',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 2,
},
textBlock('thought-1', 'thought', 'inside agent', 3, false, {
parentToolCallId: 'agent-1',
}),
toolBlock('child-1', 'child-tool-1', 'completed', 4, {
toolName: 'web_fetch',
parentToolCallId: 'agent-1',
rawOutput: 'child output',
}),
toolBlock('t1', 'agent-1', 'completed', 5, {
toolName: 'agent',
title: 'Agent A',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
rawOutput: { type: 'task_execution', result: 'done' },
updatedAt: 6,
}),
]);
expect(messages).toHaveLength(1);
const agent =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agent?.callId).toBe('agent-1');
expect(agent?.status).toBe('completed');
expect(agent?.subContent).toBe('inside agent');
expect(agent?.subTools?.[0]).toMatchObject({
callId: 'child-tool-1',
toolName: 'web_fetch',
});
});
it('does not put approved background permission placeholders on the active subagent stack', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'perm-1',
kind: 'permission',
requestId: 'req-1',
sessionId: 'sess-1',
title: 'Agent A',
options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'agent-1',
rawInput: {
subagent_type: 'Explore',
prompt: 'a',
run_in_background: true,
},
},
preview: { kind: 'generic' as const },
resolved: 'selected:allow',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 2,
},
textBlock('thought-1', 'thought', 'background agent is running', 3),
textBlock('assistant-1', 'assistant', 'main turn continues', 4),
]);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: 'tool_group',
tools: [
{
callId: 'agent-1',
status: 'pending',
},
],
});
const agent =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agent?.subContent).toBeUndefined();
expect(messages[1]).toMatchObject({
role: 'thinking',
content: 'background agent is running',
});
expect(messages[2]).toMatchObject({
role: 'assistant',
content: 'main turn continues',
});
});
it('keeps approved regular tool permission visible until tool update arrives', () => {
const pendingMessages = transcriptBlocksToDaemonMessages([
{
id: 'perm-1',
kind: 'permission',
requestId: 'req-1',
sessionId: 'sess-1',
title:
'Fetching content from https://www.aliyun.com/activity (format: markdown)',
options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'webfetch-1',
kind: 'fetch',
rawInput: {
url: 'https://www.aliyun.com/activity',
prompt: 'list activities',
format: 'markdown',
},
},
preview: { kind: 'generic' as const },
resolved: 'selected:proceed_once',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 2,
},
]);
expect(pendingMessages).toHaveLength(1);
const pendingTool =
pendingMessages[0].role === 'tool_group'
? pendingMessages[0].tools[0]
: undefined;
expect(pendingTool).toMatchObject({
callId: 'webfetch-1',
toolName: 'web_fetch',
status: 'in_progress',
kind: 'fetch',
});
const messages = transcriptBlocksToDaemonMessages([
{
id: 'perm-1',
kind: 'permission',
requestId: 'req-1',
sessionId: 'sess-1',
title:
'Fetching content from https://www.aliyun.com/activity (format: markdown)',
options: [{ optionId: 'proceed_once', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'webfetch-1',
kind: 'fetch',
rawInput: {
url: 'https://www.aliyun.com/activity',
prompt: 'list activities',
format: 'markdown',
},
},
preview: { kind: 'generic' as const },
resolved: 'selected:proceed_once',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 2,
},
toolBlock('t1', 'webfetch-1', 'completed', 3, {
toolName: 'web_fetch',
toolKind: 'fetch',
title: 'WebFetch result',
rawOutput: 'Content processed successfully.',
updatedAt: 4,
}),
]);
expect(messages).toHaveLength(1);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool).toMatchObject({
callId: 'webfetch-1',
toolName: 'web_fetch',
status: 'completed',
rawOutput: 'Content processed successfully.',
});
});
it('treats compound allow permission resolutions as approved', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'perm-1',
kind: 'permission',
requestId: 'req-1',
sessionId: 'sess-1',
title: 'Fetch page',
options: [{ optionId: 'allow_once', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'webfetch-1',
kind: 'fetch',
rawInput: {
url: 'https://example.com',
prompt: 'read',
},
},
preview: { kind: 'generic' as const },
resolved: 'selected:allow_once',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 2,
},
]);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool).toMatchObject({
callId: 'webfetch-1',
status: 'in_progress',
});
});
it('renders rejected permission as completed agent card', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('t1', 'thought', 'first thinking', 1),
{
id: 'perm-1',
kind: 'permission',
requestId: 'req-1',
sessionId: 'sess-1',
title: '查询阿里云官网活动',
options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }],
toolCall: {
toolCallId: 'agent-1',
rawInput: { subagent_type: 'general-purpose', prompt: 'query' },
},
preview: { kind: 'generic' as const },
resolved: 'selected:cancel',
clientReceivedAt: 2,
createdAt: 2,
updatedAt: 3,
},
textBlock('t2', 'thought', 'second thinking', 4),
textBlock('a1', 'assistant', 'response text', 5),
]);
expect(messages).toHaveLength(4);
// First: thinking message
expect(messages[0]).toMatchObject({
role: 'thinking',
content: 'first thinking',
});
// Second: completed agent card (not pending)
const agentGroup = messages[1];
expect(agentGroup.role).toBe('tool_group');
if (agentGroup.role === 'tool_group') {
expect(agentGroup.tools[0]).toMatchObject({
callId: 'agent-1',
status: 'failed',
endTime: 3,
});
}
// Third: second thinking, fourth: response (not absorbed into agent subContent)
expect(messages[2]).toMatchObject({
role: 'thinking',
content: 'second thinking',
});
expect(messages[3]).toMatchObject({
role: 'assistant',
content: 'response text',
});
});
it('does not treat negative approval words as approved permissions', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'perm-1',
kind: 'permission',
requestId: 'req-1',
sessionId: 'sess-1',
title: 'Agent A',
options: [{ optionId: 'opt-1', label: 'Deny', raw: {} }],
toolCall: {
toolCallId: 'agent-1',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
},
preview: { kind: 'generic' as const },
resolved: 'selected:not_approved',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 2,
},
]);
const agent =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agent).toMatchObject({
callId: 'agent-1',
status: 'failed',
endTime: 2,
});
});
it('splits thought across permission boundary when content already exists', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('t1', 'thought', 'first thinking', 1),
textBlock('a1', 'assistant', 'let me try this', 2),
{
id: 'perm-1',
kind: 'permission',
requestId: 'req-1',
sessionId: 'sess-1',
title: 'Allow Skill?',
options: [{ optionId: 'opt-1', label: 'Allow', raw: {} }],
toolCall: { toolCallId: 'tc-1', rawInput: { skill: 'codegraph' } },
preview: { kind: 'generic' as const },
clientReceivedAt: 3,
createdAt: 3,
updatedAt: 3,
},
textBlock('t2', 'thought', 'second thinking', 4),
textBlock('a2', 'assistant', 'different approach', 5),
]);
expect(messages).toMatchObject([
{ role: 'thinking', content: 'first thinking' },
{ role: 'assistant', content: 'let me try this' },
{ role: 'thinking', content: 'second thinking' },
{ role: 'assistant', content: 'different approach' },
]);
});
it('converts error blocks to system error messages', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'err-1',
kind: 'error' as const,
text: 'Connection lost',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
]);
expect(messages).toEqual([
{
id: 'err-1',
role: 'system',
content: 'Connection lost',
variant: 'error',
retryable: false,
timestamp: 1,
},
]);
});
it('marks turn_error blocks as retryable system errors', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'err-1',
kind: 'error' as const,
source: 'turn_error' as const,
text: 'Request failed',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
]);
expect(messages).toEqual([
{
id: 'err-1',
role: 'system',
content: 'Request failed',
variant: 'error',
retryable: true,
source: 'turn_error',
timestamp: 1,
},
]);
});
it('renders model stream interruption errors from structured errorKind labels', () => {
const messages = transcriptBlocksToDaemonMessages(
[
{
id: 'err-1',
kind: 'error' as const,
source: 'turn_error' as const,
errorKind: 'model_stream_interrupted' as const,
text: 'terminated',
data: { diagnosticId: 'abc' },
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
],
{
labels: {
modelStreamInterrupted: 'Localized stream interruption.',
},
},
);
expect(messages).toEqual([
{
id: 'err-1',
role: 'system',
content: 'Localized stream interruption.',
variant: 'error',
retryable: true,
source: 'turn_error',
data: {
diagnosticId: 'abc',
errorKind: 'model_stream_interrupted',
},
timestamp: 1,
},
]);
});
it('upgrades older daemon terminated turn errors to localized text', () => {
const messages = transcriptBlocksToDaemonMessages(
[
{
id: 'err-1',
kind: 'error' as const,
source: 'turn_error' as const,
text: 'terminated',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
],
{
labels: {
modelStreamInterrupted: 'Localized stream interruption.',
},
},
);
expect(messages[0]).toMatchObject({
content: 'Localized stream interruption.',
retryable: true,
source: 'turn_error',
});
});
it('does not add data solely for structured errorKind labels', () => {
const messages = transcriptBlocksToDaemonMessages(
[
{
id: 'err-1',
kind: 'error' as const,
source: 'turn_error' as const,
errorKind: 'model_stream_interrupted' as const,
text: 'terminated',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
],
{
labels: {
modelStreamInterrupted: 'Localized stream interruption.',
},
},
);
expect(messages[0]).toMatchObject({
content: 'Localized stream interruption.',
retryable: true,
source: 'turn_error',
});
expect(messages[0]).not.toHaveProperty('data');
});
it('preserves non-object error data when adding structured errorKind', () => {
const messages = transcriptBlocksToDaemonMessages(
[
{
id: 'err-1',
kind: 'error' as const,
source: 'turn_error' as const,
errorKind: 'model_stream_interrupted' as const,
text: 'terminated',
data: 'diagnostic text',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
],
{
labels: {
modelStreamInterrupted: 'Localized stream interruption.',
},
},
);
expect(messages[0]).toMatchObject({
data: {
value: 'diagnostic text',
errorKind: 'model_stream_interrupted',
},
});
});
it('converts debug blocks to system messages with info variant', () => {
const messages = transcriptBlocksToDaemonMessages([
{
id: 'dbg-1',
kind: 'debug' as const,
text: 'Session initialized',
clientReceivedAt: 1,
createdAt: 1,
updatedAt: 1,
},
]);
expect(messages).toEqual([
{
id: 'dbg-1',
role: 'system',
content: 'Session initialized',
variant: 'info',
timestamp: 1,
},
]);
});
it('creates thinking message for thought-only blocks', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('t1', 'thought', 'let me think about this', 1),
]);
expect(messages).toEqual([
{
id: 't1',
role: 'thinking',
content: 'let me think about this',
isStreaming: false,
timestamp: 1,
},
]);
});
it('handles nested subagent via parentToolCallId', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('parent-start', 'parent-1', 'in_progress', 10, {
title: 'Agent: outer',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
{
id: 'child-start',
kind: 'tool' as const,
toolCallId: 'child-1',
title: 'Agent: inner',
status: 'in_progress',
toolName: 'agent',
preview: { kind: 'generic' as const },
rawInput: { subagent_type: 'Explore' },
parentToolCallId: 'parent-1',
clientReceivedAt: 20,
createdAt: 20,
updatedAt: 20,
},
toolBlock('read-inner', 'read-1', 'completed', 30, {
toolName: 'Read',
title: 'Read file.ts',
parentToolCallId: 'child-1',
}),
{
id: 'child-end',
kind: 'tool' as const,
toolCallId: 'child-1',
title: 'Agent: inner',
status: 'completed',
toolName: 'agent',
preview: { kind: 'generic' as const },
rawOutput: { type: 'task_execution' },
parentToolCallId: 'parent-1',
clientReceivedAt: 40,
createdAt: 40,
updatedAt: 40,
},
toolBlock('parent-end', 'parent-1', 'completed', 50, {
title: 'Agent: outer',
toolName: 'agent',
rawOutput: { type: 'task_execution' },
}),
]);
expect(messages).toHaveLength(1);
const parentTool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(parentTool?.callId).toBe('parent-1');
expect(parentTool?.subTools).toHaveLength(1);
const childTool = parentTool?.subTools?.[0];
expect(childTool?.callId).toBe('child-1');
expect(childTool?.subTools).toHaveLength(1);
expect(childTool?.subTools?.[0]?.callId).toBe('read-1');
});
it('user message resets assistant merge state', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'first', 1),
textBlock('u1', 'user', 'question', 2),
textBlock('a2', 'assistant', 'second', 3),
]);
expect(messages).toEqual([
{
id: 'a1',
role: 'assistant',
content: 'first',
isStreaming: false,
timestamp: 1,
},
{ id: 'u1', role: 'user', content: 'question', timestamp: 2 },
{
id: 'a2',
role: 'assistant',
content: 'second',
isStreaming: false,
timestamp: 3,
},
]);
});
it('keeps thought and assistant as separate messages without tools', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('t1', 'thought', 'analyzing...', 1),
textBlock('a1', 'assistant', 'here is my answer', 2),
]);
expect(messages).toEqual([
{
id: 't1',
role: 'thinking',
content: 'analyzing...',
isStreaming: false,
timestamp: 1,
},
{
id: 'a1',
role: 'assistant',
content: 'here is my answer',
isStreaming: false,
timestamp: 2,
},
]);
});
it('returns empty array for empty blocks', () => {
const messages = transcriptBlocksToDaemonMessages([]);
expect(messages).toEqual([]);
});
it('keeps status blocks in the main transcript while subagent is active', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: analyze',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
statusBlock('st1', 'Working on it...', 20),
toolBlock('agent-end', 'agent-1', 'completed', 30, {
title: 'Agent: analyze',
toolName: 'agent',
rawOutput: { type: 'task_execution' },
}),
]);
expect(messages).toHaveLength(2);
const agentTool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agentTool?.subContent).toBeUndefined();
expect(messages[1]).toMatchObject({
id: 'st1',
role: 'system',
content: 'Working on it...',
variant: 'info',
});
});
it('renders prompt cancellation blocks as system messages', () => {
const messages = transcriptBlocksToDaemonMessages([
promptCancelledBlock('cancel-1', 20),
]);
expect(messages).toEqual([
{
id: 'cancel-1',
role: 'system',
content: 'Request cancelled.',
variant: 'info',
source: 'prompt_cancelled',
timestamp: 20,
},
]);
});
it('renders localized prompt cancellation messages', () => {
const messages = transcriptBlocksToDaemonMessages(
[promptCancelledBlock('cancel-1', 20)],
{ labels: { promptCancelled: '请求已取消。' } },
);
expect(messages).toEqual([
{
id: 'cancel-1',
role: 'system',
content: '请求已取消。',
variant: 'info',
source: 'prompt_cancelled',
timestamp: 20,
},
]);
});
it('keeps assistant text after a completed agent in the main transcript', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-1', 'call-1', 'completed', 10, {
title: 'Agent: quick',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
rawOutput: { type: 'task_execution' },
updatedAt: 20,
}),
textBlock('a1', 'assistant', 'after agent', 25),
]);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
role: 'tool_group',
tools: [{ callId: 'call-1', status: 'completed' }],
});
expect(messages[1]).toMatchObject({
role: 'assistant',
content: 'after agent',
});
});
it('creates sibling tool groups for top-level subagents', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-1-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: first',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-2-start', 'agent-2', 'in_progress', 20, {
title: 'Agent: second',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
]);
expect(messages).toHaveLength(2);
expect(messages[0]).toMatchObject({
role: 'tool_group',
tools: [{ callId: 'agent-1' }],
});
expect(messages[1]).toMatchObject({
role: 'tool_group',
tools: [{ callId: 'agent-2' }],
});
});
it('cancelled agent without details returns rawOutput as-is', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: working',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-end', 'agent-1', 'cancelled', 20, {
title: 'Agent: working',
toolName: 'agent',
updatedAt: 25,
}),
]);
expect(messages).toHaveLength(1);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool?.status).toBe('completed');
expect(tool?.endTime).toBe(25);
expect(tool?.rawOutput).toBeUndefined();
});
it('cancelled agent with existing rawOutput object spreads and adds status/reason', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: analyze',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-end', 'agent-1', 'cancelled', 20, {
title: 'Agent: analyze',
toolName: 'agent',
rawOutput: { type: 'task_execution', subagentName: 'general-purpose' },
details: 'User cancelled',
updatedAt: 30,
}),
]);
expect(messages).toHaveLength(1);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool?.rawOutput).toEqual({
type: 'task_execution',
subagentName: 'general-purpose',
status: 'cancelled',
reason: 'User cancelled',
});
});
it('inferToolKind uses toolKind over toolName when both present', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, {
toolName: 'bash',
toolKind: 'search',
}),
]);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool?.kind).toBe('search');
});
it('inferToolKind uses exact match for grep and glob', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, { toolName: 'grep' }),
toolBlock('t2', 'tc2', 'completed', 2, { toolName: 'glob' }),
toolBlock('t3', 'tc3', 'completed', 3, { toolName: 'mygrep' }),
]);
expect(messages).toHaveLength(1);
const tools =
messages[0].role === 'tool_group' ? messages[0].tools : undefined;
expect(tools?.[0]?.kind).toBe('search');
expect(tools?.[1]?.kind).toBe('search');
expect(tools?.[2]?.kind).toBeUndefined();
});
it('getToolRawOutput fallback returns rawOutput ?? details for non-cancelled', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, {
toolName: 'Read',
rawOutput: undefined,
details: 'some detail info',
}),
]);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool?.rawOutput).toBe('some detail info');
});
it('does not use content text as generic raw output', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, {
toolName: 'custom_tool',
content: [
{
type: 'content',
content: { type: 'text', text: 'rendered elsewhere' },
},
] as DaemonToolTranscriptBlock['content'],
}),
]);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool?.rawOutput).toBeUndefined();
expect(tool?.content).toEqual([
{
type: 'content',
content: { type: 'text', text: 'rendered elsewhere' },
},
]);
});
it('mergeToolCall updates fields from completion block', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: work',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
toolBlock('agent-end', 'agent-1', 'completed', 20, {
title: 'Agent: work done',
toolName: 'agent',
rawOutput: { type: 'task_execution', totalTokens: 500 },
updatedAt: 25,
}),
]);
expect(messages).toHaveLength(1);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool?.title).toBe('Agent: work done');
expect(tool?.status).toBe('completed');
expect(tool?.endTime).toBe(25);
expect(tool?.rawOutput).toEqual({
type: 'task_execution',
totalTokens: 500,
});
});
it('does not merge assistant across error block', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'analyzing...', 1),
{
id: 'err-1',
kind: 'error' as const,
text: 'Connection lost',
clientReceivedAt: 2,
createdAt: 2,
updatedAt: 2,
},
textBlock('a2', 'assistant', 'recovered', 3),
]);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: 'assistant',
content: 'analyzing...',
});
expect(messages[1]).toMatchObject({
role: 'system',
content: 'Connection lost',
variant: 'error',
});
expect(messages[2]).toMatchObject({
role: 'assistant',
content: 'recovered',
});
});
it('does not merge assistant across plan status block', () => {
const plan = {
sessionUpdate: 'plan',
entries: [{ content: 'Step 1', status: 'in_progress' }],
};
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'let me plan', 1),
statusBlock('plan-1', `plan: ${JSON.stringify(plan)}`, 2),
textBlock('a2', 'assistant', 'executing', 3),
]);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: 'assistant',
content: 'let me plan',
});
expect(messages[1]).toMatchObject({ role: 'plan' });
expect(messages[2]).toMatchObject({
role: 'assistant',
content: 'executing',
});
});
it('does not merge assistant across system status block', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'working...', 1),
statusBlock('st1', 'Model switched to opus', 2),
textBlock('a2', 'assistant', 'continuing', 3),
]);
expect(messages).toHaveLength(3);
expect(messages[0]).toMatchObject({
role: 'assistant',
content: 'working...',
});
expect(messages[1]).toMatchObject({
role: 'system',
content: 'Model switched to opus',
});
expect(messages[2]).toMatchObject({
role: 'assistant',
content: 'continuing',
});
});
it('does not merge assistant across standalone shell block', () => {
const messages = transcriptBlocksToDaemonMessages([
textBlock('a1', 'assistant', 'before shell', 1),
textBlock('u1', 'user', '! ls', 2),
shellBlock('sh1', 'file.ts\n', 3),
textBlock('a2', 'assistant', 'after shell', 4),
]);
const assistantMsgs = messages.filter((m) => m.role === 'assistant');
expect(assistantMsgs).toHaveLength(2);
expect(assistantMsgs[0]).toMatchObject({ content: 'before shell' });
expect(assistantMsgs[1]).toMatchObject({ content: 'after shell' });
});
it('failed subAgent keeps parented content and allows assistant to return to main thread', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-start', 'agent-1', 'in_progress', 10, {
title: 'Agent: investigate',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
}),
textBlock('sub-a1', 'assistant', 'Looking into it', 15, false, {
parentToolCallId: 'agent-1',
}),
toolBlock('agent-end', 'agent-1', 'failed', 20, {
title: 'Agent: investigate',
toolName: 'agent',
rawOutput: { error: 'Context limit exceeded' },
updatedAt: 25,
}),
textBlock('a1', 'assistant', 'The agent failed, let me try directly', 30),
]);
expect(messages).toHaveLength(2);
const agentTool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agentTool?.status).toBe('failed');
expect(agentTool?.endTime).toBe(25);
expect(agentTool?.subContent).toBe('Looking into it');
expect(messages[1]).toMatchObject({
role: 'assistant',
content: 'The agent failed, let me try directly',
});
});
it('status mapping covers running, pending, failed, and canceled (alternate spelling)', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'running', 1, { toolName: 'Bash' }),
toolBlock('t2', 'tc2', 'pending', 2, { toolName: 'Read' }),
toolBlock('t3', 'tc3', 'failed', 3, { toolName: 'Edit' }),
toolBlock('t4', 'tc4', 'canceled', 4, {
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
updatedAt: 5,
}),
]);
// tc1-tc3 are adjacent regular tools and share one group; the trailing
// subagent call stays in its own single-tool group.
expect(messages).toHaveLength(2);
const tools =
messages[0].role === 'tool_group' ? messages[0].tools : undefined;
const tool4 =
messages[1].role === 'tool_group' ? messages[1].tools[0] : undefined;
expect(tools?.[0]?.status).toBe('in_progress');
expect(tools?.[1]?.status).toBe('pending');
expect(tools?.[2]?.status).toBe('failed');
expect(tool4?.status).toBe('completed');
expect(tool4?.endTime).toBe(5);
});
it('parented blocks after completed subAgent remain nested', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-1', 'call-1', 'completed', 10, {
title: 'Agent: fast',
toolName: 'agent',
rawInput: { subagent_type: 'general-purpose' },
rawOutput: { type: 'task_execution' },
updatedAt: 30,
}),
textBlock('a1', 'assistant', 'inside agent', 25, false, {
parentToolCallId: 'call-1',
}),
textBlock('a2', 'assistant', 'after agent', 35),
]);
expect(messages).toHaveLength(2);
const agentTool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agentTool?.subContent).toBe('inside agent');
expect(messages[1]).toMatchObject({
role: 'assistant',
content: 'after agent',
});
});
it('identifies subAgent by toolName task and rawOutput.type task_execution', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('task-start', 'task-1', 'in_progress', 10, {
title: 'Task: build',
toolName: 'task',
}),
textBlock('sub-text', 'assistant', 'Building...', 15, false, {
parentToolCallId: 'task-1',
}),
toolBlock('sub-tool', 'sub-tc', 'completed', 20, {
toolName: 'Bash',
parentToolCallId: 'task-1',
}),
toolBlock('task-end', 'task-1', 'completed', 30, {
title: 'Task: build',
toolName: 'task',
rawOutput: { type: 'task_execution', result: 'Build passed' },
updatedAt: 35,
}),
]);
expect(messages).toHaveLength(1);
const taskTool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(taskTool?.toolName).toBe('task');
expect(taskTool?.status).toBe('completed');
expect(taskTool?.subContent).toBe('Building...');
expect(taskTool?.subTools).toHaveLength(1);
expect(taskTool?.subTools?.[0].callId).toBe('sub-tc');
const taskExecByRawOutput = transcriptBlocksToDaemonMessages([
toolBlock('exec-start', 'exec-1', 'in_progress', 10, {
title: 'Custom Tool',
toolName: 'my_runner',
rawOutput: { type: 'task_execution' },
}),
textBlock('sub-text2', 'assistant', 'Running...', 15, false, {
parentToolCallId: 'exec-1',
}),
toolBlock('exec-end', 'exec-1', 'completed', 20, {
toolName: 'my_runner',
rawOutput: { type: 'task_execution', result: 'done' },
updatedAt: 25,
}),
]);
expect(taskExecByRawOutput).toHaveLength(1);
const execTool =
taskExecByRawOutput[0].role === 'tool_group'
? taskExecByRawOutput[0].tools[0]
: undefined;
expect(execTool?.subContent).toBe('Running...');
});
it('passes content but not locations or preview to DaemonMessageToolCall', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'tc1', 'completed', 1, {
toolName: 'Edit',
content: [
{
type: 'diff',
path: '/path/file.ts',
oldText: 'old',
newText: 'new',
},
] as DaemonToolTranscriptBlock['content'],
locations: [{ file: '/path/file.ts', line: 10 }],
preview: { kind: 'generic', summary: 'Edit file.ts' },
}),
]);
const tool =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(tool).toBeDefined();
expect(tool?.callId).toBe('tc1');
expect(tool?.content).toEqual([
{
type: 'diff',
path: '/path/file.ts',
oldText: 'old',
newText: 'new',
},
]);
expect('locations' in tool!).toBe(false);
expect('preview' in tool!).toBe(false);
});
it('Agent tool with confirming status (from permission_request) nests sub-tools via parentToolCallId', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'agent-call-1', 'confirming', 100, {
toolName: 'Agent',
title: 'Query website',
rawInput: {
description: 'Query website',
prompt: 'fetch data',
subagent_type: 'Explore',
},
}),
toolBlock('t2', 'sub-tool-1', 'completed', 200, {
toolName: 'web_fetch',
title: 'Fetch page',
parentToolCallId: 'agent-call-1',
rawOutput: 'page content',
}),
toolBlock('t3', 'sub-tool-2', 'in_progress', 300, {
toolName: 'web_fetch',
title: 'Fetch another page',
parentToolCallId: 'agent-call-1',
}),
]);
expect(messages).toHaveLength(1);
expect(messages[0].role).toBe('tool_group');
const agent =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agent).toBeDefined();
expect(agent!.callId).toBe('agent-call-1');
expect(agent!.toolName).toBe('Agent');
expect(agent!.status).toBe('pending');
expect(agent!.subTools).toHaveLength(2);
expect(agent!.subTools![0].callId).toBe('sub-tool-1');
expect(agent!.subTools![1].callId).toBe('sub-tool-2');
});
it('two parallel Agents from permission_request each nest their own sub-tools', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'agent-1', 'confirming', 100, {
toolName: 'Agent',
title: 'Agent A',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
}),
toolBlock('t2', 'agent-2', 'confirming', 100, {
toolName: 'Agent',
title: 'Agent B',
rawInput: { subagent_type: 'Explore', prompt: 'b' },
}),
toolBlock('t3', 'sub-a1', 'completed', 200, {
toolName: 'web_fetch',
parentToolCallId: 'agent-1',
rawOutput: 'data-a',
}),
toolBlock('t4', 'sub-b1', 'completed', 200, {
toolName: 'web_fetch',
parentToolCallId: 'agent-2',
rawOutput: 'data-b',
}),
]);
expect(messages).toHaveLength(2);
const agentA =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
const agentB =
messages[1].role === 'tool_group' ? messages[1].tools[0] : undefined;
expect(agentA!.callId).toBe('agent-1');
expect(agentA!.subTools).toHaveLength(1);
expect(agentA!.subTools![0].callId).toBe('sub-a1');
expect(agentB!.callId).toBe('agent-2');
expect(agentB!.subTools).toHaveLength(1);
expect(agentB!.subTools![0].callId).toBe('sub-b1');
});
it('nests parented text inside the matching parallel subagent', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'agent-1', 'running', 100, {
toolName: 'Agent',
title: 'Agent A',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
}),
toolBlock('t2', 'agent-2', 'running', 100, {
toolName: 'Agent',
title: 'Agent B',
rawInput: { subagent_type: 'Security', prompt: 'b' },
}),
textBlock('th1', 'thought', 'A thinking. ', 150, false, {
parentToolCallId: 'agent-1',
}),
textBlock('msg1', 'assistant', 'B answer. ', 160, false, {
parentToolCallId: 'agent-2',
}),
]);
expect(messages).toHaveLength(2);
const agentA =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
const agentB =
messages[1].role === 'tool_group' ? messages[1].tools[0] : undefined;
expect(agentA?.subContent).toBe('A thinking. ');
expect(agentB?.subContent).toBe('B answer. ');
expect(messages.find((message) => message.role === 'assistant')).toBe(
undefined,
);
});
it('nests parented shell tools inside the matching failed subagent', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('agent-failed', 'agent-1', 'failed', 100, {
toolName: 'agent',
title: 'Agent: review PR',
rawOutput: { type: 'task_execution', result: 'failed' },
}),
toolBlock('shell-child', 'shell-1', 'completed', 110, {
toolName: 'run_shell_command',
title: 'Shell: git diff',
rawInput: {
command: 'git diff FETCH_HEAD...HEAD',
description: '获取 PR diff',
},
rawOutput: 'No such file or directory',
parentToolCallId: 'agent-1',
}),
]);
expect(messages).toHaveLength(1);
const agent =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agent?.callId).toBe('agent-1');
expect(agent?.subTools).toHaveLength(1);
expect(agent?.subTools?.[0]).toMatchObject({
callId: 'shell-1',
toolName: 'run_shell_command',
rawOutput: 'No such file or directory',
});
});
it('nests parented replay text after a completed subagent update', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'agent-1', 'completed', 100, {
toolName: 'Agent',
title: 'Agent A',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
rawOutput: { type: 'task_execution', result: 'done' },
updatedAt: 120,
}),
textBlock('th1', 'thought', 'late parented thought', 150, false, {
parentToolCallId: 'agent-1',
}),
]);
expect(messages).toHaveLength(1);
const agent =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
expect(agent?.subContent).toBe('late parented thought');
});
it('keeps unparented thought text in the main transcript', () => {
const messages = transcriptBlocksToDaemonMessages([
toolBlock('t1', 'agent-1', 'running', 100, {
toolName: 'Agent',
title: 'Agent A',
rawInput: { subagent_type: 'Explore', prompt: 'a' },
}),
toolBlock('t2', 'agent-2', 'running', 100, {
toolName: 'Agent',
title: 'Agent B',
rawInput: { subagent_type: 'Security', prompt: 'b' },
}),
textBlock('th1', 'thought', 'I need to review the PR diff.', 150),
]);
expect(messages).toHaveLength(3);
const thinking = messages.find((message) => message.role === 'thinking');
expect(thinking).toMatchObject({
content: 'I need to review the PR diff.',
});
const agentA =
messages[0].role === 'tool_group' ? messages[0].tools[0] : undefined;
const agentB =
messages[1].role === 'tool_group' ? messages[1].tools[0] : undefined;
expect(agentA!.subContent).toBeUndefined();
expect(agentB!.subContent).toBeUndefined();
});
});