fix(agents): keep grouped subagent completions

This commit is contained in:
Vincent Koc
2026-05-03 22:38:49 -07:00
parent cbd91676ac
commit b6f9b5f21e
3 changed files with 140 additions and 20 deletions

View File

@@ -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 <message>` 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.

View File

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

View File

@@ -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 {