From 12075b96f2bbbfc81a31dc7041ea3097bfb45349 Mon Sep 17 00:00:00 2001 From: yushen Date: Fri, 6 Feb 2026 19:10:44 +0800 Subject: [PATCH] fix(subagent): capture latest non-empty findings from child runs --- src/agent/subagent/announce-findings.test.ts | 67 ++++++++++++++++++++ src/agent/subagent/announce.ts | 35 ++++++++-- 2 files changed, 95 insertions(+), 7 deletions(-) create mode 100644 src/agent/subagent/announce-findings.test.ts diff --git a/src/agent/subagent/announce-findings.test.ts b/src/agent/subagent/announce-findings.test.ts new file mode 100644 index 00000000..6a52fc76 --- /dev/null +++ b/src/agent/subagent/announce-findings.test.ts @@ -0,0 +1,67 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const readEntriesMock = vi.fn(); + +vi.mock("../session/storage.js", () => ({ + readEntries: (sessionId: string) => readEntriesMock(sessionId), +})); + +import { readLatestAssistantReply } from "./announce.js"; + +describe("readLatestAssistantReply", () => { + beforeEach(() => { + readEntriesMock.mockReset(); + }); + + it("returns the latest non-empty assistant text when the last assistant message is tool-only", () => { + readEntriesMock.mockReturnValue([ + { + type: "message", + timestamp: 1, + message: { + role: "assistant", + content: [{ type: "text", text: "南京天气:晴,12°C。" }], + }, + }, + { + type: "message", + timestamp: 2, + message: { + role: "assistant", + content: [{ type: "toolCall", id: "tool-1", name: "weather", arguments: { city: "Nanjing" } }], + }, + }, + ]); + + const result = readLatestAssistantReply("child-session"); + expect(result).toBe("南京天气:晴,12°C。"); + }); + + it("falls back to latest toolResult text when no assistant text exists", () => { + readEntriesMock.mockReturnValue([ + { + type: "message", + timestamp: 1, + message: { + role: "assistant", + content: [{ type: "toolCall", id: "tool-2", name: "weather", arguments: { city: "Nanjing" } }], + }, + }, + { + type: "message", + timestamp: 2, + message: { + role: "toolResult", + toolCallId: "tool-2", + toolName: "weather", + content: [{ type: "text", text: "{\"city\":\"Nanjing\",\"tempC\":12,\"condition\":\"Sunny\"}" }], + isError: false, + }, + }, + ]); + + const result = readLatestAssistantReply("child-session"); + expect(result).toContain("\"city\":\"Nanjing\""); + expect(result).toContain("\"condition\":\"Sunny\""); + }); +}); diff --git a/src/agent/subagent/announce.ts b/src/agent/subagent/announce.ts index 8809efdb..7afeb707 100644 --- a/src/agent/subagent/announce.ts +++ b/src/agent/subagent/announce.ts @@ -39,19 +39,29 @@ export function buildSubagentSystemPrompt(params: SubagentSystemPromptParams): s */ export function readLatestAssistantReply(sessionId: string): string | undefined { const entries = readEntries(sessionId); + let latestToolResultText: string | undefined; - // Walk backwards to find last assistant message + // Walk backwards to find the last non-empty assistant reply. + // If no assistant text exists (e.g. run ended after tool execution), + // fall back to the latest non-empty toolResult content. for (let i = entries.length - 1; i >= 0; i--) { const entry = entries[i]!; if (entry.type !== "message") continue; const message = entry.message; - if (message.role !== "assistant") continue; + if (message.role === "assistant") { + const text = extractAssistantText(message); + if (text) return text; + continue; + } - return extractAssistantText(message); + if (message.role === "toolResult" && !latestToolResultText) { + const text = extractToolResultText(message); + if (text) latestToolResultText = text; + } } - return undefined; + return latestToolResultText; } /** @@ -59,7 +69,17 @@ export function readLatestAssistantReply(sessionId: string): string | undefined * AgentMessage.content for assistant is (TextContent | ThinkingContent | ToolCall)[]. */ function extractAssistantText(message: { role: string; content: unknown }): string { - const content = message.content; + return extractTextLikeContent(message.content); +} + +/** + * Extract text content from a toolResult message. + */ +function extractToolResultText(message: { role: string; content: unknown }): string { + return extractTextLikeContent(message.content); +} + +function extractTextLikeContent(content: unknown): string { if (typeof content === "string") { return sanitizeText(content); } @@ -68,8 +88,9 @@ function extractAssistantText(message: { role: string; content: unknown }): stri const textParts: string[] = []; for (const block of content) { - if (block && typeof block === "object" && "type" in block && block.type === "text" && "text" in block) { - textParts.push(String(block.text)); + if (!block || typeof block !== "object") continue; + if ("text" in block) { + textParts.push(String((block as { text: unknown }).text)); } }