From 669dccbd02197dba528e9b3212fbc40c700e10f6 Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Tue, 19 May 2026 08:32:56 -0700 Subject: [PATCH 1/2] fix(daemon): extract subagent reply text from tool_response envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit closeSubagentInvokeAgentSpan was storing the full Claude Code tool_response envelope JSON-stringified into gen_ai.output.messages: {"status":"completed","prompt":"...","agentId":"...", "content":[{"type":"text","text":"ok let me push..."}], ...} The chat view then rendered that whole blob as the subagent's assistant_message, instead of just the subagent's reply text. Extract `content[*].text` from the Anthropic-shape envelope. Plain strings (orphan-path lastAssistantText, error-path messages) and unrecognized shapes pass through unchanged so behavior is preserved for the non-envelope call sites. Verified locally against a realistic tool_response with sibling fields (status, prompt, agentId, agentType, description, totalDurationMs, totalTokens). After fix, output_messages.content is exactly the subagent's text — none of the envelope keys leak through. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/daemon.ts | 44 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index 1db8345..b02b8da 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -82,6 +82,40 @@ function hashPrompt(prompt: string): string { return createHash('sha256').update(prompt, 'utf8').digest('hex'); } +/** + * Pull the subagent's reply text out of a Claude-Code `tool_response` value. + * Anthropic-style responses arrive as + * `{ content: [{ type: 'text', text: '...' }, ...], ... }` + * with optional `prompt`, `agentId`, `agentType`, etc. sibling fields. The + * chat view should render just the assistant's reply, not the whole + * envelope, so we extract the concatenated `text` blocks. Returns: + * - the input verbatim if it's already a plain string, + * - joined text content if `content` is a list of blocks with `type:'text'`, + * - JSON-stringified fallback only if the shape is unrecognized (preserves + * pre-fix behavior so we never silently lose data). + */ +function extractSubagentReplyText(toolResponse: unknown): string { + if (typeof toolResponse === 'string') return toolResponse; + if (toolResponse === null || typeof toolResponse !== 'object') { + return JSON.stringify(toolResponse); + } + const obj = toolResponse as Record; + const content = obj['content']; + if (Array.isArray(content)) { + const texts: string[] = []; + for (const block of content) { + if (block && typeof block === 'object') { + const b = block as Record; + if (b['type'] === 'text' && typeof b['text'] === 'string') { + texts.push(b['text']); + } + } + } + if (texts.length > 0) return texts.join('\n'); + } + return JSON.stringify(toolResponse); +} + /** * Map a parent transcript path + subagent agent_id to the subagent's transcript * file. Claude Code writes subagent transcripts as siblings of the parent in a @@ -810,10 +844,16 @@ export class GlobalDaemon { if (!span || tracker.ended) return; if (output !== undefined && output !== null && output !== '') { - const outputText = typeof output === 'string' ? output : jsonStr(output); + // For matched (PostToolUse) closes, `output` is the `tool_response` + // value Claude Code passes — an Anthropic-shape envelope with + // `content[]` blocks plus sibling metadata. Extract just the reply + // text so the chat view shows the subagent's message, not the raw + // JSON wrapper. Failure path passes a plain error string, which + // extractSubagentReplyText returns verbatim. + const replyText = extractSubagentReplyText(output); span.setAttribute( ATTR.OUTPUT_MESSAGES, - jsonStr([{ role: 'assistant', content: outputText }]), + jsonStr([{ role: 'assistant', content: replyText }]), ); } if (failure) { From 97c148f08c1f43327b27353fd261145b78a0b18e Mon Sep 17 00:00:00 2001 From: Rick Gao Date: Fri, 22 May 2026 11:55:25 -0700 Subject: [PATCH 2/2] review polish: lock down empty-content path, document semconv split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Restructure extractSubagentReplyText so a recognized envelope shape (object with a `content` array) always returns the joined text — even when no text blocks were present — instead of falling through to JSON.stringify and re-emitting the wrapper. The empty-text fall-through was a latent bug-class that would silently reintroduce the original rendering issue if Anthropic returned a content list of non-text blocks (image / tool_use / thinking). JSDoc now states this contract explicitly. Drive-by: collapse the redundant null-and-non-object guard since the null check is load-bearing only for the object-cast that follows. - Add a JSDoc note to closeSubagentInvokeAgentSpan explaining why we deliberately do not set `gen_ai.tool.call.result` on the subagent invoke_agent span. The envelope sibling fields (status, prompt, agentId, agentType, description, totalDurationMs, totalTokens) are fully reconstructible from other span attributes — dispatch metadata is set at PreToolUse from tool_input, duration from span timestamps, and per-turn token counts on child chat spans — so duplicating the envelope would only create a second source of truth. - Trim the now-redundant call-site comment that duplicated the helper's JSDoc. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/daemon.ts | 58 ++++++++++++++++++++++++++++++--------------------- 1 file changed, 34 insertions(+), 24 deletions(-) diff --git a/src/daemon.ts b/src/daemon.ts index b02b8da..58185a8 100644 --- a/src/daemon.ts +++ b/src/daemon.ts @@ -88,30 +88,33 @@ function hashPrompt(prompt: string): string { * `{ content: [{ type: 'text', text: '...' }, ...], ... }` * with optional `prompt`, `agentId`, `agentType`, etc. sibling fields. The * chat view should render just the assistant's reply, not the whole - * envelope, so we extract the concatenated `text` blocks. Returns: - * - the input verbatim if it's already a plain string, - * - joined text content if `content` is a list of blocks with `type:'text'`, - * - JSON-stringified fallback only if the shape is unrecognized (preserves - * pre-fix behavior so we never silently lose data). + * envelope. Behavior: + * - string input → returned verbatim (orphan-path lastAssistantText, + * error-path messages), + * - recognized envelope (object with a `content` array) → joined `text` + * blocks, possibly the empty string if no text blocks were present. + * Falling through to JSON.stringify on an empty/text-less recognized + * envelope would re-emit the wrapper and reintroduce the bug this + * helper exists to prevent, so the recognized-shape path always wins. + * - anything else (unrecognized object shape, numbers, booleans, etc.) + * → JSON-stringified so we never silently lose data. */ function extractSubagentReplyText(toolResponse: unknown): string { if (typeof toolResponse === 'string') return toolResponse; - if (toolResponse === null || typeof toolResponse !== 'object') { - return JSON.stringify(toolResponse); - } - const obj = toolResponse as Record; - const content = obj['content']; - if (Array.isArray(content)) { - const texts: string[] = []; - for (const block of content) { - if (block && typeof block === 'object') { - const b = block as Record; - if (b['type'] === 'text' && typeof b['text'] === 'string') { - texts.push(b['text']); + if (toolResponse !== null && typeof toolResponse === 'object') { + const content = (toolResponse as Record)['content']; + if (Array.isArray(content)) { + const texts: string[] = []; + for (const block of content) { + if (block && typeof block === 'object') { + const b = block as Record; + if (b['type'] === 'text' && typeof b['text'] === 'string') { + texts.push(b['text']); + } } } + return texts.join('\n'); } - if (texts.length > 0) return texts.join('\n'); } return JSON.stringify(toolResponse); } @@ -834,6 +837,17 @@ export class GlobalDaemon { * `tracker.ended` so PostToolUse and SubagentStop can both safely call this * regardless of order. Sets `gen_ai.output.messages` from the canonical * tool return string when available; marks the span ERROR on failure. + * + * Unlike the regular tool path in `handlePostToolUse`, we deliberately do + * not set `gen_ai.tool.call.result` here: the subagent is modeled as an + * `invoke_agent` (chat-flavored) span, not a `tool_call` span, so the + * semconv attribute split mirrors the span-type split. The envelope + * sibling fields (`status`, `prompt`, `agentId`, `agentType`, + * `description`, `totalDurationMs`, `totalTokens`) are already + * reconstructible from other attributes: dispatch metadata is set on the + * span at PreToolUse from `tool_input`, duration is recoverable from the + * span's own start/end timestamps, and per-turn token counts (with cache + * breakdowns) live on child chat spans. */ private closeSubagentInvokeAgentSpan( tracker: SubagentTracker, @@ -844,12 +858,8 @@ export class GlobalDaemon { if (!span || tracker.ended) return; if (output !== undefined && output !== null && output !== '') { - // For matched (PostToolUse) closes, `output` is the `tool_response` - // value Claude Code passes — an Anthropic-shape envelope with - // `content[]` blocks plus sibling metadata. Extract just the reply - // text so the chat view shows the subagent's message, not the raw - // JSON wrapper. Failure path passes a plain error string, which - // extractSubagentReplyText returns verbatim. + // `output` is the Anthropic tool_response envelope on success or a + // plain error string on failure — extractSubagentReplyText handles both. const replyText = extractSubagentReplyText(output); span.setAttribute( ATTR.OUTPUT_MESSAGES,