fix(subagent): capture latest non-empty findings from child runs

This commit is contained in:
yushen 2026-02-06 19:10:44 +08:00
parent 4d6017e782
commit 12075b96f2
2 changed files with 95 additions and 7 deletions

View file

@ -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\"");
});
});

View file

@ -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));
}
}