Skip to content

Commit 0bf1b38

Browse files
committed
Agents: fix subagent completion thread routing
1 parent 35851cd commit 0bf1b38

File tree

8 files changed

+90
-25
lines changed

8 files changed

+90
-25
lines changed

src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.e2e.test.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
212212
expect(send?.sessionKey).toBe("agent:main:main");
213213
expect(send?.channel).toBe("whatsapp");
214214
expect(send?.to).toBe("+123");
215-
expect(send?.message).toBe("done");
215+
expect(send?.message).toBe("✅ Subagent main finished\n\ndone");
216216
expect(child.sessionKey?.startsWith("agent:main:subagent:")).toBe(true);
217217
});
218218

@@ -297,7 +297,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
297297
expect(send?.sessionKey).toBe("agent:main:discord:group:req");
298298
expect(send?.channel).toBe("discord");
299299
expect(send?.to).toBe("discord:dm:u123");
300-
expect(send?.message).toContain("completed successfully");
300+
expect(send?.message).toBe("✅ Subagent main finished");
301301

302302
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);
303303
});
@@ -364,7 +364,7 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => {
364364
expect(send?.sessionKey).toBe("agent:main:discord:group:req");
365365
expect(send?.channel).toBe("discord");
366366
expect(send?.to).toBe("discord:dm:u123");
367-
expect(send?.message).toBe("done");
367+
expect(send?.message).toBe("✅ Subagent main finished\n\ndone");
368368

369369
// Session should be deleted
370370
expect(deletedKey?.startsWith("agent:main:subagent:")).toBe(true);

src/agents/openclaw-tools.subagents.sessions-spawn.model.e2e.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ describe("openclaw-tools: subagents (sessions_spawn model + thinking)", () => {
8383
});
8484
expect(result.details).toMatchObject({
8585
status: "accepted",
86-
note: "auto-announces on completion, do not poll",
86+
note: "auto-announces on completion, do not poll/sleep. The response will be sent back as an agent message.",
8787
modelApplied: true,
8888
});
8989

src/agents/sessions-spawn-threadid.e2e.test.ts

Lines changed: 14 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,21 @@
11
import { beforeEach, describe, expect, it } from "vitest";
2-
import { createOpenClawTools } from "./openclaw-tools.js";
32
import "./test-helpers/fast-core-tools.js";
43
import {
5-
callGatewayMock,
6-
setSubagentsConfigOverride,
7-
} from "./openclaw-tools.subagents.test-harness.js";
4+
getCallGatewayMock,
5+
getSessionsSpawnTool,
6+
setSessionsSpawnConfigOverride,
7+
} from "./openclaw-tools.subagents.sessions-spawn.test-harness.js";
88
import {
99
listSubagentRunsForRequester,
1010
resetSubagentRegistryForTests,
1111
} from "./subagent-registry.js";
1212

1313
describe("sessions_spawn requesterOrigin threading", () => {
1414
beforeEach(() => {
15+
const callGatewayMock = getCallGatewayMock();
1516
resetSubagentRegistryForTests();
1617
callGatewayMock.mockReset();
17-
setSubagentsConfigOverride({
18+
setSessionsSpawnConfigOverride({
1819
session: {
1920
mainKey: "main",
2021
scope: "per-sender",
@@ -35,20 +36,18 @@ describe("sessions_spawn requesterOrigin threading", () => {
3536
});
3637

3738
it("captures threadId in requesterOrigin", async () => {
38-
const tool = createOpenClawTools({
39+
const tool = await getSessionsSpawnTool({
3940
agentSessionKey: "main",
4041
agentChannel: "telegram",
4142
agentTo: "telegram:123",
4243
agentThreadId: 42,
43-
}).find((candidate) => candidate.name === "sessions_spawn");
44-
if (!tool) {
45-
throw new Error("missing sessions_spawn tool");
46-
}
44+
});
4745

48-
await tool.execute("call", {
46+
const result = await tool.execute("call", {
4947
task: "do thing",
5048
runTimeoutSeconds: 1,
5149
});
50+
expect(result.details).toMatchObject({ status: "accepted", runId: "run-1" });
5251

5352
const runs = listSubagentRunsForRequester("main");
5453
expect(runs).toHaveLength(1);
@@ -60,19 +59,17 @@ describe("sessions_spawn requesterOrigin threading", () => {
6059
});
6160

6261
it("stores requesterOrigin without threadId when none is provided", async () => {
63-
const tool = createOpenClawTools({
62+
const tool = await getSessionsSpawnTool({
6463
agentSessionKey: "main",
6564
agentChannel: "telegram",
6665
agentTo: "telegram:123",
67-
}).find((candidate) => candidate.name === "sessions_spawn");
68-
if (!tool) {
69-
throw new Error("missing sessions_spawn tool");
70-
}
66+
});
7167

72-
await tool.execute("call", {
68+
const result = await tool.execute("call", {
7369
task: "do thing",
7470
runTimeoutSeconds: 1,
7571
});
72+
expect(result.details).toMatchObject({ status: "accepted", runId: "run-1" });
7673

7774
const runs = listSubagentRunsForRequester("main");
7875
expect(runs).toHaveLength(1);

src/agents/subagent-announce.format.e2e.test.ts

Lines changed: 40 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -372,11 +372,8 @@ describe("subagent announce formatting", () => {
372372
expect(call?.params?.channel).toBe("discord");
373373
expect(call?.params?.to).toBe("channel:12345");
374374
expect(call?.params?.sessionKey).toBe("agent:main:main");
375-
expect(msg).toContain("[System Message]");
376-
expect(msg).toContain('subagent task "do thing"');
377-
expect(msg).toContain("Result:");
375+
expect(msg).toContain("✅ Subagent main finished");
378376
expect(msg).toContain("final answer: 2");
379-
expect(msg).toContain("Stats:");
380377
expect(msg).not.toContain("Convert the result above into your normal assistant voice");
381378
});
382379

@@ -413,6 +410,45 @@ describe("subagent announce formatting", () => {
413410
const call = sendSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
414411
expect(call?.params?.channel).toBe("discord");
415412
expect(call?.params?.to).toBe("channel:12345");
413+
expect(call?.params?.threadId).toBeUndefined();
414+
});
415+
416+
it("passes requesterOrigin.threadId for manual completion direct-send", async () => {
417+
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
418+
sessionStore = {
419+
"agent:main:subagent:test": {
420+
sessionId: "child-session-direct-thread-pass",
421+
},
422+
"agent:main:main": {
423+
sessionId: "requester-session-thread-pass",
424+
},
425+
};
426+
chatHistoryMock.mockResolvedValueOnce({
427+
messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }],
428+
});
429+
430+
const didAnnounce = await runSubagentAnnounceFlow({
431+
childSessionKey: "agent:main:subagent:test",
432+
childRunId: "run-direct-thread-pass",
433+
requesterSessionKey: "agent:main:main",
434+
requesterDisplayKey: "main",
435+
requesterOrigin: {
436+
channel: "discord",
437+
to: "channel:12345",
438+
accountId: "acct-1",
439+
threadId: 99,
440+
},
441+
...defaultOutcomeAnnounce,
442+
expectsCompletionMessage: true,
443+
});
444+
445+
expect(didAnnounce).toBe(true);
446+
expect(sendSpy).toHaveBeenCalledTimes(1);
447+
expect(agentSpy).not.toHaveBeenCalled();
448+
const call = sendSpy.mock.calls[0]?.[0] as { params?: Record<string, unknown> };
449+
expect(call?.params?.channel).toBe("discord");
450+
expect(call?.params?.to).toBe("channel:12345");
451+
expect(call?.params?.threadId).toBe("99");
416452
});
417453

418454
it("steers announcements into an active run when queue mode is steer", async () => {

src/agents/subagent-announce.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -463,12 +463,17 @@ async function sendSubagentAnnounceDirectly(params: {
463463
hasCompletionDirectTarget &&
464464
params.completionMessage?.trim()
465465
) {
466+
const completionThreadId =
467+
completionDirectOrigin?.threadId != null && completionDirectOrigin.threadId !== ""
468+
? String(completionDirectOrigin.threadId)
469+
: undefined;
466470
await callGateway({
467471
method: "send",
468472
params: {
469473
channel: completionChannel,
470474
to: completionTo,
471475
accountId: completionDirectOrigin?.accountId,
476+
threadId: completionThreadId,
472477
sessionKey: canonicalRequesterSessionKey,
473478
message: params.completionMessage,
474479
idempotencyKey: params.directIdempotencyKey,

src/gateway/protocol/schema/agent.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ export const SendParamsSchema = Type.Object(
2222
gifPlayback: Type.Optional(Type.Boolean()),
2323
channel: Type.Optional(Type.String()),
2424
accountId: Type.Optional(Type.String()),
25+
/** Thread id (channel-specific meaning, e.g. Telegram forum topic id). */
26+
threadId: Type.Optional(Type.String()),
2527
/** Optional session key for mirroring delivered output back into the transcript. */
2628
sessionKey: Type.Optional(Type.String()),
2729
idempotencyKey: NonEmptyString,

src/gateway/server-methods/send.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,4 +235,22 @@ describe("gateway send mirroring", () => {
235235
}),
236236
);
237237
});
238+
239+
it("forwards threadId to outbound delivery when provided", async () => {
240+
mocks.deliverOutboundPayloads.mockResolvedValue([{ messageId: "m-thread", channel: "slack" }]);
241+
242+
await runSend({
243+
to: "channel:C1",
244+
message: "hi",
245+
channel: "slack",
246+
threadId: "1710000000.9999",
247+
idempotencyKey: "idem-thread",
248+
});
249+
250+
expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith(
251+
expect.objectContaining({
252+
threadId: "1710000000.9999",
253+
}),
254+
);
255+
});
238256
});

src/gateway/server-methods/send.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ export const sendHandlers: GatewayRequestHandlers = {
6464
gifPlayback?: boolean;
6565
channel?: string;
6666
accountId?: string;
67+
threadId?: string;
6768
sessionKey?: string;
6869
idempotencyKey: string;
6970
};
@@ -130,6 +131,10 @@ export const sendHandlers: GatewayRequestHandlers = {
130131
typeof request.accountId === "string" && request.accountId.trim().length
131132
? request.accountId.trim()
132133
: undefined;
134+
const threadId =
135+
typeof request.threadId === "string" && request.threadId.trim().length
136+
? request.threadId.trim()
137+
: undefined;
133138
const outboundChannel = channel;
134139
const plugin = getChannelPlugin(channel);
135140
if (!plugin) {
@@ -182,6 +187,7 @@ export const sendHandlers: GatewayRequestHandlers = {
182187
agentId: derivedAgentId,
183188
accountId,
184189
target: resolved.to,
190+
threadId,
185191
})
186192
: null;
187193
if (derivedRoute) {
@@ -203,6 +209,7 @@ export const sendHandlers: GatewayRequestHandlers = {
203209
? resolveSessionAgentId({ sessionKey: providedSessionKey, config: cfg })
204210
: derivedAgentId,
205211
gifPlayback: request.gifPlayback,
212+
threadId: threadId ?? null,
206213
deps: outboundDeps,
207214
mirror: providedSessionKey
208215
? {

0 commit comments

Comments
 (0)