Skip to content

Commit 669dccb

Browse files
fix(daemon): extract subagent reply text from tool_response envelope
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) <noreply@anthropic.com>
1 parent 2f16e32 commit 669dccb

1 file changed

Lines changed: 42 additions & 2 deletions

File tree

src/daemon.ts

Lines changed: 42 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,40 @@ function hashPrompt(prompt: string): string {
8282
return createHash('sha256').update(prompt, 'utf8').digest('hex');
8383
}
8484

85+
/**
86+
* Pull the subagent's reply text out of a Claude-Code `tool_response` value.
87+
* Anthropic-style responses arrive as
88+
* `{ content: [{ type: 'text', text: '...' }, ...], ... }`
89+
* with optional `prompt`, `agentId`, `agentType`, etc. sibling fields. The
90+
* chat view should render just the assistant's reply, not the whole
91+
* envelope, so we extract the concatenated `text` blocks. Returns:
92+
* - the input verbatim if it's already a plain string,
93+
* - joined text content if `content` is a list of blocks with `type:'text'`,
94+
* - JSON-stringified fallback only if the shape is unrecognized (preserves
95+
* pre-fix behavior so we never silently lose data).
96+
*/
97+
function extractSubagentReplyText(toolResponse: unknown): string {
98+
if (typeof toolResponse === 'string') return toolResponse;
99+
if (toolResponse === null || typeof toolResponse !== 'object') {
100+
return JSON.stringify(toolResponse);
101+
}
102+
const obj = toolResponse as Record<string, unknown>;
103+
const content = obj['content'];
104+
if (Array.isArray(content)) {
105+
const texts: string[] = [];
106+
for (const block of content) {
107+
if (block && typeof block === 'object') {
108+
const b = block as Record<string, unknown>;
109+
if (b['type'] === 'text' && typeof b['text'] === 'string') {
110+
texts.push(b['text']);
111+
}
112+
}
113+
}
114+
if (texts.length > 0) return texts.join('\n');
115+
}
116+
return JSON.stringify(toolResponse);
117+
}
118+
85119
/**
86120
* Map a parent transcript path + subagent agent_id to the subagent's transcript
87121
* file. Claude Code writes subagent transcripts as siblings of the parent in a
@@ -810,10 +844,16 @@ export class GlobalDaemon {
810844
if (!span || tracker.ended) return;
811845

812846
if (output !== undefined && output !== null && output !== '') {
813-
const outputText = typeof output === 'string' ? output : jsonStr(output);
847+
// For matched (PostToolUse) closes, `output` is the `tool_response`
848+
// value Claude Code passes — an Anthropic-shape envelope with
849+
// `content[]` blocks plus sibling metadata. Extract just the reply
850+
// text so the chat view shows the subagent's message, not the raw
851+
// JSON wrapper. Failure path passes a plain error string, which
852+
// extractSubagentReplyText returns verbatim.
853+
const replyText = extractSubagentReplyText(output);
814854
span.setAttribute(
815855
ATTR.OUTPUT_MESSAGES,
816-
jsonStr([{ role: 'assistant', content: outputText }]),
856+
jsonStr([{ role: 'assistant', content: replyText }]),
817857
);
818858
}
819859
if (failure) {

0 commit comments

Comments
 (0)