fix(subagents): return completion message for manual session spawns

This commit is contained in:
Peter Steinberger
2026-02-18 02:52:23 +01:00
parent f6f5cda6ca
commit fa4f66255c
9 changed files with 138 additions and 4 deletions

View File

@@ -23,6 +23,7 @@ const subagentRegistryMock = {
countActiveDescendantRuns: vi.fn((_sessionKey: string) => 0),
resolveRequesterForChildSession: vi.fn((_sessionKey: string): RequesterResolution => null),
};
const chatHistoryMock = vi.fn(async (_sessionKey: string) => ({ messages: [] as Array<unknown> }));
let sessionStore: Record<string, Record<string, unknown>> = {};
let configOverride: ReturnType<(typeof import("../config/config.js"))["loadConfig"]> = {
session: {
@@ -66,6 +67,9 @@ vi.mock("../gateway/call.js", () => ({
if (typed.method === "agent.wait") {
return { status: "error", startedAt: 10, endedAt: 20, error: "boom" };
}
if (typed.method === "chat.history") {
return await chatHistoryMock(typed.params?.sessionKey);
}
if (typed.method === "sessions.patch") {
return {};
}
@@ -114,6 +118,7 @@ describe("subagent announce formatting", () => {
subagentRegistryMock.countActiveDescendantRuns.mockReset().mockReturnValue(0);
subagentRegistryMock.resolveRequesterForChildSession.mockReset().mockReturnValue(null);
readLatestAssistantReplyMock.mockReset().mockResolvedValue("raw subagent reply");
chatHistoryMock.mockReset().mockResolvedValue({ messages: [] });
sessionStore = {};
configOverride = {
session: {
@@ -197,6 +202,36 @@ describe("subagent announce formatting", () => {
);
});
it("falls back to latest toolResult output when assistant reply is empty", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
chatHistoryMock.mockResolvedValueOnce({
messages: [
{
role: "assistant",
content: [{ type: "text", text: "" }],
},
{
role: "toolResult",
content: [{ type: "text", text: "tool output line 1" }],
},
],
});
readLatestAssistantReplyMock.mockResolvedValue("");
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-tool-fallback",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
...defaultOutcomeAnnounce,
waitForCompletion: false,
});
const call = agentSpy.mock.calls[0]?.[0] as { params?: { message?: string } };
const msg = call?.params?.message as string;
expect(msg).toContain("tool output line 1");
});
it("keeps full findings and includes compact stats", async () => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
sessionStore = {

View File

@@ -10,6 +10,7 @@ import {
import { callGateway } from "../gateway/call.js";
import { normalizeMainKey } from "../routing/session-key.js";
import { defaultRuntime } from "../runtime.js";
import { extractTextFromChatContent } from "../shared/chat-content.js";
import {
type DeliveryContext,
deliveryContextFromSession,
@@ -29,6 +30,67 @@ import {
import { type AnnounceQueueItem, enqueueAnnounce } from "./subagent-announce-queue.js";
import { getSubagentDepthFromSessionStore } from "./subagent-depth.js";
import { readLatestAssistantReply } from "./tools/agent-step.js";
import { sanitizeTextContent } from "./tools/sessions-helpers.js";
type ToolResultMessage = {
role?: unknown;
content?: unknown;
};
function extractToolResultText(content: unknown): string {
if (typeof content === "string") {
return sanitizeTextContent(content);
}
if (!Array.isArray(content)) {
return "";
}
const joined = extractTextFromChatContent(content, {
sanitizeText: sanitizeTextContent,
normalizeText: (text) => text,
joinWith: "\n",
});
return joined?.trim() ?? "";
}
async function readLatestToolResult(sessionKey: string): Promise<string | undefined> {
const history = await callGateway<{ messages?: Array<unknown> }>({
method: "chat.history",
params: { sessionKey, limit: 50 },
});
const messages = Array.isArray(history?.messages) ? history.messages : [];
for (let i = messages.length - 1; i >= 0; i -= 1) {
const msg = messages[i];
if (!msg || typeof msg !== "object") {
continue;
}
const candidate = msg as ToolResultMessage;
if (candidate.role !== "toolResult") {
continue;
}
const text = extractToolResultText(candidate.content);
if (text) {
return text;
}
}
return undefined;
}
async function readLatestToolResultWithRetry(params: {
sessionKey: string;
maxWaitMs: number;
}): Promise<string | undefined> {
const RETRY_INTERVAL_MS = 100;
const deadline = Date.now() + Math.max(0, Math.min(params.maxWaitMs, 15_000));
let result: string | undefined;
while (Date.now() < deadline) {
result = await readLatestToolResult(params.sessionKey);
if (result?.trim()) {
return result;
}
await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS));
}
return result;
}
function formatDurationShort(valueMs?: number) {
if (!valueMs || !Number.isFinite(valueMs) || valueMs <= 0) {
@@ -470,6 +532,13 @@ export async function runSubagentAnnounceFlow(params: {
});
}
if (!reply?.trim()) {
reply = await readLatestToolResultWithRetry({
sessionKey: params.childSessionKey,
maxWaitMs: params.timeoutMs,
});
}
if (!reply?.trim() && childSessionId && isEmbeddedPiRunActive(childSessionId)) {
// Avoid announcing "(no output)" while the child run is still producing output.
shouldDeleteChildSession = false;

View File

@@ -1,8 +1,8 @@
import { Type } from "@sinclair/typebox";
import type { GatewayMessageChannel } from "../../utils/message-channel.js";
import type { AnyAgentTool } from "./common.js";
import { optionalStringEnum } from "../schema/typebox.js";
import { spawnSubagentDirect } from "../subagent-spawn.js";
import type { AnyAgentTool } from "./common.js";
import { jsonResult, readStringParam } from "./common.js";
const SessionsSpawnToolSchema = Type.Object({
@@ -66,6 +66,7 @@ export function createSessionsSpawnTool(opts?: {
thinking: thinkingOverrideRaw,
runTimeoutSeconds,
cleanup,
expectsCompletionMessage: true,
},
{
agentSessionKey: opts?.agentSessionKey,