diff --git a/CHANGELOG.md b/CHANGELOG.md index 99e9d186980..26e3ea6c88e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai - Channels/streaming: cap progress-draft tool lines by default so edited progress boxes avoid jumpy reflow from long wrapped lines. - Agents/verbose: use compact explain-mode tool summaries for `/verbose` and progress drafts by default, with `agents.defaults.toolProgressDetail: "raw"` and per-agent overrides for debugging raw command/detail output. - Agents/commands: add `/steer ` for queue-independent steering of the active current-session run without starting a new turn when the session is idle. (#76934) +- Agents/subagents: preserve every grouped child result when direct completion fallback has to bypass the requester-agent announce turn. Thanks @vincentkoc. - Tools/BTW: add `/side` as a text and native slash-command alias for `/btw` side questions. - Doctor/config: `doctor --fix` now commits safe legacy migrations even when unrelated validation issues (e.g. a missing plugin) prevent full validation from passing, so `agents.defaults.llm` and other known-legacy keys are always cleaned up by `doctor --fix` regardless of other config problems. Fixes #76798. (#76800) Thanks @hclsys. - Docs: clarify that IRC uses raw TCP/TLS sockets outside operator-managed forward proxy routing, so direct IRC egress should be explicitly approved before enabling IRC. Thanks @jesse-merhi. diff --git a/src/agents/subagent-announce-delivery.test.ts b/src/agents/subagent-announce-delivery.test.ts index deae9eae025..1fbad2f4f50 100644 --- a/src/agents/subagent-announce-delivery.test.ts +++ b/src/agents/subagent-announce-delivery.test.ts @@ -722,6 +722,62 @@ describe("deliverSubagentAnnouncement completion delivery", () => { ); }); + it("keeps all grouped child results in direct completion fallback", async () => { + const callGateway = createGatewayMock({ + result: { + payloads: [], + }, + }); + const sendMessage = createSendMessageMock(); + const result = await deliverSlackThreadAnnouncement({ + callGateway, + sendMessage, + sessionId: "requester-session-4", + isActive: false, + expectsCompletionMessage: true, + directIdempotencyKey: "announce-thread-fallback-grouped-results", + internalEvents: [ + { + type: "task_completion", + source: "subagent", + childSessionKey: "agent:worker:subagent:first", + childSessionId: "child-session-1", + announceType: "subagent task", + taskLabel: "first task", + status: "ok", + statusLabel: "completed successfully", + result: "first child result", + replyInstruction: "Summarize the result.", + }, + { + type: "task_completion", + source: "subagent", + childSessionKey: "agent:worker:subagent:second", + childSessionId: "child-session-2", + announceType: "subagent task", + taskLabel: "second task", + status: "ok", + statusLabel: "completed successfully", + result: "second child result", + replyInstruction: "Summarize the result.", + }, + ], + }); + + expect(result).toEqual( + expect.objectContaining({ + delivered: true, + path: "direct-thread-fallback", + }), + ); + expect(sendMessage).toHaveBeenCalledWith( + expect.objectContaining({ + content: "first task:\nfirst child result\n\nsecond task:\nsecond child result", + idempotencyKey: "announce-thread-fallback-grouped-results", + }), + ); + }); + it("keeps concise requester rewrites primary even when child output is long", async () => { const callGateway = createGatewayMock({ result: { @@ -1265,4 +1321,33 @@ describe("extractThreadCompletionFallbackText", () => { ]), ).toBe("sample task"); }); + + it("combines multiple task completion results for grouped announce fallback", () => { + expect( + extractThreadCompletionFallbackText([ + { + type: "task_completion", + source: "subagent", + childSessionKey: "agent:worker:subagent:first", + announceType: "subagent task", + taskLabel: "first task", + status: "ok", + statusLabel: "completed successfully", + result: "first child result", + replyInstruction: "Summarize the result.", + }, + { + type: "task_completion", + source: "subagent", + childSessionKey: "agent:worker:subagent:second", + announceType: "subagent task", + taskLabel: "second task", + status: "ok", + statusLabel: "completed successfully", + result: "second child result", + replyInstruction: "Summarize the result.", + }, + ]), + ).toBe("first task:\nfirst child result\n\nsecond task:\nsecond child result"); + }); }); diff --git a/src/agents/subagent-announce-delivery.ts b/src/agents/subagent-announce-delivery.ts index 9a7869aa8d5..ce7cb7c003b 100644 --- a/src/agents/subagent-announce-delivery.ts +++ b/src/agents/subagent-announce-delivery.ts @@ -534,31 +534,65 @@ async function maybeQueueSubagentAnnounce(params: { return "none"; } +function extractTaskCompletionFallbackText(event: AgentInternalEvent): string { + const result = event.result.trim(); + if (result) { + return result; + } + const statusLabel = event.statusLabel.trim(); + const taskLabel = event.taskLabel.trim(); + if (statusLabel && taskLabel) { + return `${taskLabel}: ${statusLabel}`; + } + if (statusLabel) { + return statusLabel; + } + if (taskLabel) { + return taskLabel; + } + return ""; +} + +function formatTaskCompletionFallbackBlock(params: { + event: AgentInternalEvent; + text: string; + includeTaskLabel: boolean; +}): string { + const taskLabel = params.event.taskLabel.trim(); + if (!params.includeTaskLabel || !taskLabel || params.text.startsWith(`${taskLabel}:`)) { + return params.text; + } + return `${taskLabel}:\n${params.text}`; +} + export function extractThreadCompletionFallbackText(internalEvents?: AgentInternalEvent[]): string { if (!internalEvents || internalEvents.length === 0) { return ""; } - for (const event of internalEvents) { - if (event.type !== "task_completion") { - continue; - } - const result = event.result.trim(); - if (result) { - return result; - } - const statusLabel = event.statusLabel.trim(); - const taskLabel = event.taskLabel.trim(); - if (statusLabel && taskLabel) { - return `${taskLabel}: ${statusLabel}`; - } - if (statusLabel) { - return statusLabel; - } - if (taskLabel) { - return taskLabel; - } + const completions = internalEvents + .filter((event) => event.type === "task_completion") + .map((event) => ({ + event, + text: extractTaskCompletionFallbackText(event), + })) + .filter((completion) => completion.text.length > 0); + if (completions.length === 0) { + return ""; } - return ""; + const onlyCompletion = completions[0]; + if (completions.length === 1 && onlyCompletion) { + return onlyCompletion.text; + } + return completions + .map((completion) => + formatTaskCompletionFallbackBlock({ + event: completion.event, + text: completion.text, + includeTaskLabel: true, + }), + ) + .join("\n\n") + .trim(); } function hasVisibleGatewayAgentPayload(response: unknown): boolean {