mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
* feat(web-shell): collapsible TodoWrite history with status diff Inline todo_write updates rendered as a generic, non-collapsible tool row crammed in with surrounding tool calls — the todo-specific renderer was effectively dead code because the detector matched the literal "todowrite" while the wire name is "todo_write" (kind "think"). - Detect the todo tool by name (todo_write / todowrite) instead of relying on the unrelated tool kind, reviving the rich rendering and also populating the floating todo panel that was empty on the daemon path. - Render each update as its own standalone group, collapsed by default to the per-snapshot diff (just-completed / just-started items) or the current step, expanding to the full checklist; the header shows completed/total. - Use consistent status glyphs (●/◐/○) in both collapsed and expanded views via a shared TodoView component used by ToolGroup and PlanMessage. * fix(web-shell): scope todo diff per task identity; address review - [Critical] computeTodoTimeline keyed its running state on todo.id alone, but ids aren't globally unique (ACP assigns positional ids, models renumber per plan), so a later plan diffed against a previous plan's stale terminal status and silently dropped events in the collapsed view. Key on id+content so distinct tasks stay separate; add an id-reuse regression test. - Stabilize the TodoTimelineContext value with a signature-cached Map so streaming ticks that don't touch a todo snapshot no longer re-render every todo/plan row. - Drop the unused completed/total from TodoSnapshotDiff (consumers compute the count locally — single source of truth). - Fall back to the raw result summary when a todo_write payload is unparseable. - Add PlanMessage rendering tests and extractTodosFromToolCall coverage; remove stale "step time" comments left after dropping per-step timing. * test(web-shell): document todo-diff keying limits; cover signature Follow-up to review on the id+content keying in computeTodoTimeline: - Document the two rare trade-offs in the todoStateKey doc (a mid-task reword on a stable id, and unrelated plans reusing both id and content), both degrading to "the collapsed diff omits one event" while the expanded list stays correct. - Pin behavior with tests: an item carried over and completed in a later turn (which id+content handles but a user-turn reset would drop), plus the two documented gaps. - Add todoTimelineSignature tests: stable across non-todo edits; changes on any id/status/content change. * refactor(web-shell): isolate plan context read; expand todo test coverage Address follow-up review: - Extract PlanEventSummary as the sole TodoTimelineContext consumer, mirroring ToolGroup's TodoToolBody so the memo-shielded PlanMessage stays stable when the timeline Map reference changes. - Note the spurious-`started` axis of the reword trade-off in the todoStateKey doc (a reword can drop a completion or emit a stray start). - Add direct isTodoWriteToolName tests (incl. the `todowrite` ACP variant) and a todoTimelineSignature empty-transcript test.
1046 lines
32 KiB
TypeScript
1046 lines
32 KiB
TypeScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import type {
|
|
DaemonTranscriptBlock,
|
|
DaemonTextTranscriptBlock,
|
|
DaemonToolTranscriptBlock,
|
|
DaemonShellTranscriptBlock,
|
|
DaemonStatusTranscriptBlock,
|
|
DaemonUserShellTranscriptBlock,
|
|
} from '@qwen-code/sdk/daemon';
|
|
import type {
|
|
DaemonMessage,
|
|
DaemonMessageToolCall,
|
|
DaemonMessageToolCallContent,
|
|
DaemonMessageToolCallStatus,
|
|
DaemonMessageToolKind,
|
|
DaemonMessageTodoItem,
|
|
DaemonUserMessage,
|
|
} from './messageTypes.js';
|
|
import { isTodoWriteToolName } from '../utils/todos.js';
|
|
|
|
interface PermissionToolInfo {
|
|
title?: string;
|
|
args?: Record<string, unknown>;
|
|
}
|
|
|
|
type DaemonPermissionTranscriptBlock = Extract<
|
|
DaemonTranscriptBlock,
|
|
{ kind: 'permission' }
|
|
>;
|
|
|
|
type ExtendedDaemonStatusTranscriptBlock = DaemonStatusTranscriptBlock & {
|
|
source?: string;
|
|
data?: unknown;
|
|
};
|
|
|
|
interface TranscriptMessageLabels {
|
|
promptCancelled?: string;
|
|
}
|
|
|
|
interface TranscriptMessageOptions {
|
|
labels?: TranscriptMessageLabels;
|
|
}
|
|
|
|
function parseDaemonTodoItemsFromEntries(
|
|
entries: readonly unknown[],
|
|
): DaemonMessageTodoItem[] | undefined {
|
|
const todos = entries.flatMap((entry, index): DaemonMessageTodoItem[] => {
|
|
const item = getRecord(entry);
|
|
const content = getString(item, 'content');
|
|
if (!content) return [];
|
|
const id = getString(item, 'id') ?? `plan-${index}`;
|
|
return [
|
|
{
|
|
id,
|
|
content,
|
|
status: getTodoStatus(getString(item, 'status')),
|
|
...(() => {
|
|
const priority = getTodoPriority(getString(item, 'priority'));
|
|
return priority ? { priority } : {};
|
|
})(),
|
|
},
|
|
];
|
|
});
|
|
return todos.length > 0 ? todos : undefined;
|
|
}
|
|
|
|
export function transcriptBlocksToDaemonMessages(
|
|
blocks: readonly DaemonTranscriptBlock[],
|
|
options: TranscriptMessageOptions = {},
|
|
): DaemonMessage[] {
|
|
const messages: DaemonMessage[] = [];
|
|
const promptCancelledText =
|
|
options.labels?.promptCancelled ?? 'Request cancelled.';
|
|
// Replay can contain thousands of blocks. Keep tool calls indexed by callId
|
|
// so later tool updates, parented children, and permission placeholders
|
|
// merge in O(1)
|
|
// instead of scanning the rendered message list for every block.
|
|
// Subagent-owned assistant/thought/tool blocks are expected to carry
|
|
// parentToolCallId; unparented blocks are rendered as top-level transcript.
|
|
const toolsByCallId = new Map<string, DaemonMessageToolCall>();
|
|
const permissionToolInfoByCallId = new Map<string, PermissionToolInfo>();
|
|
let currentAssistantIdx: number | null = null;
|
|
// Tool cards are standalone transcript turns. Once a tool is emitted,
|
|
// the next top-level assistant/thought block must start a fresh assistant
|
|
// message instead of being appended to text that appeared before the tool.
|
|
let needsNewContentMessage = false;
|
|
|
|
for (let i = 0; i < blocks.length; i++) {
|
|
const block = blocks[i];
|
|
// Wall-clock of this block, surfaced as a hover tooltip on the rendered
|
|
// message. Prefer the daemon-authoritative stamp so every client agrees;
|
|
// fall back to the local receive time when the daemon left it unset.
|
|
const blockTime = block.serverTimestamp ?? block.clientReceivedAt;
|
|
|
|
switch (block.kind) {
|
|
case 'user': {
|
|
currentAssistantIdx = null;
|
|
needsNewContentMessage = false;
|
|
const textBlock = block as DaemonTextTranscriptBlock;
|
|
const msg: DaemonUserMessage = {
|
|
id: block.id,
|
|
role: 'user',
|
|
content: textBlock.text,
|
|
timestamp: blockTime,
|
|
};
|
|
// Attach images if present
|
|
if (textBlock.images && textBlock.images.length > 0) {
|
|
msg.images = textBlock.images.map((img) => ({
|
|
data: img.data,
|
|
mimeType: img.mimeType || 'image/*',
|
|
}));
|
|
}
|
|
messages.push(msg);
|
|
break;
|
|
}
|
|
|
|
case 'assistant': {
|
|
const textBlock = block as DaemonTextTranscriptBlock;
|
|
|
|
const parentSubAgent = textBlock.parentToolCallId
|
|
? toolsByCallId.get(textBlock.parentToolCallId)
|
|
: undefined;
|
|
if (parentSubAgent) {
|
|
appendSubContent(parentSubAgent, textBlock.text);
|
|
break;
|
|
}
|
|
|
|
const insightSegments = splitInsightSegments(textBlock.text);
|
|
if (insightSegments) {
|
|
let lastProgress: ParsedInsight | null = null;
|
|
let hasTerminal = false;
|
|
let readyCount = 0;
|
|
let errorCount = 0;
|
|
for (const seg of insightSegments) {
|
|
if (seg.kind === 'insight') {
|
|
if (seg.data.type === 'insight_progress') {
|
|
lastProgress = seg.data;
|
|
} else if (seg.data.type === 'insight_ready') {
|
|
hasTerminal = true;
|
|
messages.push({
|
|
id: `${block.id}-ir-${readyCount++}`,
|
|
role: 'insight_ready',
|
|
path: seg.data.path,
|
|
timestamp: blockTime,
|
|
});
|
|
} else if (seg.data.type === 'insight_error') {
|
|
hasTerminal = true;
|
|
messages.push({
|
|
id: `${block.id}-ie-${errorCount++}`,
|
|
role: 'insight_error',
|
|
error: seg.data.error,
|
|
timestamp: blockTime,
|
|
});
|
|
}
|
|
} else {
|
|
messages.push({
|
|
id: `${block.id}-t-${messages.length}`,
|
|
role: 'assistant',
|
|
content: seg.text,
|
|
timestamp: blockTime,
|
|
});
|
|
currentAssistantIdx = messages.length - 1;
|
|
}
|
|
}
|
|
if (lastProgress && !hasTerminal) {
|
|
messages.push({
|
|
id: `${block.id}-ip`,
|
|
role: 'insight_progress',
|
|
stage: lastProgress.stage,
|
|
progress: lastProgress.progress,
|
|
detail: lastProgress.detail,
|
|
timestamp: blockTime,
|
|
});
|
|
}
|
|
needsNewContentMessage = true;
|
|
break;
|
|
}
|
|
|
|
const target =
|
|
currentAssistantIdx !== null
|
|
? messages[currentAssistantIdx]
|
|
: undefined;
|
|
if (target && target.role === 'assistant' && !needsNewContentMessage) {
|
|
messages[currentAssistantIdx!] = {
|
|
...target,
|
|
content: target.content + textBlock.text,
|
|
isStreaming: textBlock.streaming,
|
|
};
|
|
needsNewContentMessage = false;
|
|
} else {
|
|
messages.push({
|
|
id: block.id,
|
|
role: 'assistant',
|
|
content: textBlock.text,
|
|
isStreaming: textBlock.streaming,
|
|
timestamp: blockTime,
|
|
});
|
|
currentAssistantIdx = messages.length - 1;
|
|
needsNewContentMessage = false;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'thought': {
|
|
const textBlock = block as DaemonTextTranscriptBlock;
|
|
const parentSubAgent = textBlock.parentToolCallId
|
|
? toolsByCallId.get(textBlock.parentToolCallId)
|
|
: undefined;
|
|
if (parentSubAgent) {
|
|
appendSubContent(parentSubAgent, textBlock.text);
|
|
break;
|
|
}
|
|
const target =
|
|
currentAssistantIdx !== null
|
|
? messages[currentAssistantIdx]
|
|
: undefined;
|
|
if (
|
|
target &&
|
|
target.role === 'assistant' &&
|
|
!needsNewContentMessage &&
|
|
!target.content
|
|
) {
|
|
messages[currentAssistantIdx!] = {
|
|
...target,
|
|
thinking: (target.thinking || '') + textBlock.text,
|
|
isStreaming: textBlock.streaming,
|
|
};
|
|
needsNewContentMessage = false;
|
|
} else {
|
|
messages.push({
|
|
id: block.id,
|
|
role: 'assistant',
|
|
content: '',
|
|
thinking: textBlock.text,
|
|
isStreaming: textBlock.streaming,
|
|
timestamp: blockTime,
|
|
});
|
|
currentAssistantIdx = messages.length - 1;
|
|
needsNewContentMessage = false;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'tool': {
|
|
const toolBlock = block as DaemonToolTranscriptBlock;
|
|
const toolCall = daemonToolBlockToToolCall(toolBlock);
|
|
const permissionInfo = permissionToolInfoByCallId.get(toolCall.callId);
|
|
if (permissionInfo?.title) {
|
|
toolCall.title = permissionInfo.title;
|
|
}
|
|
if (!toolCall.args && permissionInfo?.args) {
|
|
toolCall.args = permissionInfo.args;
|
|
}
|
|
const parentSubAgent = toolCall.parentToolCallId
|
|
? toolsByCallId.get(toolCall.parentToolCallId)
|
|
: undefined;
|
|
const existingTool = toolsByCallId.get(toolCall.callId);
|
|
|
|
if (existingTool) {
|
|
mergeToolCall(existingTool, toolCall);
|
|
break;
|
|
}
|
|
|
|
if (parentSubAgent) {
|
|
appendSubTool(parentSubAgent, toolCall);
|
|
toolsByCallId.set(toolCall.callId, toolCall);
|
|
break;
|
|
}
|
|
|
|
appendToolCallMessage(messages, block.id, toolCall, blockTime);
|
|
toolsByCallId.set(toolCall.callId, toolCall);
|
|
needsNewContentMessage = true;
|
|
break;
|
|
}
|
|
|
|
case 'shell': {
|
|
const shellBlock = block as DaemonShellTranscriptBlock;
|
|
const lastMsg = messages[messages.length - 1];
|
|
if (lastMsg && lastMsg.role === 'tool_group') {
|
|
const targetIdx = findShellOutputTargetIndex(lastMsg.tools);
|
|
const targetTool = lastMsg.tools[targetIdx];
|
|
if (targetTool) {
|
|
const previousOutput =
|
|
typeof targetTool.rawOutput === 'string'
|
|
? targetTool.rawOutput
|
|
: '';
|
|
const nextTool = {
|
|
...targetTool,
|
|
rawOutput: previousOutput + shellBlock.text,
|
|
};
|
|
messages[messages.length - 1] = {
|
|
...lastMsg,
|
|
tools: [
|
|
...lastMsg.tools.slice(0, targetIdx),
|
|
nextTool,
|
|
...lastMsg.tools.slice(targetIdx + 1),
|
|
],
|
|
};
|
|
if (toolsByCallId.get(targetTool.callId) === targetTool) {
|
|
toolsByCallId.set(targetTool.callId, nextTool);
|
|
}
|
|
}
|
|
} else {
|
|
messages.push({
|
|
id: block.id,
|
|
role: 'tool_group',
|
|
tools: [
|
|
{
|
|
callId: block.id,
|
|
toolName: 'shell',
|
|
status: 'completed',
|
|
kind: 'execute',
|
|
rawOutput: shellBlock.text,
|
|
},
|
|
],
|
|
timestamp: blockTime,
|
|
});
|
|
needsNewContentMessage = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
case 'user_shell': {
|
|
const shellBlock = block as DaemonUserShellTranscriptBlock;
|
|
messages.push({
|
|
id: block.id,
|
|
role: 'user_shell',
|
|
command: shellBlock.command,
|
|
output: shellBlock.text,
|
|
...(shellBlock.cwd ? { cwd: shellBlock.cwd } : {}),
|
|
timestamp: blockTime,
|
|
});
|
|
needsNewContentMessage = true;
|
|
break;
|
|
}
|
|
|
|
case 'permission': {
|
|
const permBlock = block as DaemonPermissionTranscriptBlock;
|
|
rememberPermissionToolInfo(permBlock, permissionToolInfoByCallId);
|
|
const permissionToolCall = permissionBlockToToolCall(permBlock);
|
|
if (!permissionToolCall) break;
|
|
const isSubAgentPermission = isSubAgentToolCall(permissionToolCall);
|
|
// Pending permissions are rendered by the dedicated permission UI.
|
|
if (!permBlock.resolved) {
|
|
break;
|
|
}
|
|
|
|
const existingPermission = toolsByCallId.get(permissionToolCall.callId);
|
|
if (existingPermission) {
|
|
const previousStatus = existingPermission.status;
|
|
permissionToolCall.toolName = existingPermission.toolName;
|
|
if (permBlock.resolved) {
|
|
if (isApprovedPermissionResolution(permBlock.resolved)) {
|
|
permissionToolCall.status = isSubAgentPermission
|
|
? permissionToolCall.status
|
|
: 'in_progress';
|
|
} else {
|
|
permissionToolCall.status = 'failed';
|
|
permissionToolCall.endTime = permBlock.updatedAt;
|
|
}
|
|
}
|
|
mergeToolCall(existingPermission, permissionToolCall);
|
|
if (
|
|
permBlock.resolved &&
|
|
isSubAgentPermission &&
|
|
isApprovedPermissionResolution(permBlock.resolved)
|
|
) {
|
|
existingPermission.status = previousStatus;
|
|
}
|
|
break;
|
|
}
|
|
|
|
if (permBlock.resolved) {
|
|
// Resolved permission with no matching real tool block:
|
|
// - Approved: the daemon may still skip the initial agent tool_call
|
|
// or a regular tool_call. Keep a pending placeholder visible so
|
|
// later parented child events and the final update can merge by
|
|
// callId.
|
|
// - Rejected: render a finished card. Later assistant content stays
|
|
// in the main conversation unless it has an explicit parent.
|
|
if (isApprovedPermissionResolution(permBlock.resolved)) {
|
|
if (!isSubAgentPermission) {
|
|
permissionToolCall.status = 'in_progress';
|
|
}
|
|
appendToolCallMessage(
|
|
messages,
|
|
block.id,
|
|
permissionToolCall,
|
|
blockTime,
|
|
);
|
|
toolsByCallId.set(permissionToolCall.callId, permissionToolCall);
|
|
needsNewContentMessage = true;
|
|
} else {
|
|
permissionToolCall.status = 'failed';
|
|
permissionToolCall.endTime = permBlock.updatedAt;
|
|
appendToolCallMessage(
|
|
messages,
|
|
block.id,
|
|
permissionToolCall,
|
|
blockTime,
|
|
);
|
|
toolsByCallId.set(permissionToolCall.callId, permissionToolCall);
|
|
needsNewContentMessage = true;
|
|
}
|
|
break;
|
|
}
|
|
|
|
break;
|
|
}
|
|
|
|
case 'status':
|
|
case 'debug': {
|
|
const statusBlock = block as ExtendedDaemonStatusTranscriptBlock;
|
|
const text = statusBlock.text;
|
|
const todos = parsePlanTodos(text);
|
|
if (todos) {
|
|
messages.push({
|
|
id: block.id,
|
|
role: 'plan',
|
|
todos,
|
|
timestamp: blockTime,
|
|
});
|
|
needsNewContentMessage = true;
|
|
break;
|
|
}
|
|
// Status/debug blocks are daemon-level diagnostics, not tool output.
|
|
// Keeping them in the main transcript avoids hiding global messages
|
|
// such as SSE lag warnings, malformed-event debug lines, or shell
|
|
// result notices inside whichever subAgent happened to be active.
|
|
messages.push({
|
|
id: block.id,
|
|
role: 'system',
|
|
content: text,
|
|
variant: 'info',
|
|
timestamp: blockTime,
|
|
...(statusBlock.source ? { source: statusBlock.source } : {}),
|
|
...(statusBlock.data !== undefined ? { data: statusBlock.data } : {}),
|
|
});
|
|
needsNewContentMessage = true;
|
|
break;
|
|
}
|
|
|
|
case 'error': {
|
|
const errorBlock = block as ExtendedDaemonStatusTranscriptBlock;
|
|
messages.push({
|
|
id: block.id,
|
|
role: 'system',
|
|
content: errorBlock.text,
|
|
variant: 'error',
|
|
retryable: errorBlock.source === 'turn_error',
|
|
timestamp: blockTime,
|
|
...(errorBlock.source ? { source: errorBlock.source } : {}),
|
|
...(errorBlock.data !== undefined ? { data: errorBlock.data } : {}),
|
|
});
|
|
needsNewContentMessage = true;
|
|
break;
|
|
}
|
|
|
|
case 'prompt_cancelled':
|
|
messages.push({
|
|
id: block.id,
|
|
role: 'system',
|
|
content: promptCancelledText,
|
|
variant: 'info',
|
|
timestamp: blockTime,
|
|
});
|
|
needsNewContentMessage = true;
|
|
break;
|
|
|
|
default:
|
|
break;
|
|
}
|
|
}
|
|
|
|
return messages;
|
|
}
|
|
|
|
function appendSubTool(
|
|
parent: DaemonMessageToolCall,
|
|
toolCall: DaemonMessageToolCall,
|
|
): void {
|
|
parent.subTools ||= [];
|
|
parent.subTools.push(toolCall);
|
|
}
|
|
|
|
function appendSubContent(parent: DaemonMessageToolCall, text: string): void {
|
|
parent.subContent = (parent.subContent || '') + text;
|
|
}
|
|
|
|
function appendToolCallMessage(
|
|
messages: DaemonMessage[],
|
|
blockId: string,
|
|
toolCall: DaemonMessageToolCall,
|
|
timestamp?: number,
|
|
): void {
|
|
// Native CLI groups every tool call of one scheduler batch into a single
|
|
// bordered tool_group (mapToDisplay in useReactToolScheduler). The daemon
|
|
// transcript carries no batch marker, so the replay-stable equivalent is
|
|
// adjacency: a tool block arriving while a tool_group is still the latest
|
|
// visible message joins that group instead of opening a new box.
|
|
//
|
|
// Sub-agent calls stay in their own single-tool groups — MessageList's
|
|
// groupParallelAgents relies on that shape to render consecutive agent
|
|
// launches as ParallelAgentsGroup.
|
|
//
|
|
// Synthetic raw-shell groups (pushed by the `shell` block fallback) use the
|
|
// bare block id without the `tg-` prefix and never absorb real tool calls.
|
|
// Sub-agent calls and todo_write updates each stand alone in their own group
|
|
// box instead of being crammed in with the tools around them: an agent renders
|
|
// an expandable panel, and a todo update is its own collapsible checklist.
|
|
const isStandalone = (t: DaemonMessageToolCall) =>
|
|
isSubAgentToolCall(t) || isTodoWriteToolName(t.toolName);
|
|
const last = messages[messages.length - 1];
|
|
if (
|
|
last &&
|
|
last.role === 'tool_group' &&
|
|
last.id.startsWith('tg-') &&
|
|
!isStandalone(toolCall) &&
|
|
!last.tools.some(isStandalone)
|
|
) {
|
|
last.tools.push(toolCall);
|
|
return;
|
|
}
|
|
messages.push({
|
|
id: `tg-${blockId}`,
|
|
role: 'tool_group',
|
|
tools: [toolCall],
|
|
timestamp,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Pick which tool in a group should receive a raw shell output chunk.
|
|
*
|
|
* Shell transcript blocks carry no toolCallId, so attachment is heuristic.
|
|
* Single-tool groups (the only shape before adjacent-merge) keep the old
|
|
* "last tool" behavior. In merged groups, prefer the most recent `execute`
|
|
* tool that is still running — the scheduler executes one tool at a time, so
|
|
* that is the tool producing output. On replay every status is already
|
|
* terminal; fall back to the most recent `execute` tool, then to the last
|
|
* tool so groups without kind metadata behave exactly as before.
|
|
*/
|
|
function findShellOutputTargetIndex(
|
|
tools: readonly DaemonMessageToolCall[],
|
|
): number {
|
|
for (let i = tools.length - 1; i >= 0; i--) {
|
|
const tool = tools[i];
|
|
if (tool.kind === 'execute' && tool.status === 'in_progress') {
|
|
return i;
|
|
}
|
|
}
|
|
for (let i = tools.length - 1; i >= 0; i--) {
|
|
if (tools[i].kind === 'execute') {
|
|
return i;
|
|
}
|
|
}
|
|
return tools.length - 1;
|
|
}
|
|
|
|
function mergeToolCall(
|
|
target: DaemonMessageToolCall,
|
|
source: DaemonMessageToolCall,
|
|
): void {
|
|
target.status = source.status ?? target.status;
|
|
target.title = source.title ?? target.title;
|
|
target.toolName = source.toolName ?? target.toolName;
|
|
target.kind = source.kind ?? target.kind;
|
|
target.content = source.content ?? target.content;
|
|
target.endTime = source.endTime ?? target.endTime;
|
|
target.rawOutput = source.rawOutput ?? target.rawOutput;
|
|
target.args = source.args ?? target.args;
|
|
target.locations = source.locations ?? target.locations;
|
|
}
|
|
|
|
function isSubAgentToolCall(tool: DaemonMessageToolCall): boolean {
|
|
const name = tool.toolName.toLowerCase();
|
|
if (name === 'agent' || name === 'task') return true;
|
|
if (tool.subTools || tool.subContent) return true;
|
|
if (isTaskExecutionRaw(tool.rawOutput)) return true;
|
|
return Boolean(tool.args?.subagent_type);
|
|
}
|
|
|
|
function isTaskExecutionRaw(raw: unknown): boolean {
|
|
return (
|
|
!!raw &&
|
|
typeof raw === 'object' &&
|
|
(raw as Record<string, unknown>).type === 'task_execution'
|
|
);
|
|
}
|
|
|
|
function parsePlanTodos(text: string): DaemonMessageTodoItem[] | undefined {
|
|
const rawJson = text.startsWith('plan: ')
|
|
? text.slice('plan: '.length)
|
|
: undefined;
|
|
if (!rawJson) {
|
|
return undefined;
|
|
}
|
|
|
|
try {
|
|
const parsed = JSON.parse(rawJson) as unknown;
|
|
const record = getRecord(parsed);
|
|
if (
|
|
record?.['sessionUpdate'] !== 'plan' ||
|
|
!Array.isArray(record['entries'])
|
|
) {
|
|
return undefined;
|
|
}
|
|
return parseDaemonTodoItemsFromEntries(record['entries']);
|
|
} catch {
|
|
return undefined;
|
|
}
|
|
}
|
|
|
|
function getRecord(value: unknown): Record<string, unknown> | undefined {
|
|
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
return undefined;
|
|
}
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function getString(
|
|
record: Record<string, unknown> | undefined,
|
|
key: string,
|
|
): string | undefined {
|
|
const value = record?.[key];
|
|
return typeof value === 'string' && value.length > 0 ? value : undefined;
|
|
}
|
|
|
|
function getTodoStatus(
|
|
value: string | undefined,
|
|
): DaemonMessageTodoItem['status'] {
|
|
return value === 'completed' || value === 'in_progress' || value === 'pending'
|
|
? value
|
|
: 'pending';
|
|
}
|
|
|
|
function getTodoPriority(
|
|
value: string | undefined,
|
|
): DaemonMessageTodoItem['priority'] | undefined {
|
|
return value === 'high' || value === 'medium' || value === 'low'
|
|
? value
|
|
: undefined;
|
|
}
|
|
|
|
function daemonToolBlockToToolCall(
|
|
block: DaemonToolTranscriptBlock,
|
|
): DaemonMessageToolCall {
|
|
const rawOutput = getToolRawOutput(block);
|
|
const isBackgroundAgent = isBackgroundAgentBlock(block, rawOutput);
|
|
const content = normalizeToolContent(block.content);
|
|
const statusMap: Record<string, DaemonMessageToolCallStatus> = {
|
|
running: 'in_progress',
|
|
pending: 'pending',
|
|
confirming: 'pending',
|
|
background: 'pending',
|
|
completed: 'completed',
|
|
failed: 'failed',
|
|
cancelled: 'completed',
|
|
canceled: 'completed',
|
|
in_progress: 'in_progress',
|
|
};
|
|
const isComplete =
|
|
block.status === 'completed' ||
|
|
block.status === 'failed' ||
|
|
block.status === 'cancelled' ||
|
|
block.status === 'canceled';
|
|
|
|
return {
|
|
callId: block.toolCallId,
|
|
toolName: block.toolName || 'unknown',
|
|
title: block.title,
|
|
status:
|
|
(isBackgroundAgent ? 'pending' : statusMap[block.status]) ||
|
|
(block.status as DaemonMessageToolCallStatus) ||
|
|
'in_progress',
|
|
kind: inferToolKind(block.toolName, block.toolKind),
|
|
rawOutput,
|
|
args: block.rawInput as Record<string, unknown> | undefined,
|
|
parentToolCallId: block.parentToolCallId,
|
|
startTime: block.createdAt,
|
|
endTime: isComplete && !isBackgroundAgent ? block.updatedAt : undefined,
|
|
...(content ? { content } : {}),
|
|
};
|
|
}
|
|
|
|
function permissionBlockToToolCall(
|
|
block: DaemonPermissionTranscriptBlock,
|
|
): DaemonMessageToolCall | undefined {
|
|
const toolCall = getRecord(block.toolCall);
|
|
if (!toolCall) return undefined;
|
|
|
|
const rawInput = getToolCallRawInput(toolCall);
|
|
// AskUserQuestion permissions are rendered by the shell as a dedicated
|
|
// interactive form from the pending permission itself. Emitting a synthetic
|
|
// generic tool card here would show the same permission twice, especially
|
|
// when older daemon events only expose it as kind: "think".
|
|
if (Array.isArray(rawInput?.['questions'])) return undefined;
|
|
|
|
const meta = getRecord(toolCall['_meta']);
|
|
const kind = getString(toolCall, 'kind');
|
|
const toolName =
|
|
getString(meta, 'toolName') ??
|
|
getString(toolCall, 'toolName') ??
|
|
getString(toolCall, 'name') ??
|
|
(rawInput?.['subagent_type'] ? 'agent' : undefined) ??
|
|
(kind === 'fetch' ? 'web_fetch' : kind);
|
|
const toolCallId =
|
|
getString(toolCall, 'toolCallId') ?? getString(toolCall, 'id');
|
|
if (!toolCallId || !toolName) return undefined;
|
|
|
|
const syntheticTool: DaemonMessageToolCall = {
|
|
callId: toolCallId,
|
|
toolName,
|
|
title: getString(toolCall, 'title') ?? block.title,
|
|
status: 'pending',
|
|
kind: inferToolKind(toolName, kind),
|
|
args: rawInput,
|
|
startTime: block.createdAt,
|
|
};
|
|
|
|
return syntheticTool;
|
|
}
|
|
|
|
function rememberPermissionToolInfo(
|
|
block: DaemonPermissionTranscriptBlock,
|
|
infoByCallId: Map<string, PermissionToolInfo>,
|
|
): void {
|
|
const toolCall = getRecord(block.toolCall);
|
|
const toolCallId =
|
|
getString(toolCall, 'toolCallId') ?? getString(toolCall, 'id');
|
|
if (!toolCallId) return;
|
|
const title = getString(toolCall, 'title') ?? block.title;
|
|
const rawInput = toolCall ? getToolCallRawInput(toolCall) : undefined;
|
|
if (!Array.isArray(rawInput?.['questions'])) return;
|
|
infoByCallId.set(toolCallId, {
|
|
...(title ? { title } : {}),
|
|
...(rawInput ? { args: rawInput } : {}),
|
|
});
|
|
}
|
|
|
|
function isApprovedPermissionResolution(resolved: string): boolean {
|
|
const [primary = '', detail = ''] = resolved.toLowerCase().split(':', 2);
|
|
if (isApprovalToken(primary)) return true;
|
|
if (primary !== 'selected') return false;
|
|
return isApprovalToken(detail.trim());
|
|
}
|
|
|
|
function isApprovalToken(token: string): boolean {
|
|
return (
|
|
token === 'allow' ||
|
|
token === 'allowed' ||
|
|
token === 'approve' ||
|
|
token === 'approved' ||
|
|
token === 'accept' ||
|
|
token === 'accepted' ||
|
|
token === 'confirm' ||
|
|
token === 'confirmed' ||
|
|
token === 'proceed' ||
|
|
token === 'proceed_once' ||
|
|
token === 'proceed_always_project' ||
|
|
token === 'proceed_always_user' ||
|
|
token === 'allow_once' ||
|
|
token === 'allow_always' ||
|
|
token === 'success' ||
|
|
token === 'succeeded'
|
|
);
|
|
}
|
|
|
|
function getToolCallRawInput(
|
|
toolCall: Record<string, unknown>,
|
|
): Record<string, unknown> | undefined {
|
|
return (
|
|
getRecord(toolCall['rawInput']) ??
|
|
getRecord(toolCall['input']) ??
|
|
getRecord(toolCall['args'])
|
|
);
|
|
}
|
|
|
|
function isBackgroundAgentBlock(
|
|
block: DaemonToolTranscriptBlock,
|
|
rawOutput: unknown,
|
|
): boolean {
|
|
const name = block.toolName?.toLowerCase();
|
|
if (name !== 'agent' && name !== 'task') return false;
|
|
const raw = getRecord(rawOutput);
|
|
return raw?.['status'] === 'background';
|
|
}
|
|
|
|
function getToolRawOutput(block: DaemonToolTranscriptBlock): unknown {
|
|
if (isAskUserQuestionBlock(block) && block.status === 'failed') {
|
|
return getToolContentText(block) ?? block.details ?? block.rawOutput;
|
|
}
|
|
|
|
if (!isCancelledStatus(block.status) || !block.details) {
|
|
return block.rawOutput ?? block.details;
|
|
}
|
|
|
|
if (
|
|
block.rawOutput &&
|
|
typeof block.rawOutput === 'object' &&
|
|
!Array.isArray(block.rawOutput)
|
|
) {
|
|
return {
|
|
...(block.rawOutput as Record<string, unknown>),
|
|
status: block.status,
|
|
reason: block.details,
|
|
};
|
|
}
|
|
|
|
return {
|
|
status: block.status,
|
|
reason: block.details,
|
|
text:
|
|
typeof block.rawOutput === 'string' && block.rawOutput
|
|
? block.rawOutput
|
|
: block.details,
|
|
};
|
|
}
|
|
|
|
function normalizeToolContent(
|
|
value: unknown,
|
|
): DaemonMessageToolCallContent[] | undefined {
|
|
if (!Array.isArray(value)) return undefined;
|
|
|
|
const content = value.flatMap((entry): DaemonMessageToolCallContent[] => {
|
|
const item = getRecord(entry);
|
|
if (!item) return [];
|
|
|
|
const type = item['type'];
|
|
if (type === 'content') {
|
|
const body = getRecord(item['content']);
|
|
if (!body || typeof body['type'] !== 'string') return [];
|
|
return [
|
|
{
|
|
type: 'content',
|
|
content: { ...body, type: body['type'] },
|
|
},
|
|
];
|
|
}
|
|
|
|
if (type === 'diff') {
|
|
const newText = item['newText'];
|
|
if (typeof newText !== 'string') return [];
|
|
|
|
const path = item['path'];
|
|
const oldText = item['oldText'];
|
|
return [
|
|
{
|
|
type: 'diff',
|
|
...(typeof path === 'string' ? { path } : {}),
|
|
...(typeof oldText === 'string' ? { oldText } : {}),
|
|
newText,
|
|
},
|
|
];
|
|
}
|
|
|
|
if (type === 'terminal') {
|
|
const terminalId = item['terminalId'];
|
|
return [
|
|
{
|
|
type: 'terminal',
|
|
...(typeof terminalId === 'string' ? { terminalId } : {}),
|
|
},
|
|
];
|
|
}
|
|
|
|
return [];
|
|
});
|
|
|
|
return content.length > 0 ? content : undefined;
|
|
}
|
|
|
|
function isAskUserQuestionBlock(block: DaemonToolTranscriptBlock): boolean {
|
|
if (!block.toolName) return false;
|
|
const normalized = block.toolName.toLowerCase();
|
|
return normalized === 'ask_user_question' || normalized === 'askuserquestion';
|
|
}
|
|
|
|
function getToolContentText(
|
|
block: DaemonToolTranscriptBlock,
|
|
): string | undefined {
|
|
if (!Array.isArray(block.content)) return undefined;
|
|
const parts = block.content
|
|
.map((item) => item?.content?.text)
|
|
.filter((text): text is string => Boolean(text));
|
|
if (!parts || parts.length === 0) return undefined;
|
|
return parts.join('\n');
|
|
}
|
|
|
|
function isCancelledStatus(status: string): boolean {
|
|
return status === 'cancelled' || status === 'canceled';
|
|
}
|
|
|
|
type ParsedInsight =
|
|
| {
|
|
type: 'insight_progress';
|
|
stage: string;
|
|
progress: number;
|
|
detail?: string;
|
|
}
|
|
| { type: 'insight_ready'; path: string }
|
|
| { type: 'insight_error'; error: string };
|
|
|
|
type InsightSegment =
|
|
| { kind: 'text'; text: string }
|
|
| { kind: 'insight'; data: ParsedInsight };
|
|
|
|
const INSIGHT_PREFIXES = [
|
|
'"insight_progress":',
|
|
'"insight_ready":',
|
|
'"insight_error":',
|
|
];
|
|
|
|
function parseInsightJson(json: string): ParsedInsight | null {
|
|
try {
|
|
const parsed = JSON.parse(json) as Record<string, unknown>;
|
|
const prog = getRecord(parsed['insight_progress']);
|
|
if (
|
|
prog &&
|
|
typeof prog['stage'] === 'string' &&
|
|
typeof prog['progress'] === 'number'
|
|
) {
|
|
return {
|
|
type: 'insight_progress',
|
|
stage: prog['stage'] as string,
|
|
progress: prog['progress'] as number,
|
|
detail:
|
|
typeof prog['detail'] === 'string'
|
|
? (prog['detail'] as string)
|
|
: undefined,
|
|
};
|
|
}
|
|
const ready = getRecord(parsed['insight_ready']);
|
|
if (ready && typeof ready['path'] === 'string') {
|
|
return { type: 'insight_ready', path: ready['path'] as string };
|
|
}
|
|
const insightError = getRecord(parsed['insight_error']);
|
|
if (insightError && typeof insightError['error'] === 'string') {
|
|
return { type: 'insight_error', error: insightError['error'] as string };
|
|
}
|
|
} catch {
|
|
// not valid JSON
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Balanced-braces JSON extractor. Handles string escapes but not standalone
|
|
// arrays — sufficient for the insight protocol's object-only payloads.
|
|
function extractJsonObject(text: string, start: number): string | null {
|
|
if (text[start] !== '{') return null;
|
|
let depth = 0;
|
|
let inString = false;
|
|
let escape = false;
|
|
for (let i = start; i < text.length; i++) {
|
|
const ch = text[i]!;
|
|
if (escape) {
|
|
escape = false;
|
|
continue;
|
|
}
|
|
if (ch === '\\' && inString) {
|
|
escape = true;
|
|
continue;
|
|
}
|
|
if (ch === '"') {
|
|
inString = !inString;
|
|
continue;
|
|
}
|
|
if (inString) continue;
|
|
if (ch === '{') depth++;
|
|
else if (ch === '}') {
|
|
depth--;
|
|
if (depth === 0) return text.slice(start, i + 1);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function splitInsightSegments(text: string): InsightSegment[] | null {
|
|
const segments: InsightSegment[] = [];
|
|
let lastIndex = 0;
|
|
let pos = 0;
|
|
let hasInsight = false;
|
|
|
|
while (pos < text.length) {
|
|
const braceIdx = text.indexOf('{', pos);
|
|
if (braceIdx === -1) break;
|
|
|
|
const afterBrace = text.slice(braceIdx + 1).trimStart();
|
|
const isInsight = INSIGHT_PREFIXES.some((p) => afterBrace.startsWith(p));
|
|
if (!isInsight) {
|
|
pos = braceIdx + 1;
|
|
continue;
|
|
}
|
|
|
|
const json = extractJsonObject(text, braceIdx);
|
|
if (!json) {
|
|
pos = braceIdx + 1;
|
|
continue;
|
|
}
|
|
|
|
const insight = parseInsightJson(json);
|
|
if (!insight) {
|
|
pos = braceIdx + 1;
|
|
continue;
|
|
}
|
|
|
|
hasInsight = true;
|
|
const before = text.slice(lastIndex, braceIdx).trim();
|
|
if (before) {
|
|
segments.push({ kind: 'text', text: before });
|
|
}
|
|
segments.push({ kind: 'insight', data: insight });
|
|
lastIndex = braceIdx + json.length;
|
|
pos = lastIndex;
|
|
}
|
|
|
|
if (!hasInsight) return null;
|
|
|
|
const after = text.slice(lastIndex).trim();
|
|
if (after) {
|
|
segments.push({ kind: 'text', text: after });
|
|
}
|
|
|
|
return segments.length > 0 ? segments : null;
|
|
}
|
|
|
|
function inferToolKind(
|
|
toolName?: string,
|
|
toolKind?: string,
|
|
): DaemonMessageToolKind | undefined {
|
|
if (toolKind) return toolKind as DaemonMessageToolKind;
|
|
if (!toolName) return undefined;
|
|
const name = toolName.toLowerCase();
|
|
if (name === 'bash' || name === 'execute') return 'execute';
|
|
if (name === 'read') return 'read';
|
|
if (name === 'edit' || name === 'write') return 'edit';
|
|
if (name.includes('search') || name === 'grep' || name === 'glob')
|
|
return 'search';
|
|
if (name === 'agent' || name === 'task') return 'other';
|
|
return undefined;
|
|
}
|