mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-24 00:14:12 +00:00
* 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>
3153 lines
94 KiB
TypeScript
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();
|
|
});
|
|
});
|