diff --git a/CHANGELOG.md b/CHANGELOG.md index 696b50df1b4..b8d18806f79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,6 +58,7 @@ Docs: https://docs.openclaw.ai - Hooks/doctor: warn when `hooks.transformsDir` points outside the canonical hooks transform directory, so invalid workspace skill paths get a direct recovery hint before the Gateway crash-loops. Fixes #75853. Thanks @midobk. - Proxy/audio: convert standard `FormData` bodies before proxy-backed undici fetches, so audio transcription and multipart uploads no longer send `[object FormData]` when `HTTP_PROXY` or `HTTPS_PROXY` is configured. Fixes #48554. Thanks @dco5. - Discord: allow explicitly configured ack reactions in tool-only guild channels while keeping automatic lifecycle/status reactions suppressed. Fixes #74922. Thanks @samvilian and @BlueBirdBack. +- Discord: enable session-backed A2A announce target lookup so `sessions_send` uses the target session's `deliveryContext.accountId` or `lastAccountId` instead of falling back to the default bot in multi-account setups. Fixes #42652; refs #51626 and #44773; supersedes #73975. Thanks @irchelper, @dpalfox, and @Lanfei. - Discord: treat abort-time Carbon reconnect-exhausted events as expected shutdown during stale-socket restarts, so health-monitor restarts no longer reject the monitor lifecycle. Carries forward #58216; supersedes #73949. Thanks @Perttulands. - Discord/native commands: return an explicit warning when slash command dispatch or direct plugin execution produces no visible reply instead of a success-style completion ack. Fixes #58986; supersedes #62057. Thanks @jb510. - Discord: keep typing indicators alive during long tool runs and auto-compaction while keepalive ticks continue, so active sessions do not appear stalled before the final reply. Thanks @Squirbie. diff --git a/extensions/discord/package.json b/extensions/discord/package.json index f7c06f91565..3ff11fb2b27 100644 --- a/extensions/discord/package.json +++ b/extensions/discord/package.json @@ -43,6 +43,7 @@ "blurb": "very well supported right now.", "systemImage": "bubble.left.and.bubble.right", "markdownCapable": true, + "preferSessionLookupForAnnounceTarget": true, "commands": { "nativeCommandsAutoEnabled": true, "nativeSkillsAutoEnabled": true diff --git a/extensions/discord/src/channel-api.ts b/extensions/discord/src/channel-api.ts index 452b8fa068d..1156ff45c34 100644 --- a/extensions/discord/src/channel-api.ts +++ b/extensions/discord/src/channel-api.ts @@ -18,6 +18,7 @@ const DISCORD_CHANNEL_META = { blurb: "very well supported right now.", systemImage: "bubble.left.and.bubble.right", markdownCapable: true, + preferSessionLookupForAnnounceTarget: true, } as const; export function getChatChannelMeta(id: string) { diff --git a/extensions/discord/src/shared.test.ts b/extensions/discord/src/shared.test.ts index 93b6ae37b40..0e2209dc2e4 100644 --- a/extensions/discord/src/shared.test.ts +++ b/extensions/discord/src/shared.test.ts @@ -32,6 +32,12 @@ describe("createDiscordPluginBase", () => { expect(plugin.security?.collectAuditFindings).toBeTypeOf("function"); }); + it("hydrates announce delivery targets from stored session routing", () => { + const plugin = createDiscordPluginBase({ setup: {} as never }); + + expect(plugin.meta.preferSessionLookupForAnnounceTarget).toBe(true); + }); + it("reports duplicate-token accounts as disabled to gateway startup", () => { vi.stubEnv("DISCORD_BOT_TOKEN", "same-token"); const plugin = createDiscordPluginBase({ setup: {} as never }); diff --git a/src/agents/tools/sessions-send-tool.a2a.test.ts b/src/agents/tools/sessions-send-tool.a2a.test.ts index a700f704766..310ffeabf22 100644 --- a/src/agents/tools/sessions-send-tool.a2a.test.ts +++ b/src/agents/tools/sessions-send-tool.a2a.test.ts @@ -3,8 +3,15 @@ import type { CallGatewayOptions } from "../../gateway/call.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createSessionConversationTestRegistry } from "../../test-utils/session-conversation-registry.js"; import { runAgentStep } from "./agent-step.js"; +import type { SessionListRow } from "./sessions-helpers.js"; import { runSessionsSendA2AFlow, __testing } from "./sessions-send-tool.a2a.js"; +const callGatewayMock = vi.hoisted(() => vi.fn()); + +vi.mock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), +})); + vi.mock("../run-wait.js", () => ({ waitForAgentRun: vi.fn().mockResolvedValue({ status: "ok" }), readLatestAssistantReply: vi.fn().mockResolvedValue("Test announce reply"), @@ -16,17 +23,25 @@ vi.mock("./agent-step.js", () => ({ describe("runSessionsSendA2AFlow announce delivery", () => { let gatewayCalls: CallGatewayOptions[]; + let sessionListRows: SessionListRow[]; beforeEach(() => { setActivePluginRegistry(createSessionConversationTestRegistry()); gatewayCalls = []; + sessionListRows = []; + callGatewayMock.mockReset(); + const callGateway = async >(opts: CallGatewayOptions) => { + gatewayCalls.push(opts); + if (opts.method === "sessions.list") { + return { sessions: sessionListRows } as T; + } + return {} as T; + }; + callGatewayMock.mockImplementation(callGateway); vi.clearAllMocks(); vi.mocked(runAgentStep).mockResolvedValue("Test announce reply"); __testing.setDepsForTest({ - callGateway: async >(opts: CallGatewayOptions) => { - gatewayCalls.push(opts); - return {} as T; - }, + callGateway, }); }); @@ -70,6 +85,55 @@ describe("runSessionsSendA2AFlow announce delivery", () => { expect(sendParams.threadId).toBeUndefined(); }); + it.each([ + { + source: "deliveryContext.accountId", + accountId: "thinker", + session: { + key: "agent:main:discord:channel:target-room", + kind: "group", + channel: "discord", + deliveryContext: { + channel: "discord", + to: "channel:target-room", + accountId: "thinker", + }, + } satisfies SessionListRow, + }, + { + source: "lastAccountId", + accountId: "scout", + session: { + key: "agent:main:discord:channel:target-room", + kind: "group", + channel: "discord", + lastChannel: "discord", + lastTo: "channel:target-room", + lastAccountId: "scout", + } satisfies SessionListRow, + }, + ])("uses Discord session $source for announce accountId", async ({ accountId, session }) => { + sessionListRows = [session]; + + await runSessionsSendA2AFlow({ + targetSessionKey: session.key, + displayKey: session.key, + message: "Test message", + announceTimeoutMs: 10_000, + maxPingPongTurns: 0, + roundOneReply: "Worker completed successfully", + }); + + expect(gatewayCalls.some((call) => call.method === "sessions.list")).toBe(true); + const sendCall = gatewayCalls.find((call) => call.method === "send"); + expect(sendCall).toBeDefined(); + expect(sendCall?.params).toMatchObject({ + channel: "discord", + to: "channel:target-room", + accountId, + }); + }); + it.each(["NO_REPLY", "HEARTBEAT_OK", "ANNOUNCE_SKIP", "REPLY_SKIP"])( "does not re-inject exact control reply %s into agent-to-agent flow", async (roundOneReply) => { diff --git a/src/test-utils/session-conversation-registry.ts b/src/test-utils/session-conversation-registry.ts index c691dc75009..376a0339b88 100644 --- a/src/test-utils/session-conversation-registry.ts +++ b/src/test-utils/session-conversation-registry.ts @@ -62,6 +62,7 @@ export function createSessionConversationTestRegistry() { selectionLabel: "Discord", docsPath: "/channels/discord", blurb: "Discord test stub.", + preferSessionLookupForAnnounceTarget: true, }, capabilities: { chatTypes: ["direct", "channel", "thread"] }, messaging: {