Skip to content

Commit 03f2951

Browse files
SnowSky1jalehman
andauthored
fix(agents): preserve announce threadId on sessions.list fallback (openclaw#63506)
Merged via squash. Prepared head SHA: a81e85d Co-authored-by: SnowSky1 <126348592+SnowSky1@users.noreply.github.com> Co-authored-by: jalehman <550978+jalehman@users.noreply.github.com> Reviewed-by: @jalehman
1 parent 10797cb commit 03f2951

5 files changed

Lines changed: 209 additions & 2 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ Docs: https://docs.openclaw.ai
5959
- Cron/Telegram: collapse isolated announce delivery to the final assistant-visible text only for Telegram targets, while preserving existing multi-message direct delivery semantics for other channels. (#63228) Thanks @welfo-beo.
6060
- Gateway/thread routing: preserve Slack, Telegram, and Mattermost thread-child delivery targets so bound subagent completion messages land in the originating thread instead of top-level channels. (#54840) Thanks @yzzymt.
6161
- ACP/stream relay: pass parent delivery context to ACP stream relay system events so `streamTo="parent"` updates route to the correct thread or topic instead of falling back to the main DM. (#57056) Thanks @pingren.
62-
62+
- Agents/sessions: preserve announce `threadId` when `sessions.list` fallback rehydrates agent-to-agent announce targets so final announce messages stay in the originating thread/topic. (#63506) Thanks @SnowSky1.
6363
## 2026.4.9
6464

6565
### Changes

src/agents/openclaw-tools.sessions.test.ts

Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import path from "node:path";
22
import { beforeEach, describe, expect, it, vi } from "vitest";
3+
import type { ChannelMessagingAdapter } from "../channels/plugins/types.js";
34
import type { OpenClawConfig } from "../config/config.js";
5+
import { createTestRegistry } from "../test-utils/channel-plugins.js";
46

57
const callGatewayMock = vi.fn();
68
vi.mock("../gateway/call.js", () => ({
@@ -28,6 +30,7 @@ vi.mock("../config/config.js", async () => {
2830
});
2931

3032
import "./test-helpers/fast-openclaw-tools-sessions.js";
33+
import { setActivePluginRegistry } from "../plugins/runtime.js";
3134
import { __testing as agentStepTesting } from "./tools/agent-step.js";
3235
import { createSessionsHistoryTool } from "./tools/sessions-history-tool.js";
3336
import { createSessionsListTool } from "./tools/sessions-list-tool.js";
@@ -47,6 +50,71 @@ const TEST_CONFIG = {
4750
},
4851
} as OpenClawConfig;
4952

53+
const resolveSessionConversationStub: NonNullable<
54+
ChannelMessagingAdapter["resolveSessionConversation"]
55+
> = ({ rawId }) => ({
56+
id: rawId,
57+
});
58+
const resolveSessionTargetStub: NonNullable<ChannelMessagingAdapter["resolveSessionTarget"]> = ({
59+
kind,
60+
id,
61+
threadId,
62+
}) => (threadId ? `${kind}:${id}:thread:${threadId}` : `${kind}:${id}`);
63+
64+
function installMessagingTestRegistry() {
65+
setActivePluginRegistry(
66+
createTestRegistry([
67+
{
68+
pluginId: "discord",
69+
source: "test",
70+
plugin: {
71+
id: "discord",
72+
meta: {
73+
id: "discord",
74+
label: "Discord",
75+
selectionLabel: "Discord",
76+
docsPath: "/channels/discord",
77+
blurb: "Discord test stub.",
78+
},
79+
capabilities: { chatTypes: ["direct", "channel", "thread"] },
80+
messaging: {
81+
resolveSessionConversation: resolveSessionConversationStub,
82+
resolveSessionTarget: resolveSessionTargetStub,
83+
},
84+
config: {
85+
listAccountIds: () => ["default"],
86+
resolveAccount: () => ({}),
87+
},
88+
},
89+
},
90+
{
91+
pluginId: "whatsapp",
92+
source: "test",
93+
plugin: {
94+
id: "whatsapp",
95+
meta: {
96+
id: "whatsapp",
97+
label: "WhatsApp",
98+
selectionLabel: "WhatsApp",
99+
docsPath: "/channels/whatsapp",
100+
blurb: "WhatsApp test stub.",
101+
preferSessionLookupForAnnounceTarget: true,
102+
},
103+
capabilities: { chatTypes: ["direct", "group"] },
104+
messaging: {
105+
resolveSessionConversation: resolveSessionConversationStub,
106+
resolveSessionTarget: resolveSessionTargetStub,
107+
},
108+
config: {
109+
listAccountIds: () => ["default"],
110+
resolveAccount: () => ({}),
111+
},
112+
},
113+
},
114+
]),
115+
);
116+
}
117+
50118
function createOpenClawTools(options?: {
51119
agentSessionKey?: string;
52120
agentChannel?: string;
@@ -90,6 +158,7 @@ const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2
90158
describe("sessions tools", () => {
91159
beforeEach(() => {
92160
callGatewayMock.mockClear();
161+
installMessagingTestRegistry();
93162
agentStepTesting.setDepsForTest({
94163
callGateway: (opts: unknown) => callGatewayMock(opts),
95164
});
@@ -894,4 +963,133 @@ describe("sessions tools", () => {
894963
message: "announce now",
895964
});
896965
});
966+
967+
it("sessions_send preserves threadId when announce target is hydrated via sessions.list", async () => {
968+
const calls: Array<{ method?: string; params?: unknown }> = [];
969+
let agentCallCount = 0;
970+
let lastWaitedRunId: string | undefined;
971+
const replyByRunId = new Map<string, string>();
972+
const requesterKey = "discord:group:req";
973+
const targetKey = "agent:main:worker";
974+
let sendParams: {
975+
to?: string;
976+
channel?: string;
977+
accountId?: string;
978+
message?: string;
979+
threadId?: string;
980+
} = {};
981+
982+
callGatewayMock.mockImplementation(async (opts: unknown) => {
983+
const request = opts as { method?: string; params?: unknown };
984+
calls.push(request);
985+
if (request.method === "agent") {
986+
agentCallCount += 1;
987+
const runId = `run-${agentCallCount}`;
988+
const params = request.params as
989+
| {
990+
sessionKey?: string;
991+
extraSystemPrompt?: string;
992+
}
993+
| undefined;
994+
let reply = "initial";
995+
if (params?.extraSystemPrompt?.includes("Agent-to-agent reply step")) {
996+
reply = params.sessionKey === requesterKey ? "pong-1" : "pong-2";
997+
}
998+
if (params?.extraSystemPrompt?.includes("Agent-to-agent announce step")) {
999+
reply = "announce now";
1000+
}
1001+
replyByRunId.set(runId, reply);
1002+
return {
1003+
runId,
1004+
status: "accepted",
1005+
acceptedAt: 3000 + agentCallCount,
1006+
};
1007+
}
1008+
if (request.method === "agent.wait") {
1009+
const params = request.params as { runId?: string } | undefined;
1010+
lastWaitedRunId = params?.runId;
1011+
return { runId: params?.runId ?? "run-1", status: "ok" };
1012+
}
1013+
if (request.method === "chat.history") {
1014+
const text = (lastWaitedRunId && replyByRunId.get(lastWaitedRunId)) ?? "";
1015+
return {
1016+
messages: [
1017+
{
1018+
role: "assistant",
1019+
content: [{ type: "text", text }],
1020+
timestamp: 20,
1021+
},
1022+
],
1023+
};
1024+
}
1025+
if (request.method === "sessions.list") {
1026+
return {
1027+
sessions: [
1028+
{
1029+
key: targetKey,
1030+
deliveryContext: {
1031+
channel: "whatsapp",
1032+
to: "123@g.us",
1033+
accountId: "work",
1034+
threadId: 99,
1035+
},
1036+
},
1037+
],
1038+
};
1039+
}
1040+
if (request.method === "send") {
1041+
const params = request.params as
1042+
| {
1043+
to?: string;
1044+
channel?: string;
1045+
accountId?: string;
1046+
message?: string;
1047+
threadId?: string;
1048+
}
1049+
| undefined;
1050+
sendParams = {
1051+
to: params?.to,
1052+
channel: params?.channel,
1053+
accountId: params?.accountId,
1054+
message: params?.message,
1055+
threadId: params?.threadId,
1056+
};
1057+
return { messageId: "m-threaded-announce" };
1058+
}
1059+
return {};
1060+
});
1061+
1062+
const tool = createOpenClawTools({
1063+
agentSessionKey: requesterKey,
1064+
agentChannel: "discord",
1065+
}).find((candidate) => candidate.name === "sessions_send");
1066+
expect(tool).toBeDefined();
1067+
if (!tool) {
1068+
throw new Error("missing sessions_send tool");
1069+
}
1070+
1071+
const waited = await tool.execute("call-thread", {
1072+
sessionKey: targetKey,
1073+
message: "ping",
1074+
timeoutSeconds: 1,
1075+
});
1076+
expect(waited.details).toMatchObject({
1077+
status: "ok",
1078+
reply: "initial",
1079+
});
1080+
await vi.waitFor(
1081+
() => {
1082+
expect(calls.filter((call) => call.method === "send")).toHaveLength(1);
1083+
},
1084+
{ timeout: 2_000, interval: 5 },
1085+
);
1086+
1087+
expect(sendParams).toMatchObject({
1088+
to: "123@g.us",
1089+
channel: "whatsapp",
1090+
accountId: "work",
1091+
message: "announce now",
1092+
threadId: "99",
1093+
});
1094+
});
8971095
});

src/agents/tools/sessions-announce-target.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { getChannelPlugin, normalizeChannelId } from "../../channels/plugins/index.js";
22
import { callGateway } from "../../gateway/call.js";
3+
import { normalizeOptionalStringifiedId } from "../../shared/string-coerce.js";
34
import { SessionListRow } from "./sessions-helpers.js";
45
import type { AnnounceTarget } from "./sessions-send-helpers.js";
56
import { resolveAnnounceTargetFromKey } from "./sessions-send-helpers.js";
@@ -53,8 +54,11 @@ export async function resolveAnnounceTarget(params: {
5354
(typeof deliveryContext?.accountId === "string" ? deliveryContext.accountId : undefined) ??
5455
(typeof match?.lastAccountId === "string" ? match.lastAccountId : undefined) ??
5556
(typeof origin?.accountId === "string" ? origin.accountId : undefined);
57+
const threadId = normalizeOptionalStringifiedId(
58+
deliveryContext?.threadId ?? match?.lastThreadId,
59+
);
5660
if (channel && to) {
57-
return { channel, to, accountId };
61+
return { channel, to, accountId, threadId };
5862
}
5963
} catch {
6064
// ignore

src/agents/tools/sessions-helpers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ export type SessionListRow = {
8383
lastChannel?: string;
8484
lastTo?: string;
8585
lastAccountId?: string;
86+
lastThreadId?: string | number;
8687
transcriptPath?: string;
8788
messages?: unknown[];
8889
};

src/agents/tools/sessions.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,7 @@ describe("resolveAnnounceTarget", () => {
288288
channel: "whatsapp",
289289
to: "123@g.us",
290290
accountId: "work",
291+
threadId: 99,
291292
},
292293
},
293294
],
@@ -301,6 +302,7 @@ describe("resolveAnnounceTarget", () => {
301302
channel: "whatsapp",
302303
to: "123@g.us",
303304
accountId: "work",
305+
threadId: "99",
304306
});
305307
expect(callGatewayMock).toHaveBeenCalledTimes(1);
306308
const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined;
@@ -318,6 +320,7 @@ describe("resolveAnnounceTarget", () => {
318320
accountId: "work",
319321
},
320322
lastTo: "123@g.us",
323+
lastThreadId: 271,
321324
},
322325
],
323326
});
@@ -330,6 +333,7 @@ describe("resolveAnnounceTarget", () => {
330333
channel: "whatsapp",
331334
to: "123@g.us",
332335
accountId: "work",
336+
threadId: "271",
333337
});
334338
});
335339
});

0 commit comments

Comments
 (0)