Skip to content

Commit 325e5e9

Browse files
authored
fix: preserve thread-bound subagent completion fallback
Preserve the requester-agent announce path for thread-bound subagent completions, while falling back to direct thread delivery only when the announce fails or produces no visible output.\n\nThanks @DolencLuka.
1 parent 5865197 commit 325e5e9

5 files changed

Lines changed: 444 additions & 56 deletions

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,7 @@ Docs: https://docs.openclaw.ai
6363

6464
### Fixes
6565

66+
- Discord/subagents: preserve thread-bound completion delivery by keeping the requester-agent announce path primary and falling back to direct thread sends only when the announce produces no visible output. (#71064) Thanks @DolencLuka.
6667
- Gateway/sessions: recover main-agent turns interrupted by a gateway restart from stale transcript-lock evidence, avoiding stuck `status: "running"` sessions without broad post-boot transcript scans. Fixes #70555. Thanks @bitloi.
6768
- Codex approvals: keep command approval responses within Codex app-server `availableDecisions`, including deny/cancel fallbacks for prompts that do not offer `decline`. (#71338) Thanks @Lucenx9.
6869
- Plugins/Google Meet: include live Chrome-node readiness in `googlemeet setup` and document the Parallels recovery checks, so stale node tokens or disconnected VM browsers are visible before an agent opens a meeting. Thanks @steipete.

src/agents/subagent-announce-delivery.runtime.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export {
88
export { callGateway } from "../gateway/call.js";
99
export { resolveQueueSettings } from "../auto-reply/reply/queue.js";
1010
export { resolveExternalBestEffortDeliveryTarget } from "../infra/outbound/best-effort-delivery.js";
11+
export { sendMessage } from "../infra/outbound/message.js";
1112
export { createBoundDeliveryRouter } from "../infra/outbound/bound-delivery-router.js";
1213
export { resolveConversationIdFromTargets } from "../infra/outbound/conversation-id.js";
1314
export { getGlobalHookRunner } from "../plugins/hook-runner-global.js";

src/agents/subagent-announce-delivery.test.ts

Lines changed: 229 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import { afterEach, describe, expect, it, vi } from "vitest";
2-
import { __testing, deliverSubagentAnnouncement } from "./subagent-announce-delivery.js";
3-
import { callGateway as runtimeCallGateway } from "./subagent-announce-delivery.runtime.js";
2+
import type { AgentInternalEvent } from "./internal-events.js";
3+
import {
4+
__testing,
5+
deliverSubagentAnnouncement,
6+
extractThreadCompletionFallbackText,
7+
} from "./subagent-announce-delivery.js";
8+
import {
9+
callGateway as runtimeCallGateway,
10+
sendMessage as runtimeSendMessage,
11+
} from "./subagent-announce-delivery.runtime.js";
412
import { resolveAnnounceOrigin } from "./subagent-announce-origin.js";
513

614
afterEach(() => {
@@ -14,8 +22,18 @@ const slackThreadOrigin = {
1422
threadId: "171.222",
1523
} as const;
1624

17-
function createGatewayMock() {
18-
return vi.fn(async () => ({}) as Record<string, unknown>) as unknown as typeof runtimeCallGateway;
25+
function createGatewayMock(response: Record<string, unknown> = {}) {
26+
return vi.fn(async () => response) as unknown as typeof runtimeCallGateway;
27+
}
28+
29+
function createSendMessageMock() {
30+
return vi.fn(async () => ({
31+
channel: "slack",
32+
to: "channel:C123",
33+
via: "direct" as const,
34+
mediaUrl: null,
35+
result: { messageId: "msg-1" },
36+
})) as unknown as typeof runtimeSendMessage;
1937
}
2038

2139
async function deliverSlackThreadAnnouncement(params: {
@@ -25,6 +43,8 @@ async function deliverSlackThreadAnnouncement(params: {
2543
expectsCompletionMessage: boolean;
2644
directIdempotencyKey: string;
2745
queueEmbeddedPiMessage?: (sessionId: string, message: string) => boolean;
46+
sendMessage?: typeof runtimeSendMessage;
47+
internalEvents?: AgentInternalEvent[];
2848
}) {
2949
__testing.setDepsForTest({
3050
callGateway: params.callGateway,
@@ -36,6 +56,7 @@ async function deliverSlackThreadAnnouncement(params: {
3656
...(params.queueEmbeddedPiMessage
3757
? { queueEmbeddedPiMessage: params.queueEmbeddedPiMessage }
3858
: {}),
59+
...(params.sendMessage ? { sendMessage: params.sendMessage } : {}),
3960
});
4061

4162
return deliverSubagentAnnouncement({
@@ -51,6 +72,7 @@ async function deliverSlackThreadAnnouncement(params: {
5172
expectsCompletionMessage: params.expectsCompletionMessage,
5273
bestEffortDeliver: true,
5374
directIdempotencyKey: params.directIdempotencyKey,
75+
internalEvents: params.internalEvents,
5476
});
5577
}
5678

@@ -163,6 +185,153 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
163185
);
164186
});
165187

188+
it("keeps announce-agent delivery primary for dormant completion events with child output", async () => {
189+
const callGateway = createGatewayMock({
190+
result: {
191+
payloads: [{ text: "requester voice completion" }],
192+
},
193+
});
194+
const sendMessage = createSendMessageMock();
195+
const result = await deliverSlackThreadAnnouncement({
196+
callGateway,
197+
sendMessage,
198+
sessionId: "requester-session-4",
199+
isActive: false,
200+
expectsCompletionMessage: true,
201+
directIdempotencyKey: "announce-thread-fallback-1",
202+
internalEvents: [
203+
{
204+
type: "task_completion",
205+
source: "subagent",
206+
childSessionKey: "agent:worker:subagent:child",
207+
childSessionId: "child-session-id",
208+
announceType: "subagent task",
209+
taskLabel: "thread completion smoke",
210+
status: "ok",
211+
statusLabel: "completed successfully",
212+
result: "child completion output",
213+
replyInstruction: "Summarize the result.",
214+
},
215+
],
216+
});
217+
218+
expect(result).toEqual(
219+
expect.objectContaining({
220+
delivered: true,
221+
path: "direct",
222+
}),
223+
);
224+
expect(callGateway).toHaveBeenCalledWith(
225+
expect.objectContaining({
226+
method: "agent",
227+
params: expect.objectContaining({
228+
deliver: true,
229+
channel: "slack",
230+
accountId: "acct-1",
231+
to: "channel:C123",
232+
threadId: "171.222",
233+
bestEffortDeliver: true,
234+
internalEvents: expect.any(Array),
235+
}),
236+
}),
237+
);
238+
expect(sendMessage).not.toHaveBeenCalled();
239+
});
240+
241+
it("uses a direct thread fallback when announce-agent delivery fails", async () => {
242+
const callGateway = vi.fn(async () => {
243+
throw new Error("UNAVAILABLE: gateway lost final output");
244+
}) as unknown as typeof runtimeCallGateway;
245+
const sendMessage = createSendMessageMock();
246+
const result = await deliverSlackThreadAnnouncement({
247+
callGateway,
248+
sendMessage,
249+
sessionId: "requester-session-4",
250+
isActive: false,
251+
expectsCompletionMessage: true,
252+
directIdempotencyKey: "announce-thread-fallback-1",
253+
internalEvents: [
254+
{
255+
type: "task_completion",
256+
source: "subagent",
257+
childSessionKey: "agent:worker:subagent:child",
258+
childSessionId: "child-session-id",
259+
announceType: "subagent task",
260+
taskLabel: "thread completion smoke",
261+
status: "ok",
262+
statusLabel: "completed successfully",
263+
result: "child completion output",
264+
replyInstruction: "Summarize the result.",
265+
},
266+
],
267+
});
268+
269+
expect(result).toEqual(
270+
expect.objectContaining({
271+
delivered: true,
272+
path: "direct-thread-fallback",
273+
}),
274+
);
275+
expect(callGateway).toHaveBeenCalled();
276+
expect(sendMessage).toHaveBeenCalledWith(
277+
expect.objectContaining({
278+
channel: "slack",
279+
accountId: "acct-1",
280+
to: "channel:C123",
281+
threadId: "171.222",
282+
content: "child completion output",
283+
requesterSessionKey: "agent:main:slack:channel:C123:thread:171.222",
284+
bestEffort: true,
285+
idempotencyKey: "announce-thread-fallback-1",
286+
}),
287+
);
288+
});
289+
290+
it("uses a direct thread fallback when announce-agent returns no visible output", async () => {
291+
const callGateway = createGatewayMock({
292+
result: {
293+
payloads: [],
294+
},
295+
});
296+
const sendMessage = createSendMessageMock();
297+
const result = await deliverSlackThreadAnnouncement({
298+
callGateway,
299+
sendMessage,
300+
sessionId: "requester-session-4",
301+
isActive: false,
302+
expectsCompletionMessage: true,
303+
directIdempotencyKey: "announce-thread-fallback-empty",
304+
internalEvents: [
305+
{
306+
type: "task_completion",
307+
source: "subagent",
308+
childSessionKey: "agent:worker:subagent:child",
309+
childSessionId: "child-session-id",
310+
announceType: "subagent task",
311+
taskLabel: "thread completion smoke",
312+
status: "ok",
313+
statusLabel: "completed successfully",
314+
result: "child completion output",
315+
replyInstruction: "Summarize the result.",
316+
},
317+
],
318+
});
319+
320+
expect(result).toEqual(
321+
expect.objectContaining({
322+
delivered: true,
323+
path: "direct-thread-fallback",
324+
}),
325+
);
326+
expect(callGateway).toHaveBeenCalled();
327+
expect(sendMessage).toHaveBeenCalledWith(
328+
expect.objectContaining({
329+
content: "child completion output",
330+
idempotencyKey: "announce-thread-fallback-empty",
331+
}),
332+
);
333+
});
334+
166335
it("keeps direct external delivery for non-completion announces", async () => {
167336
const callGateway = createGatewayMock();
168337
await deliverSlackThreadAnnouncement({
@@ -188,3 +357,59 @@ describe("deliverSubagentAnnouncement completion delivery", () => {
188357
);
189358
});
190359
});
360+
361+
describe("extractThreadCompletionFallbackText", () => {
362+
it("prefers task completion result text", () => {
363+
expect(
364+
extractThreadCompletionFallbackText([
365+
{
366+
type: "task_completion",
367+
source: "subagent",
368+
childSessionKey: "agent:worker:subagent:child",
369+
announceType: "subagent task",
370+
taskLabel: "sample task",
371+
status: "ok",
372+
statusLabel: "completed successfully",
373+
result: "final child result",
374+
replyInstruction: "Summarize the result.",
375+
},
376+
]),
377+
).toBe("final child result");
378+
});
379+
380+
it("falls back to task and status labels when result text is empty", () => {
381+
expect(
382+
extractThreadCompletionFallbackText([
383+
{
384+
type: "task_completion",
385+
source: "subagent",
386+
childSessionKey: "agent:worker:subagent:child",
387+
announceType: "subagent task",
388+
taskLabel: "sample task",
389+
status: "ok",
390+
statusLabel: "completed successfully",
391+
result: " ",
392+
replyInstruction: "Summarize the result.",
393+
},
394+
]),
395+
).toBe("sample task: completed successfully");
396+
});
397+
398+
it("falls back to the task label when result and status label are empty", () => {
399+
expect(
400+
extractThreadCompletionFallbackText([
401+
{
402+
type: "task_completion",
403+
source: "subagent",
404+
childSessionKey: "agent:worker:subagent:child",
405+
announceType: "subagent task",
406+
taskLabel: "sample task",
407+
status: "ok",
408+
statusLabel: " ",
409+
result: " ",
410+
replyInstruction: "Summarize the result.",
411+
},
412+
]),
413+
).toBe("sample task");
414+
});
415+
});

0 commit comments

Comments
 (0)