diff --git a/src/agents/subagent-announce.format.test.ts b/src/agents/subagent-announce.format.test.ts index 91f4b0d6752..8952e82cc68 100644 --- a/src/agents/subagent-announce.format.test.ts +++ b/src/agents/subagent-announce.format.test.ts @@ -825,6 +825,47 @@ describe("subagent announce formatting", () => { } }); + it("routes manual completion direct-send for telegram forum topics", async () => { + sendSpy.mockClear(); + agentSpy.mockClear(); + sessionStore = { + "agent:main:subagent:test": { + sessionId: "child-session-telegram-topic", + }, + "agent:main:main": { + sessionId: "requester-session-telegram-topic", + lastChannel: "telegram", + lastTo: "123:topic:999", + lastThreadId: 999, + }, + }; + chatHistoryMock.mockResolvedValueOnce({ + messages: [{ role: "assistant", content: [{ type: "text", text: "done" }] }], + }); + + const didAnnounce = await runSubagentAnnounceFlow({ + childSessionKey: "agent:main:subagent:test", + childRunId: "run-direct-telegram-topic", + requesterSessionKey: "agent:main:main", + requesterDisplayKey: "main", + requesterOrigin: { + channel: "telegram", + to: "123", + threadId: 42, + }, + ...defaultOutcomeAnnounce, + expectsCompletionMessage: true, + }); + + expect(didAnnounce).toBe(true); + expect(sendSpy).toHaveBeenCalledTimes(1); + expect(agentSpy).not.toHaveBeenCalled(); + const call = sendSpy.mock.calls[0]?.[0] as { params?: Record }; + expect(call?.params?.channel).toBe("telegram"); + expect(call?.params?.to).toBe("123"); + expect(call?.params?.threadId).toBe("42"); + }); + it("uses hook-provided thread target across requester thread variants", async () => { const cases = [ { diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 7734de8e911..0e3bfba668c 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -1,5 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; +import { setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { sendHandlers } from "./send.js"; import type { GatewayRequestContext } from "./types.js"; @@ -10,6 +12,8 @@ const mocks = vi.hoisted(() => ({ resolveOutboundTarget: vi.fn(() => ({ ok: true, to: "resolved" })), resolveMessageChannelSelection: vi.fn(), sendPoll: vi.fn(async () => ({ messageId: "poll-1" })), + getChannelPlugin: vi.fn(), + loadOpenClawPlugins: vi.fn(), })); vi.mock("../../config/config.js", async () => { @@ -22,10 +26,38 @@ vi.mock("../../config/config.js", async () => { }); vi.mock("../../channels/plugins/index.js", () => ({ - getChannelPlugin: () => ({ outbound: { sendPoll: mocks.sendPoll } }), + getChannelPlugin: mocks.getChannelPlugin, normalizeChannelId: (value: string) => (value === "webchat" ? null : value), })); +vi.mock("../../agents/agent-scope.js", () => ({ + resolveSessionAgentId: ({ + sessionKey, + }: { + sessionKey?: string; + config?: unknown; + agentId?: string; + }) => { + if (typeof sessionKey === "string") { + const match = sessionKey.match(/^agent:([^:]+)/i); + if (match?.[1]) { + return match[1]; + } + } + return "main"; + }, + resolveDefaultAgentId: () => "main", + resolveAgentWorkspaceDir: () => "/tmp/openclaw-test-workspace", +})); + +vi.mock("../../config/plugin-auto-enable.js", () => ({ + applyPluginAutoEnable: ({ config }: { config: unknown }) => ({ config, changes: [] }), +})); + +vi.mock("../../plugins/loader.js", () => ({ + loadOpenClawPlugins: mocks.loadOpenClawPlugins, +})); + vi.mock("../../infra/outbound/targets.js", () => ({ resolveOutboundTarget: mocks.resolveOutboundTarget, })); @@ -85,14 +117,19 @@ function mockDeliverySuccess(messageId: string) { } describe("gateway send mirroring", () => { + let registrySeq = 0; + beforeEach(() => { vi.clearAllMocks(); + registrySeq += 1; + setActivePluginRegistry(createTestRegistry([]), `send-test-${registrySeq}`); mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "resolved" }); mocks.resolveMessageChannelSelection.mockResolvedValue({ channel: "slack", configured: ["slack"], }); mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" }); + mocks.getChannelPlugin.mockReturnValue({ outbound: { sendPoll: mocks.sendPoll } }); }); it("accepts media-only sends without message", async () => { @@ -475,4 +512,39 @@ describe("gateway send mirroring", () => { }), ); }); + + it("recovers cold plugin resolution for telegram threaded sends", async () => { + mocks.resolveOutboundTarget.mockReturnValue({ ok: true, to: "123" }); + mocks.deliverOutboundPayloads.mockResolvedValue([ + { messageId: "m-telegram", channel: "telegram" }, + ]); + const telegramPlugin = { outbound: { sendPoll: mocks.sendPoll } }; + mocks.getChannelPlugin + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(telegramPlugin) + .mockReturnValue(telegramPlugin); + + const { respond } = await runSend({ + to: "123", + message: "forum completion", + channel: "telegram", + threadId: "42", + idempotencyKey: "idem-cold-telegram-thread", + }); + + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); + expect(mocks.deliverOutboundPayloads).toHaveBeenCalledWith( + expect.objectContaining({ + channel: "telegram", + to: "123", + threadId: "42", + }), + ); + expect(respond).toHaveBeenCalledWith( + true, + expect.objectContaining({ messageId: "m-telegram" }), + undefined, + expect.objectContaining({ channel: "telegram" }), + ); + }); }); diff --git a/src/infra/outbound/channel-resolution.ts b/src/infra/outbound/channel-resolution.ts index 58596da93f3..8d17294d024 100644 --- a/src/infra/outbound/channel-resolution.ts +++ b/src/infra/outbound/channel-resolution.ts @@ -47,10 +47,15 @@ function maybeBootstrapChannelPlugin(params: { const autoEnabled = applyPluginAutoEnable({ config: cfg }).config; const defaultAgentId = resolveDefaultAgentId(autoEnabled); const workspaceDir = resolveAgentWorkspaceDir(autoEnabled, defaultAgentId); - loadOpenClawPlugins({ - config: autoEnabled, - workspaceDir, - }); + try { + loadOpenClawPlugins({ + config: autoEnabled, + workspaceDir, + }); + } catch { + // Allow a follow-up resolution attempt if bootstrap failed transiently. + bootstrapAttempts.delete(attemptKey); + } } export function resolveOutboundChannelPlugin(params: { diff --git a/src/infra/outbound/targets.channel-resolution.test.ts b/src/infra/outbound/targets.channel-resolution.test.ts index 615134f654b..01779d0655c 100644 --- a/src/infra/outbound/targets.channel-resolution.test.ts +++ b/src/infra/outbound/targets.channel-resolution.test.ts @@ -28,8 +28,11 @@ import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { resolveOutboundTarget } from "./targets.js"; describe("resolveOutboundTarget channel resolution", () => { + let registrySeq = 0; + beforeEach(() => { - setActivePluginRegistry(createTestRegistry([])); + registrySeq += 1; + setActivePluginRegistry(createTestRegistry([]), `targets-test-${registrySeq}`); mocks.getChannelPlugin.mockReset(); mocks.loadOpenClawPlugins.mockReset(); }); @@ -58,4 +61,43 @@ describe("resolveOutboundTarget channel resolution", () => { expect(result).toEqual({ ok: true, to: "123456" }); expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(1); }); + + it("retries bootstrap on subsequent resolve when the first bootstrap attempt fails", () => { + const telegramPlugin = { + id: "telegram", + meta: { label: "Telegram" }, + config: { + listAccountIds: () => [], + resolveAccount: () => ({}), + }, + }; + mocks.getChannelPlugin + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(undefined) + .mockReturnValueOnce(telegramPlugin) + .mockReturnValue(telegramPlugin); + mocks.loadOpenClawPlugins + .mockImplementationOnce(() => { + throw new Error("bootstrap failed"); + }) + .mockImplementation(() => undefined); + + const first = resolveOutboundTarget({ + channel: "telegram", + to: "123456", + cfg: { channels: { telegram: { botToken: "test-token" } } }, + mode: "explicit", + }); + const second = resolveOutboundTarget({ + channel: "telegram", + to: "123456", + cfg: { channels: { telegram: { botToken: "test-token" } } }, + mode: "explicit", + }); + + expect(first.ok).toBe(false); + expect(second).toEqual({ ok: true, to: "123456" }); + expect(mocks.loadOpenClawPlugins).toHaveBeenCalledTimes(2); + }); }); diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index afd616b5f15..b589fdcf52b 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -1280,6 +1280,23 @@ describe("sendStickerTelegram", () => { expect(sendSticker).toHaveBeenNthCalledWith(2, chatId, "fileId123", undefined); expect(res.messageId).toBe("109"); }); + + it("fails when sticker send returns no message_id", async () => { + const chatId = "123"; + const sendSticker = vi.fn().mockResolvedValue({ + chat: { id: chatId }, + }); + const api = { sendSticker } as unknown as { + sendSticker: typeof sendSticker; + }; + + await expect( + sendStickerTelegram(chatId, "fileId123", { + token: "tok", + api, + }), + ).rejects.toThrow(/returned no message_id/i); + }); }); describe("shared send behaviors", () => { @@ -1542,6 +1559,20 @@ describe("sendPollTelegram", () => { expect(api.sendPoll).not.toHaveBeenCalled(); }); + + it("fails when poll send returns no message_id", async () => { + const api = { + sendPoll: vi.fn(async () => ({ chat: { id: 555 }, poll: { id: "p1" } })), + }; + + await expect( + sendPollTelegram( + "123", + { question: "Q", options: ["A", "B"] }, + { token: "t", api: api as unknown as Bot["api"] }, + ), + ).rejects.toThrow(/returned no message_id/i); + }); }); describe("createForumTopicTelegram", () => {