diff --git a/src/agents/openclaw-tools.session-status.e2e.test.ts b/src/agents/openclaw-tools.session-status.e2e.test.ts index 99bce1757..1793738c0 100644 --- a/src/agents/openclaw-tools.session-status.e2e.test.ts +++ b/src/agents/openclaw-tools.session-status.e2e.test.ts @@ -79,24 +79,33 @@ vi.mock("../infra/provider-usage.js", () => ({ import "./test-helpers/fast-core-tools.js"; import { createOpenClawTools } from "./openclaw-tools.js"; +function resetSessionStore(store: Record) { + loadSessionStoreMock.mockReset(); + updateSessionStoreMock.mockReset(); + loadSessionStoreMock.mockReturnValue(store); +} + +function getSessionStatusTool(agentSessionKey = "main") { + const tool = createOpenClawTools({ agentSessionKey }).find( + (candidate) => candidate.name === "session_status", + ); + expect(tool).toBeDefined(); + if (!tool) { + throw new Error("missing session_status tool"); + } + return tool; +} + describe("session_status tool", () => { it("returns a status card for the current session", async () => { - loadSessionStoreMock.mockReset(); - updateSessionStoreMock.mockReset(); - loadSessionStoreMock.mockReturnValue({ + resetSessionStore({ main: { sessionId: "s1", updatedAt: 10, }, }); - const tool = createOpenClawTools({ agentSessionKey: "main" }).find( - (candidate) => candidate.name === "session_status", - ); - expect(tool).toBeDefined(); - if (!tool) { - throw new Error("missing session_status tool"); - } + const tool = getSessionStatusTool(); const result = await tool.execute("call1", {}); const details = result.details as { ok?: boolean; statusText?: string }; @@ -107,19 +116,11 @@ describe("session_status tool", () => { }); it("errors for unknown session keys", async () => { - loadSessionStoreMock.mockReset(); - updateSessionStoreMock.mockReset(); - loadSessionStoreMock.mockReturnValue({ + resetSessionStore({ main: { sessionId: "s1", updatedAt: 10 }, }); - const tool = createOpenClawTools({ agentSessionKey: "main" }).find( - (candidate) => candidate.name === "session_status", - ); - expect(tool).toBeDefined(); - if (!tool) { - throw new Error("missing session_status tool"); - } + const tool = getSessionStatusTool(); await expect(tool.execute("call2", { sessionKey: "nope" })).rejects.toThrow( "Unknown sessionId", @@ -128,23 +129,15 @@ describe("session_status tool", () => { }); it("resolves sessionId inputs", async () => { - loadSessionStoreMock.mockReset(); - updateSessionStoreMock.mockReset(); const sessionId = "sess-main"; - loadSessionStoreMock.mockReturnValue({ + resetSessionStore({ "agent:main:main": { sessionId, updatedAt: 10, }, }); - const tool = createOpenClawTools({ agentSessionKey: "main" }).find( - (candidate) => candidate.name === "session_status", - ); - expect(tool).toBeDefined(); - if (!tool) { - throw new Error("missing session_status tool"); - } + const tool = getSessionStatusTool(); const result = await tool.execute("call3", { sessionKey: sessionId }); const details = result.details as { ok?: boolean; sessionKey?: string }; @@ -153,22 +146,14 @@ describe("session_status tool", () => { }); it("uses non-standard session keys without sessionId resolution", async () => { - loadSessionStoreMock.mockReset(); - updateSessionStoreMock.mockReset(); - loadSessionStoreMock.mockReturnValue({ + resetSessionStore({ "temp:slug-generator": { sessionId: "sess-temp", updatedAt: 10, }, }); - const tool = createOpenClawTools({ agentSessionKey: "main" }).find( - (candidate) => candidate.name === "session_status", - ); - expect(tool).toBeDefined(); - if (!tool) { - throw new Error("missing session_status tool"); - } + const tool = getSessionStatusTool(); const result = await tool.execute("call4", { sessionKey: "temp:slug-generator" }); const details = result.details as { ok?: boolean; sessionKey?: string }; @@ -177,22 +162,14 @@ describe("session_status tool", () => { }); it("blocks cross-agent session_status without agent-to-agent access", async () => { - loadSessionStoreMock.mockReset(); - updateSessionStoreMock.mockReset(); - loadSessionStoreMock.mockReturnValue({ + resetSessionStore({ "agent:other:main": { sessionId: "s2", updatedAt: 10, }, }); - const tool = createOpenClawTools({ agentSessionKey: "agent:main:main" }).find( - (candidate) => candidate.name === "session_status", - ); - expect(tool).toBeDefined(); - if (!tool) { - throw new Error("missing session_status tool"); - } + const tool = getSessionStatusTool("agent:main:main"); await expect(tool.execute("call5", { sessionKey: "agent:other:main" })).rejects.toThrow( "Agent-to-agent status is disabled", @@ -228,13 +205,7 @@ describe("session_status tool", () => { }, ); - const tool = createOpenClawTools({ agentSessionKey: "agent:support:main" }).find( - (candidate) => candidate.name === "session_status", - ); - expect(tool).toBeDefined(); - if (!tool) { - throw new Error("missing session_status tool"); - } + const tool = getSessionStatusTool("agent:support:main"); const result = await tool.execute("call6", { sessionKey: "main" }); const details = result.details as { ok?: boolean; sessionKey?: string }; @@ -243,9 +214,7 @@ describe("session_status tool", () => { }); it("resets per-session model override via model=default", async () => { - loadSessionStoreMock.mockReset(); - updateSessionStoreMock.mockReset(); - loadSessionStoreMock.mockReturnValue({ + resetSessionStore({ main: { sessionId: "s1", updatedAt: 10, @@ -255,13 +224,7 @@ describe("session_status tool", () => { }, }); - const tool = createOpenClawTools({ agentSessionKey: "main" }).find( - (candidate) => candidate.name === "session_status", - ); - expect(tool).toBeDefined(); - if (!tool) { - throw new Error("missing session_status tool"); - } + const tool = getSessionStatusTool(); await tool.execute("call3", { model: "default" }); expect(updateSessionStoreMock).toHaveBeenCalled(); diff --git a/src/agents/tools/discord-actions.e2e.test.ts b/src/agents/tools/discord-actions.e2e.test.ts index b95e5e85b..d73448071 100644 --- a/src/agents/tools/discord-actions.e2e.test.ts +++ b/src/agents/tools/discord-actions.e2e.test.ts @@ -5,60 +5,55 @@ import { handleDiscordMessagingAction } from "./discord-actions-messaging.js"; import { handleDiscordModerationAction } from "./discord-actions-moderation.js"; import { handleDiscordAction } from "./discord-actions.js"; -const createChannelDiscord = vi.fn(async () => ({ - id: "new-channel", - name: "test", - type: 0, +const discordSendMocks = vi.hoisted(() => ({ + banMemberDiscord: vi.fn(async () => ({})), + createChannelDiscord: vi.fn(async () => ({ + id: "new-channel", + name: "test", + type: 0, + })), + createThreadDiscord: vi.fn(async () => ({})), + deleteChannelDiscord: vi.fn(async () => ({ ok: true, channelId: "C1" })), + deleteMessageDiscord: vi.fn(async () => ({})), + editChannelDiscord: vi.fn(async () => ({ + id: "C1", + name: "edited", + })), + editMessageDiscord: vi.fn(async () => ({})), + fetchChannelPermissionsDiscord: vi.fn(async () => ({})), + fetchMessageDiscord: vi.fn(async () => ({})), + fetchReactionsDiscord: vi.fn(async () => ({})), + kickMemberDiscord: vi.fn(async () => ({})), + listGuildChannelsDiscord: vi.fn(async () => []), + listPinsDiscord: vi.fn(async () => ({})), + listThreadsDiscord: vi.fn(async () => ({})), + moveChannelDiscord: vi.fn(async () => ({ ok: true })), + pinMessageDiscord: vi.fn(async () => ({})), + reactMessageDiscord: vi.fn(async () => ({})), + readMessagesDiscord: vi.fn(async () => []), + removeChannelPermissionDiscord: vi.fn(async () => ({ ok: true })), + removeOwnReactionsDiscord: vi.fn(async () => ({ removed: ["👍"] })), + removeReactionDiscord: vi.fn(async () => ({})), + searchMessagesDiscord: vi.fn(async () => ({})), + sendMessageDiscord: vi.fn(async () => ({})), + sendPollDiscord: vi.fn(async () => ({})), + sendStickerDiscord: vi.fn(async () => ({})), + sendVoiceMessageDiscord: vi.fn(async () => ({})), + setChannelPermissionDiscord: vi.fn(async () => ({ ok: true })), + timeoutMemberDiscord: vi.fn(async () => ({})), + unpinMessageDiscord: vi.fn(async () => ({})), })); -const createThreadDiscord = vi.fn(async () => ({})); -const deleteChannelDiscord = vi.fn(async () => ({ ok: true, channelId: "C1" })); -const deleteMessageDiscord = vi.fn(async () => ({})); -const editChannelDiscord = vi.fn(async () => ({ - id: "C1", - name: "edited", -})); -const editMessageDiscord = vi.fn(async () => ({})); -const fetchMessageDiscord = vi.fn(async () => ({})); -const fetchChannelPermissionsDiscord = vi.fn(async () => ({})); -const fetchReactionsDiscord = vi.fn(async () => ({})); -const listGuildChannelsDiscord = vi.fn(async () => []); -const listPinsDiscord = vi.fn(async () => ({})); -const listThreadsDiscord = vi.fn(async () => ({})); -const moveChannelDiscord = vi.fn(async () => ({ ok: true })); -const pinMessageDiscord = vi.fn(async () => ({})); -const reactMessageDiscord = vi.fn(async () => ({})); -const readMessagesDiscord = vi.fn(async () => []); -const removeChannelPermissionDiscord = vi.fn(async () => ({ ok: true })); -const removeOwnReactionsDiscord = vi.fn(async () => ({ removed: ["👍"] })); -const removeReactionDiscord = vi.fn(async () => ({})); -const searchMessagesDiscord = vi.fn(async () => ({})); -const sendMessageDiscord = vi.fn(async () => ({})); -const sendVoiceMessageDiscord = vi.fn(async () => ({})); -const sendPollDiscord = vi.fn(async () => ({})); -const sendStickerDiscord = vi.fn(async () => ({})); -const setChannelPermissionDiscord = vi.fn(async () => ({ ok: true })); -const unpinMessageDiscord = vi.fn(async () => ({})); -const timeoutMemberDiscord = vi.fn(async () => ({})); -const kickMemberDiscord = vi.fn(async () => ({})); -const banMemberDiscord = vi.fn(async () => ({})); -vi.mock("../../discord/send.js", () => ({ - banMemberDiscord, +const { createChannelDiscord, createThreadDiscord, deleteChannelDiscord, - deleteMessageDiscord, editChannelDiscord, - editMessageDiscord, fetchMessageDiscord, - fetchChannelPermissionsDiscord, - fetchReactionsDiscord, kickMemberDiscord, listGuildChannelsDiscord, listPinsDiscord, - listThreadsDiscord, moveChannelDiscord, - pinMessageDiscord, reactMessageDiscord, readMessagesDiscord, removeChannelPermissionDiscord, @@ -67,11 +62,12 @@ vi.mock("../../discord/send.js", () => ({ searchMessagesDiscord, sendMessageDiscord, sendVoiceMessageDiscord, - sendPollDiscord, - sendStickerDiscord, setChannelPermissionDiscord, timeoutMemberDiscord, - unpinMessageDiscord, +} = discordSendMocks; + +vi.mock("../../discord/send.js", () => ({ + ...discordSendMocks, })); const enableAllActions = () => true; @@ -388,35 +384,15 @@ describe("handleDiscordGuildAction - channel management", () => { }); }); - it("clears the channel parent when parentId is null", async () => { + it.each([ + ["parentId is null", { parentId: null }], + ["clearParent is true", { clearParent: true }], + ])("clears the channel parent when %s", async (_label, payload) => { await handleDiscordGuildAction( "channelEdit", { channelId: "C1", - parentId: null, - }, - channelsEnabled, - ); - expect(editChannelDiscord).toHaveBeenCalledWith({ - channelId: "C1", - name: undefined, - topic: undefined, - position: undefined, - parentId: null, - nsfw: undefined, - rateLimitPerUser: undefined, - archived: undefined, - locked: undefined, - autoArchiveDuration: undefined, - }); - }); - - it("clears the channel parent when clearParent is true", async () => { - await handleDiscordGuildAction( - "channelEdit", - { - channelId: "C1", - clearParent: true, + ...payload, }, channelsEnabled, ); @@ -458,31 +434,16 @@ describe("handleDiscordGuildAction - channel management", () => { }); }); - it("clears the channel parent on move when parentId is null", async () => { + it.each([ + ["parentId is null", { parentId: null }], + ["clearParent is true", { clearParent: true }], + ])("clears the channel parent on move when %s", async (_label, payload) => { await handleDiscordGuildAction( "channelMove", { guildId: "G1", channelId: "C1", - parentId: null, - }, - channelsEnabled, - ); - expect(moveChannelDiscord).toHaveBeenCalledWith({ - guildId: "G1", - channelId: "C1", - parentId: null, - position: undefined, - }); - }); - - it("clears the channel parent on move when clearParent is true", async () => { - await handleDiscordGuildAction( - "channelMove", - { - guildId: "G1", - channelId: "C1", - clearParent: true, + ...payload, }, channelsEnabled, ); diff --git a/src/channels/plugins/actions/actions.test.ts b/src/channels/plugins/actions/actions.test.ts index 226138839..71d4fe090 100644 --- a/src/channels/plugins/actions/actions.test.ts +++ b/src/channels/plugins/actions/actions.test.ts @@ -63,6 +63,25 @@ function expectFirstSlackAction(expected: Record) { expect(params).toMatchObject(expected); } +function expectModerationActions(actions: string[]) { + expect(actions).toContain("timeout"); + expect(actions).toContain("kick"); + expect(actions).toContain("ban"); +} + +async function expectSlackSendRejected(params: Record, error: RegExp) { + const { cfg, actions } = slackHarness(); + await expect( + actions.handleAction?.({ + channel: "slack", + action: "send", + cfg, + params, + }), + ).rejects.toThrow(error); + expect(handleSlackAction).not.toHaveBeenCalled(); +} + beforeEach(() => { vi.clearAllMocks(); }); @@ -98,9 +117,7 @@ describe("discord message actions", () => { } as OpenClawConfig; const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - expect(actions).toContain("timeout"); - expect(actions).toContain("kick"); - expect(actions).toContain("ban"); + expectModerationActions(actions); }); it("lists moderation when one account enables and another omits", () => { @@ -116,9 +133,7 @@ describe("discord message actions", () => { } as OpenClawConfig; const actions = discordMessageActions.listActions?.({ cfg }) ?? []; - expect(actions).toContain("timeout"); - expect(actions).toContain("kick"); - expect(actions).toContain("ban"); + expectModerationActions(actions); }); it("omits moderation when all accounts omit it", () => { @@ -765,58 +780,37 @@ describe("slack actions adapter", () => { }); it("rejects invalid blocks JSON for send", async () => { - const { cfg, actions } = slackHarness(); - - await expect( - actions.handleAction?.({ - channel: "slack", - action: "send", - cfg, - params: { - to: "channel:C1", - message: "", - blocks: "{bad-json", - }, - }), - ).rejects.toThrow(/blocks must be valid JSON/i); - expect(handleSlackAction).not.toHaveBeenCalled(); + await expectSlackSendRejected( + { + to: "channel:C1", + message: "", + blocks: "{bad-json", + }, + /blocks must be valid JSON/i, + ); }); it("rejects empty blocks arrays for send", async () => { - const { cfg, actions } = slackHarness(); - - await expect( - actions.handleAction?.({ - channel: "slack", - action: "send", - cfg, - params: { - to: "channel:C1", - message: "", - blocks: "[]", - }, - }), - ).rejects.toThrow(/at least one block/i); - expect(handleSlackAction).not.toHaveBeenCalled(); + await expectSlackSendRejected( + { + to: "channel:C1", + message: "", + blocks: "[]", + }, + /at least one block/i, + ); }); it("rejects send when both blocks and media are provided", async () => { - const { cfg, actions } = slackHarness(); - - await expect( - actions.handleAction?.({ - channel: "slack", - action: "send", - cfg, - params: { - to: "channel:C1", - message: "", - media: "https://example.com/image.png", - blocks: JSON.stringify([{ type: "divider" }]), - }, - }), - ).rejects.toThrow(/does not support blocks with media/i); - expect(handleSlackAction).not.toHaveBeenCalled(); + await expectSlackSendRejected( + { + to: "channel:C1", + message: "", + media: "https://example.com/image.png", + blocks: JSON.stringify([{ type: "divider" }]), + }, + /does not support blocks with media/i, + ); }); it("forwards blocks JSON for edit", async () => { diff --git a/src/commands/doctor-gateway-services.test.ts b/src/commands/doctor-gateway-services.test.ts index 4ab73e1f1..e80954a63 100644 --- a/src/commands/doctor-gateway-services.test.ts +++ b/src/commands/doctor-gateway-services.test.ts @@ -71,36 +71,48 @@ async function runRepair(cfg: OpenClawConfig) { await maybeRepairGatewayServiceConfig(cfg, "local", makeDoctorIo(), makeDoctorPrompts()); } +const gatewayProgramArguments = [ + "/usr/bin/node", + "/usr/local/bin/openclaw", + "gateway", + "--port", + "18789", +]; + +function setupGatewayTokenRepairScenario(expectedToken: string) { + mocks.readCommand.mockResolvedValue({ + programArguments: gatewayProgramArguments, + environment: { + OPENCLAW_GATEWAY_TOKEN: "stale-token", + }, + }); + mocks.auditGatewayServiceConfig.mockResolvedValue({ + ok: false, + issues: [ + { + code: "gateway-token-mismatch", + message: "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token", + level: "recommended", + }, + ], + }); + mocks.buildGatewayInstallPlan.mockResolvedValue({ + programArguments: gatewayProgramArguments, + workingDirectory: "/tmp", + environment: { + OPENCLAW_GATEWAY_TOKEN: expectedToken, + }, + }); + mocks.install.mockResolvedValue(undefined); +} + describe("maybeRepairGatewayServiceConfig", () => { beforeEach(() => { vi.clearAllMocks(); }); it("treats gateway.auth.token as source of truth for service token repairs", async () => { - mocks.readCommand.mockResolvedValue({ - programArguments: ["/usr/bin/node", "/usr/local/bin/openclaw", "gateway", "--port", "18789"], - environment: { - OPENCLAW_GATEWAY_TOKEN: "stale-token", - }, - }); - mocks.auditGatewayServiceConfig.mockResolvedValue({ - ok: false, - issues: [ - { - code: "gateway-token-mismatch", - message: "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token", - level: "recommended", - }, - ], - }); - mocks.buildGatewayInstallPlan.mockResolvedValue({ - programArguments: ["/usr/bin/node", "/usr/local/bin/openclaw", "gateway", "--port", "18789"], - workingDirectory: "/tmp", - environment: { - OPENCLAW_GATEWAY_TOKEN: "config-token", - }, - }); - mocks.install.mockResolvedValue(undefined); + setupGatewayTokenRepairScenario("config-token"); const cfg: OpenClawConfig = { gateway: { @@ -130,42 +142,7 @@ describe("maybeRepairGatewayServiceConfig", () => { const previousToken = process.env.OPENCLAW_GATEWAY_TOKEN; process.env.OPENCLAW_GATEWAY_TOKEN = "env-token"; try { - mocks.readCommand.mockResolvedValue({ - programArguments: [ - "/usr/bin/node", - "/usr/local/bin/openclaw", - "gateway", - "--port", - "18789", - ], - environment: { - OPENCLAW_GATEWAY_TOKEN: "stale-token", - }, - }); - mocks.auditGatewayServiceConfig.mockResolvedValue({ - ok: false, - issues: [ - { - code: "gateway-token-mismatch", - message: "Gateway service OPENCLAW_GATEWAY_TOKEN does not match gateway.auth.token", - level: "recommended", - }, - ], - }); - mocks.buildGatewayInstallPlan.mockResolvedValue({ - programArguments: [ - "/usr/bin/node", - "/usr/local/bin/openclaw", - "gateway", - "--port", - "18789", - ], - workingDirectory: "/tmp", - environment: { - OPENCLAW_GATEWAY_TOKEN: "env-token", - }, - }); - mocks.install.mockResolvedValue(undefined); + setupGatewayTokenRepairScenario("env-token"); const cfg: OpenClawConfig = { gateway: {}, diff --git a/src/discord/monitor/monitor.test.ts b/src/discord/monitor/monitor.test.ts index 670a2a0a8..e1359bda4 100644 --- a/src/discord/monitor/monitor.test.ts +++ b/src/discord/monitor/monitor.test.ts @@ -351,10 +351,10 @@ describe("discord component interactions", () => { expect(resolveDiscordComponentEntry({ id: "btn_1", consume: false })).not.toBeNull(); }); - it("routes modal submissions with field values", async () => { + async function runModalSubmission(params?: { reusable?: boolean }) { registerDiscordComponentEntries({ entries: [], - modals: [createModalEntry()], + modals: [createModalEntry({ reusable: params?.reusable ?? false })], }); const modal = createDiscordComponentModal( @@ -365,6 +365,11 @@ describe("discord component interactions", () => { const { interaction, acknowledge } = createModalInteraction(); await modal.run(interaction, { mid: "mdl_1" } as ComponentData); + return { acknowledge }; + } + + it("routes modal submissions with field values", async () => { + const { acknowledge } = await runModalSubmission(); expect(acknowledge).toHaveBeenCalledTimes(1); expect(lastDispatchCtx?.BodyForAgent).toContain('Form "Details" submitted.'); @@ -376,19 +381,7 @@ describe("discord component interactions", () => { }); it("keeps reusable modal entries active after submission", async () => { - registerDiscordComponentEntries({ - entries: [], - modals: [createModalEntry({ reusable: true })], - }); - - const modal = createDiscordComponentModal( - createComponentContext({ - discordConfig: createDiscordConfig({ replyToMode: "all" }), - }), - ); - const { interaction, acknowledge } = createModalInteraction(); - - await modal.run(interaction, { mid: "mdl_1" } as ComponentData); + const { acknowledge } = await runModalSubmission({ reusable: true }); expect(acknowledge).toHaveBeenCalledTimes(1); expect(resolveDiscordModalEntry({ id: "mdl_1", consume: false })).not.toBeNull(); @@ -790,27 +783,34 @@ describe("maybeCreateDiscordAutoThread", () => { }); describe("resolveDiscordAutoThreadReplyPlan", () => { - it("switches delivery + session context to the created thread", async () => { - const client = { - rest: { post: async () => ({ id: "thread" }) }, - } as unknown as Client; - const plan = await resolveDiscordAutoThreadReplyPlan({ - client, + function createAutoThreadPlanParams(overrides?: { + client?: Client; + channelConfig?: DiscordChannelConfigResolved; + threadChannel?: { id: string } | null; + }) { + return { + client: + overrides?.client ?? + ({ rest: { post: async () => ({ id: "thread" }) } } as unknown as Client), message: { id: "m1", channelId: "parent", } as unknown as import("./listeners.js").DiscordMessageEvent["message"], isGuildMessage: true, - channelConfig: { - autoThread: true, - } as unknown as DiscordChannelConfigResolved, - threadChannel: null, + channelConfig: + overrides?.channelConfig ?? + ({ autoThread: true } as unknown as DiscordChannelConfigResolved), + threadChannel: overrides?.threadChannel ?? null, baseText: "hello", combinedBody: "hello", - replyToMode: "all", + replyToMode: "all" as const, agentId: "agent", - channel: "discord", - }); + channel: "discord" as const, + }; + } + + it("switches delivery + session context to the created thread", async () => { + const plan = await resolveDiscordAutoThreadReplyPlan(createAutoThreadPlanParams()); expect(plan.deliverTarget).toBe("channel:thread"); expect(plan.replyReference.use()).toBeUndefined(); expect(plan.autoThreadContext?.SessionKey).toBe( @@ -823,24 +823,11 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { }); it("routes replies to an existing thread channel", async () => { - const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; - const plan = await resolveDiscordAutoThreadReplyPlan({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: true, - } as unknown as DiscordChannelConfigResolved, - threadChannel: { id: "thread" }, - baseText: "hello", - combinedBody: "hello", - replyToMode: "all", - agentId: "agent", - channel: "discord", - }); + const plan = await resolveDiscordAutoThreadReplyPlan( + createAutoThreadPlanParams({ + threadChannel: { id: "thread" }, + }), + ); expect(plan.deliverTarget).toBe("channel:thread"); expect(plan.replyTarget).toBe("channel:thread"); expect(plan.replyReference.use()).toBe("m1"); @@ -848,24 +835,11 @@ describe("resolveDiscordAutoThreadReplyPlan", () => { }); it("does nothing when autoThread is disabled", async () => { - const client = { rest: { post: async () => ({ id: "thread" }) } } as unknown as Client; - const plan = await resolveDiscordAutoThreadReplyPlan({ - client, - message: { - id: "m1", - channelId: "parent", - } as unknown as import("./listeners.js").DiscordMessageEvent["message"], - isGuildMessage: true, - channelConfig: { - autoThread: false, - } as unknown as DiscordChannelConfigResolved, - threadChannel: null, - baseText: "hello", - combinedBody: "hello", - replyToMode: "all", - agentId: "agent", - channel: "discord", - }); + const plan = await resolveDiscordAutoThreadReplyPlan( + createAutoThreadPlanParams({ + channelConfig: { autoThread: false } as unknown as DiscordChannelConfigResolved, + }), + ); expect(plan.deliverTarget).toBe("channel:parent"); expect(plan.autoThreadContext).toBeNull(); }); diff --git a/src/gateway/tools-invoke-http.test.ts b/src/gateway/tools-invoke-http.test.ts index aa5f9a637..eea4ebb8b 100644 --- a/src/gateway/tools-invoke-http.test.ts +++ b/src/gateway/tools-invoke-http.test.ts @@ -170,6 +170,7 @@ beforeEach(() => { }); const resolveGatewayToken = (): string => TEST_GATEWAY_TOKEN; +const gatewayAuthHeaders = () => ({ authorization: `Bearer ${resolveGatewayToken()}` }); const allowAgentsListForMain = () => { cfg = { @@ -229,16 +230,29 @@ const invokeTool = async (params: { }); }; +const invokeAgentsListAuthed = async (params: { sessionKey?: string } = {}) => + invokeAgentsList({ + port: sharedPort, + headers: gatewayAuthHeaders(), + sessionKey: params.sessionKey, + }); + +const invokeToolAuthed = async (params: { + tool: string; + args?: Record; + action?: string; + sessionKey?: string; +}) => + invokeTool({ + port: sharedPort, + headers: gatewayAuthHeaders(), + ...params, + }); + describe("POST /tools/invoke", () => { it("invokes a tool and returns {ok:true,result}", async () => { allowAgentsListForMain(); - const token = resolveGatewayToken(); - - const res = await invokeAgentsList({ - port: sharedPort, - headers: { authorization: `Bearer ${token}` }, - sessionKey: "main", - }); + const res = await invokeAgentsListAuthed({ sessionKey: "main" }); expect(res.status).toBe(200); const body = await res.json(); @@ -253,13 +267,7 @@ describe("POST /tools/invoke", () => { tools: { profile: "minimal", alsoAllow: ["agents_list"] }, }; - const token = resolveGatewayToken(); - - const resProfile = await invokeAgentsList({ - port: sharedPort, - headers: { authorization: `Bearer ${token}` }, - sessionKey: "main", - }); + const resProfile = await invokeAgentsListAuthed({ sessionKey: "main" }); expect(resProfile.status).toBe(200); const profileBody = await resProfile.json(); @@ -270,11 +278,7 @@ describe("POST /tools/invoke", () => { tools: { alsoAllow: ["agents_list"] }, }; - const resImplicit = await invokeAgentsList({ - port: sharedPort, - headers: { authorization: `Bearer ${token}` }, - sessionKey: "main", - }); + const resImplicit = await invokeAgentsListAuthed({ sessionKey: "main" }); expect(resImplicit.status).toBe(200); const implicitBody = await resImplicit.json(); expect(implicitBody.ok).toBe(true); @@ -289,12 +293,7 @@ describe("POST /tools/invoke", () => { allowAgentsListForMain(); pluginHttpHandlers = [async (req, res) => pluginHandler(req, res)]; - const token = resolveGatewayToken(); - const res = await invokeAgentsList({ - port: sharedPort, - headers: { authorization: `Bearer ${token}` }, - sessionKey: "main", - }); + const res = await invokeAgentsListAuthed({ sessionKey: "main" }); expect(res.status).toBe(200); expect(pluginHandler).not.toHaveBeenCalled(); @@ -315,13 +314,7 @@ describe("POST /tools/invoke", () => { ], }, }; - const token = resolveGatewayToken(); - - const denyRes = await invokeAgentsList({ - port: sharedPort, - headers: { authorization: `Bearer ${token}` }, - sessionKey: "main", - }); + const denyRes = await invokeAgentsListAuthed({ sessionKey: "main" }); expect(denyRes.status).toBe(404); allowAgentsListForMain(); @@ -330,11 +323,7 @@ describe("POST /tools/invoke", () => { tools: { profile: "minimal" }, }; - const profileRes = await invokeAgentsList({ - port: sharedPort, - headers: { authorization: `Bearer ${token}` }, - sessionKey: "main", - }); + const profileRes = await invokeAgentsListAuthed({ sessionKey: "main" }); expect(profileRes.status).toBe(404); }); @@ -352,13 +341,9 @@ describe("POST /tools/invoke", () => { }, }; - const token = resolveGatewayToken(); - - const res = await invokeTool({ - port: sharedPort, + const res = await invokeToolAuthed({ tool: "sessions_spawn", args: { task: "test" }, - headers: { authorization: `Bearer ${token}` }, sessionKey: "main", }); @@ -376,12 +361,8 @@ describe("POST /tools/invoke", () => { }, }; - const token = resolveGatewayToken(); - - const res = await invokeTool({ - port: sharedPort, + const res = await invokeToolAuthed({ tool: "sessions_send", - headers: { authorization: `Bearer ${token}` }, sessionKey: "main", }); @@ -396,12 +377,8 @@ describe("POST /tools/invoke", () => { }, }; - const token = resolveGatewayToken(); - - const res = await invokeTool({ - port: sharedPort, + const res = await invokeToolAuthed({ tool: "gateway", - headers: { authorization: `Bearer ${token}` }, sessionKey: "main", }); @@ -417,12 +394,8 @@ describe("POST /tools/invoke", () => { gateway: { tools: { allow: ["gateway"] } }, }; - const token = resolveGatewayToken(); - - const res = await invokeTool({ - port: sharedPort, + const res = await invokeToolAuthed({ tool: "gateway", - headers: { authorization: `Bearer ${token}` }, sessionKey: "main", }); @@ -442,12 +415,8 @@ describe("POST /tools/invoke", () => { gateway: { tools: { allow: ["gateway"], deny: ["gateway"] } }, }; - const token = resolveGatewayToken(); - - const res = await invokeTool({ - port: sharedPort, + const res = await invokeToolAuthed({ tool: "gateway", - headers: { authorization: `Bearer ${token}` }, sessionKey: "main", }); @@ -477,19 +446,10 @@ describe("POST /tools/invoke", () => { session: { mainKey: "primary" }, }; - const token = resolveGatewayToken(); - - const resDefault = await invokeAgentsList({ - port: sharedPort, - headers: { authorization: `Bearer ${token}` }, - }); + const resDefault = await invokeAgentsListAuthed(); expect(resDefault.status).toBe(200); - const resMain = await invokeAgentsList({ - port: sharedPort, - headers: { authorization: `Bearer ${token}` }, - sessionKey: "main", - }); + const resMain = await invokeAgentsListAuthed({ sessionKey: "main" }); expect(resMain.status).toBe(200); }); @@ -501,13 +461,9 @@ describe("POST /tools/invoke", () => { }, }; - const token = resolveGatewayToken(); - - const inputRes = await invokeTool({ - port: sharedPort, + const inputRes = await invokeToolAuthed({ tool: "tools_invoke_test", args: { mode: "input" }, - headers: { authorization: `Bearer ${token}` }, sessionKey: "main", }); expect(inputRes.status).toBe(400); @@ -516,11 +472,9 @@ describe("POST /tools/invoke", () => { expect(inputBody.error?.type).toBe("tool_error"); expect(inputBody.error?.message).toBe("mode invalid"); - const crashRes = await invokeTool({ - port: sharedPort, + const crashRes = await invokeToolAuthed({ tool: "tools_invoke_test", args: { mode: "crash" }, - headers: { authorization: `Bearer ${token}` }, sessionKey: "main", }); expect(crashRes.status).toBe(500); diff --git a/src/infra/heartbeat-runner.transcript-prune.test.ts b/src/infra/heartbeat-runner.transcript-prune.test.ts index c578d2fa3..b669582a2 100644 --- a/src/infra/heartbeat-runner.transcript-prune.test.ts +++ b/src/infra/heartbeat-runner.transcript-prune.test.ts @@ -50,31 +50,34 @@ describe("heartbeat transcript pruning", () => { }); } - it("prunes transcript when heartbeat returns HEARTBEAT_OK", async () => { + async function runTranscriptScenario(params: { + sessionId: string; + reply: { + text: string; + usage: { + inputTokens: number; + outputTokens: number; + cacheReadTokens: number; + cacheWriteTokens: number; + }; + }; + expectPruned: boolean; + }) { await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { const sessionKey = resolveMainSessionKey(undefined); - const sessionId = "test-session-prune"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - - // Create a transcript with some existing content - const originalContent = await createTranscriptWithContent(transcriptPath, sessionId); + const transcriptPath = path.join(tmpDir, `${params.sessionId}.jsonl`); + const originalContent = await createTranscriptWithContent(transcriptPath, params.sessionId); const originalSize = (await fs.stat(transcriptPath)).size; - // Seed session store await seedSessionStore(storePath, sessionKey, { - sessionId, + sessionId: params.sessionId, lastChannel: "telegram", lastProvider: "telegram", lastTo: "user123", }); - // Mock reply to return HEARTBEAT_OK (which triggers pruning) - replySpy.mockResolvedValueOnce({ - text: "HEARTBEAT_OK", - usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, - }); + replySpy.mockResolvedValueOnce(params.reply); - // Run heartbeat const cfg = { version: 1, model: "test-model", @@ -90,57 +93,36 @@ describe("heartbeat transcript pruning", () => { deps: { sendTelegram: vi.fn() }, }); - // Verify transcript was truncated back to original size - const finalContent = await fs.readFile(transcriptPath, "utf-8"); - expect(finalContent).toBe(originalContent); const finalSize = (await fs.stat(transcriptPath)).size; - expect(finalSize).toBe(originalSize); + if (params.expectPruned) { + const finalContent = await fs.readFile(transcriptPath, "utf-8"); + expect(finalContent).toBe(originalContent); + expect(finalSize).toBe(originalSize); + return; + } + expect(finalSize).toBeGreaterThanOrEqual(originalSize); + }); + } + + it("prunes transcript when heartbeat returns HEARTBEAT_OK", async () => { + await runTranscriptScenario({ + sessionId: "test-session-prune", + reply: { + text: "HEARTBEAT_OK", + usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0 }, + }, + expectPruned: true, }); }); it("does not prune transcript when heartbeat returns meaningful content", async () => { - await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => { - const sessionKey = resolveMainSessionKey(undefined); - const sessionId = "test-session-no-prune"; - const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); - - // Create a transcript with some existing content - await createTranscriptWithContent(transcriptPath, sessionId); - const originalSize = (await fs.stat(transcriptPath)).size; - - // Seed session store - await seedSessionStore(storePath, sessionKey, { - sessionId, - lastChannel: "telegram", - lastProvider: "telegram", - lastTo: "user123", - }); - - // Mock reply to return meaningful content (should NOT trigger pruning) - replySpy.mockResolvedValueOnce({ + await runTranscriptScenario({ + sessionId: "test-session-no-prune", + reply: { text: "Alert: Something needs your attention!", usage: { inputTokens: 10, outputTokens: 20, cacheReadTokens: 0, cacheWriteTokens: 0 }, - }); - - // Run heartbeat - const cfg = { - version: 1, - model: "test-model", - agent: { workspace: tmpDir }, - sessionStore: storePath, - channels: { telegram: {} }, - } as unknown as OpenClawConfig; - - await runHeartbeatOnce({ - agentId: undefined, - reason: "test", - cfg, - deps: { sendTelegram: vi.fn() }, - }); - - // Verify transcript was NOT truncated (it may have grown with new entries) - const finalSize = (await fs.stat(transcriptPath)).size; - expect(finalSize).toBeGreaterThanOrEqual(originalSize); + }, + expectPruned: false, }); }); }); diff --git a/src/plugins/uninstall.test.ts b/src/plugins/uninstall.test.ts index f4172cf16..112bc375e 100644 --- a/src/plugins/uninstall.test.ts +++ b/src/plugins/uninstall.test.ts @@ -46,6 +46,28 @@ async function createInstalledNpmPluginFixture(params: { }; } +function createSinglePluginEntries(pluginId = "my-plugin") { + return { + [pluginId]: { enabled: true }, + }; +} + +function createSinglePluginWithEmptySlotsConfig(): OpenClawConfig { + return { + plugins: { + entries: createSinglePluginEntries(), + slots: {}, + }, + }; +} + +async function createPluginDirFixture(baseDir: string, pluginId = "my-plugin") { + const pluginDir = path.join(baseDir, pluginId); + await fs.mkdir(pluginDir, { recursive: true }); + await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); + return pluginDir; +} + describe("removePluginFromConfig", () => { it("removes plugin from entries", () => { const config: OpenClawConfig = { @@ -175,14 +197,7 @@ describe("removePluginFromConfig", () => { }); it("removes plugins object when uninstall leaves only empty slots", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - "my-plugin": { enabled: true }, - }, - slots: {}, - }, - }; + const config = createSinglePluginWithEmptySlotsConfig(); const { config: result } = removePluginFromConfig(config, "my-plugin"); @@ -190,14 +205,7 @@ describe("removePluginFromConfig", () => { }); it("cleans up empty slots object", () => { - const config: OpenClawConfig = { - plugins: { - entries: { - "my-plugin": { enabled: true }, - }, - slots: {}, - }, - }; + const config = createSinglePluginWithEmptySlotsConfig(); const { config: result } = removePluginFromConfig(config, "my-plugin"); @@ -345,15 +353,11 @@ describe("uninstallPlugin", () => { }); it("preserves directory for linked plugins", async () => { - const pluginDir = path.join(tempDir, "my-plugin"); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); + const pluginDir = await createPluginDirFixture(tempDir); const config: OpenClawConfig = { plugins: { - entries: { - "my-plugin": { enabled: true }, - }, + entries: createSinglePluginEntries(), installs: { "my-plugin": { source: "path", @@ -383,15 +387,11 @@ describe("uninstallPlugin", () => { }); it("does not delete directory when deleteFiles is false", async () => { - const pluginDir = path.join(tempDir, "my-plugin"); - await fs.mkdir(pluginDir, { recursive: true }); - await fs.writeFile(path.join(pluginDir, "index.js"), "// plugin"); + const pluginDir = await createPluginDirFixture(tempDir); const config: OpenClawConfig = { plugins: { - entries: { - "my-plugin": { enabled: true }, - }, + entries: createSinglePluginEntries(), installs: { "my-plugin": { source: "npm", diff --git a/src/slack/monitor/slash.test.ts b/src/slack/monitor/slash.test.ts index 6653660a7..d56ffa6f9 100644 --- a/src/slack/monitor/slash.test.ts +++ b/src/slack/monitor/slash.test.ts @@ -8,6 +8,26 @@ vi.mock("../../auto-reply/commands-registry.js", () => { const reportExternalCommand = { key: "reportexternal", nativeName: "reportexternal" }; const reportLongCommand = { key: "reportlong", nativeName: "reportlong" }; const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" }; + const periodArg = { name: "period", description: "period" }; + const hasNonEmptyArgValue = (values: unknown, key: string) => { + const raw = + typeof values === "object" && values !== null + ? (values as Record)[key] + : undefined; + return typeof raw === "string" && raw.trim().length > 0; + }; + const resolvePeriodMenu = ( + params: { args?: { values?: unknown } }, + choices: Array<{ + value: string; + label: string; + }>, + ) => { + if (hasNonEmptyArgValue(params.args?.values, "period")) { + return null; + } + return { arg: periodArg, choices }; + }; return { buildCommandTextFromArgs: ( @@ -92,53 +112,32 @@ vi.mock("../../auto-reply/commands-registry.js", () => { args?: { values?: unknown }; }) => { if (params.command?.key === "report") { - const values = (params.args?.values ?? {}) as Record; - if (typeof values.period === "string" && values.period.trim()) { - return null; - } - return { - arg: { name: "period", description: "period" }, - choices: [ - { value: "day", label: "day" }, - { value: "week", label: "week" }, - { value: "month", label: "month" }, - { value: "quarter", label: "quarter" }, - { value: "year", label: "year" }, - { value: "all", label: "all" }, - ], - }; + return resolvePeriodMenu(params, [ + { value: "day", label: "day" }, + { value: "week", label: "week" }, + { value: "month", label: "month" }, + { value: "quarter", label: "quarter" }, + { value: "year", label: "year" }, + { value: "all", label: "all" }, + ]); } if (params.command?.key === "reportlong") { - const values = (params.args?.values ?? {}) as Record; - if (typeof values.period === "string" && values.period.trim()) { - return null; - } - return { - arg: { name: "period", description: "period" }, - choices: [ - { value: "day", label: "day" }, - { value: "week", label: "week" }, - { value: "month", label: "month" }, - { value: "quarter", label: "quarter" }, - { value: "year", label: "year" }, - { value: "x".repeat(90), label: "long" }, - ], - }; + return resolvePeriodMenu(params, [ + { value: "day", label: "day" }, + { value: "week", label: "week" }, + { value: "month", label: "month" }, + { value: "quarter", label: "quarter" }, + { value: "year", label: "year" }, + { value: "x".repeat(90), label: "long" }, + ]); } if (params.command?.key === "reportcompact") { - const values = (params.args?.values ?? {}) as Record; - if (typeof values.period === "string" && values.period.trim()) { - return null; - } - return { - arg: { name: "period", description: "period" }, - choices: [ - { value: "day", label: "day" }, - { value: "week", label: "week" }, - { value: "month", label: "month" }, - { value: "quarter", label: "quarter" }, - ], - }; + return resolvePeriodMenu(params, [ + { value: "day", label: "day" }, + { value: "week", label: "week" }, + { value: "month", label: "month" }, + { value: "quarter", label: "quarter" }, + ]); } if (params.command?.key === "reportexternal") { return { @@ -651,6 +650,28 @@ async function runSlashHandler(params: { return { respond, ack }; } +async function registerAndRunPolicySlash(params: { + harness: ReturnType; + command?: Partial<{ + user_id: string; + user_name: string; + channel_id: string; + channel_name: string; + text: string; + trigger_id: string; + }>; +}) { + await registerCommands(params.harness.ctx, params.harness.account); + return await runSlashHandler({ + commands: params.harness.commands, + command: { + channel_id: params.command?.channel_id ?? params.harness.channelId, + channel_name: params.command?.channel_name ?? params.harness.channelName, + ...params.command, + }, + }); +} + function expectChannelBlockedResponse(respond: ReturnType) { expect(dispatchMock).not.toHaveBeenCalled(); expect(respond).toHaveBeenCalledWith({ @@ -669,21 +690,13 @@ function expectUnauthorizedResponse(respond: ReturnType) { describe("slack slash commands channel policy", () => { it("allows unlisted channels when groupPolicy is open", async () => { - const { commands, ctx, account, channelId, channelName } = createPolicyHarness({ + const harness = createPolicyHarness({ groupPolicy: "open", channelsConfig: { C_LISTED: { requireMention: true } }, channelId: "C_UNLISTED", channelName: "unlisted", }); - await registerCommands(ctx, account); - - const { respond } = await runSlashHandler({ - commands, - command: { - channel_id: channelId, - channel_name: channelName, - }, - }); + const { respond } = await registerAndRunPolicySlash({ harness }); expect(dispatchMock).toHaveBeenCalledTimes(1); expect(respond).not.toHaveBeenCalledWith( @@ -692,41 +705,25 @@ describe("slack slash commands channel policy", () => { }); it("blocks explicitly denied channels when groupPolicy is open", async () => { - const { commands, ctx, account, channelId, channelName } = createPolicyHarness({ + const harness = createPolicyHarness({ groupPolicy: "open", channelsConfig: { C_DENIED: { allow: false } }, channelId: "C_DENIED", channelName: "denied", }); - await registerCommands(ctx, account); - - const { respond } = await runSlashHandler({ - commands, - command: { - channel_id: channelId, - channel_name: channelName, - }, - }); + const { respond } = await registerAndRunPolicySlash({ harness }); expectChannelBlockedResponse(respond); }); it("blocks unlisted channels when groupPolicy is allowlist", async () => { - const { commands, ctx, account, channelId, channelName } = createPolicyHarness({ + const harness = createPolicyHarness({ groupPolicy: "allowlist", channelsConfig: { C_LISTED: { requireMention: true } }, channelId: "C_UNLISTED", channelName: "unlisted", }); - await registerCommands(ctx, account); - - const { respond } = await runSlashHandler({ - commands, - command: { - channel_id: channelId, - channel_name: channelName, - }, - }); + const { respond } = await registerAndRunPolicySlash({ harness }); expectChannelBlockedResponse(respond); }); @@ -734,36 +731,26 @@ describe("slack slash commands channel policy", () => { describe("slack slash commands access groups", () => { it("fails closed when channel type lookup returns empty for channels", async () => { - const { commands, ctx, account, channelId, channelName } = createPolicyHarness({ + const harness = createPolicyHarness({ allowFrom: [], channelId: "C_UNKNOWN", channelName: "unknown", resolveChannelName: async () => ({}), }); - await registerCommands(ctx, account); - - const { respond } = await runSlashHandler({ - commands, - command: { - channel_id: channelId, - channel_name: channelName, - }, - }); + const { respond } = await registerAndRunPolicySlash({ harness }); expectUnauthorizedResponse(respond); }); it("still treats D-prefixed channel ids as DMs when lookup fails", async () => { - const { commands, ctx, account } = createPolicyHarness({ + const harness = createPolicyHarness({ allowFrom: [], channelId: "D123", channelName: "notdirectmessage", resolveChannelName: async () => ({}), }); - await registerCommands(ctx, account); - - const { respond } = await runSlashHandler({ - commands, + const { respond } = await registerAndRunPolicySlash({ + harness, command: { channel_id: "D123", channel_name: "notdirectmessage", @@ -781,16 +768,14 @@ describe("slack slash commands access groups", () => { }); it("computes CommandAuthorized for DM slash commands when dmPolicy is open", async () => { - const { commands, ctx, account } = createPolicyHarness({ + const harness = createPolicyHarness({ allowFrom: ["U_OWNER"], channelId: "D999", channelName: "directmessage", resolveChannelName: async () => ({ name: "directmessage", type: "im" }), }); - await registerCommands(ctx, account); - - await runSlashHandler({ - commands, + await registerAndRunPolicySlash({ + harness, command: { user_id: "U_ATTACKER", user_name: "Mallory", @@ -807,21 +792,13 @@ describe("slack slash commands access groups", () => { }); it("enforces access-group gating when lookup fails for private channels", async () => { - const { commands, ctx, account, channelId, channelName } = createPolicyHarness({ + const harness = createPolicyHarness({ allowFrom: [], channelId: "G123", channelName: "private", resolveChannelName: async () => ({}), }); - await registerCommands(ctx, account); - - const { respond } = await runSlashHandler({ - commands, - command: { - channel_id: channelId, - channel_name: channelName, - }, - }); + const { respond } = await registerAndRunPolicySlash({ harness }); expectUnauthorizedResponse(respond); }); diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index 788460248..540c59048 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -27,6 +27,8 @@ vi.mock("../sticker-cache.js", () => ({ // eslint-disable-next-line @typescript-eslint/consistent-type-imports const { resolveMedia } = await import("./delivery.js"); +const MAX_MEDIA_BYTES = 10_000_000; +const BOT_TOKEN = "tok123"; function makeCtx( mediaField: "voice" | "audio" | "photo" | "video", @@ -61,6 +63,25 @@ function makeCtx( }; } +function setupTransientGetFileRetry() { + const getFile = vi + .fn() + .mockRejectedValueOnce(new Error("Network request for 'getFile' failed!")) + .mockResolvedValueOnce({ file_path: "voice/file_0.oga" }); + + fetchRemoteMedia.mockResolvedValueOnce({ + buffer: Buffer.from("audio"), + contentType: "audio/ogg", + fileName: "file_0.oga", + }); + saveMediaBuffer.mockResolvedValueOnce({ + path: "/tmp/file_0.oga", + contentType: "audio/ogg", + }); + + return getFile; +} + describe("resolveMedia getFile retry", () => { beforeEach(() => { vi.useFakeTimers(); @@ -73,22 +94,8 @@ describe("resolveMedia getFile retry", () => { }); it("retries getFile on transient failure and succeeds on second attempt", async () => { - const getFile = vi - .fn() - .mockRejectedValueOnce(new Error("Network request for 'getFile' failed!")) - .mockResolvedValueOnce({ file_path: "voice/file_0.oga" }); - - fetchRemoteMedia.mockResolvedValueOnce({ - buffer: Buffer.from("audio"), - contentType: "audio/ogg", - fileName: "file_0.oga", - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: "/tmp/file_0.oga", - contentType: "audio/ogg", - }); - - const promise = resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123"); + const getFile = setupTransientGetFileRetry(); + const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN); await vi.advanceTimersByTimeAsync(5000); const result = await promise; @@ -101,7 +108,7 @@ describe("resolveMedia getFile retry", () => { it("returns null when all getFile retries fail so message is not dropped", async () => { const getFile = vi.fn().mockRejectedValue(new Error("Network request for 'getFile' failed!")); - const promise = resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123"); + const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN); await vi.advanceTimersByTimeAsync(15000); const result = await promise; @@ -113,34 +120,26 @@ describe("resolveMedia getFile retry", () => { const getFile = vi.fn().mockResolvedValue({ file_path: "voice/file_0.oga" }); fetchRemoteMedia.mockRejectedValueOnce(new Error("download failed")); - await expect(resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123")).rejects.toThrow( - "download failed", - ); + await expect( + resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN), + ).rejects.toThrow("download failed"); expect(getFile).toHaveBeenCalledTimes(1); }); - it("returns null for photo when getFile exhausts retries", async () => { - const getFile = vi.fn().mockRejectedValue(new Error("HttpError: Network error")); + it.each(["photo", "video"] as const)( + "returns null for %s when getFile exhausts retries", + async (mediaField) => { + const getFile = vi.fn().mockRejectedValue(new Error("HttpError: Network error")); - const promise = resolveMedia(makeCtx("photo", getFile), 10_000_000, "tok123"); - await vi.advanceTimersByTimeAsync(15000); - const result = await promise; + const promise = resolveMedia(makeCtx(mediaField, getFile), MAX_MEDIA_BYTES, BOT_TOKEN); + await vi.advanceTimersByTimeAsync(15000); + const result = await promise; - expect(getFile).toHaveBeenCalledTimes(3); - expect(result).toBeNull(); - }); - - it("returns null for video when getFile exhausts retries", async () => { - const getFile = vi.fn().mockRejectedValue(new Error("HttpError: Network error")); - - const promise = resolveMedia(makeCtx("video", getFile), 10_000_000, "tok123"); - await vi.advanceTimersByTimeAsync(15000); - const result = await promise; - - expect(getFile).toHaveBeenCalledTimes(3); - expect(result).toBeNull(); - }); + expect(getFile).toHaveBeenCalledTimes(3); + expect(result).toBeNull(); + }, + ); it("does not retry 'file is too big' error (400 Bad Request) and returns null", async () => { // Simulate Telegram Bot API error when file exceeds 20MB limit @@ -149,7 +148,7 @@ describe("resolveMedia getFile retry", () => { ); const getFile = vi.fn().mockRejectedValue(fileTooBigError); - const result = await resolveMedia(makeCtx("video", getFile), 10_000_000, "tok123"); + const result = await resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN); // Should NOT retry - "file is too big" is a permanent error, not transient expect(getFile).toHaveBeenCalledTimes(1); @@ -165,54 +164,30 @@ describe("resolveMedia getFile retry", () => { ); const getFile = vi.fn().mockRejectedValue(fileTooBigError); - const result = await resolveMedia(makeCtx("video", getFile), 10_000_000, "tok123"); + const result = await resolveMedia(makeCtx("video", getFile), MAX_MEDIA_BYTES, BOT_TOKEN); expect(getFile).toHaveBeenCalledTimes(1); expect(result).toBeNull(); }); - it("returns null for audio when file is too big", async () => { - const fileTooBigError = new Error( - "GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)", - ); - const getFile = vi.fn().mockRejectedValue(fileTooBigError); + it.each(["audio", "voice"] as const)( + "returns null for %s when file is too big", + async (mediaField) => { + const fileTooBigError = new Error( + "GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)", + ); + const getFile = vi.fn().mockRejectedValue(fileTooBigError); - const result = await resolveMedia(makeCtx("audio", getFile), 10_000_000, "tok123"); + const result = await resolveMedia(makeCtx(mediaField, getFile), MAX_MEDIA_BYTES, BOT_TOKEN); - expect(getFile).toHaveBeenCalledTimes(1); - expect(result).toBeNull(); - }); - - it("returns null for voice when file is too big", async () => { - const fileTooBigError = new Error( - "GrammyError: Call to 'getFile' failed! (400: Bad Request: file is too big)", - ); - const getFile = vi.fn().mockRejectedValue(fileTooBigError); - - const result = await resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123"); - - expect(getFile).toHaveBeenCalledTimes(1); - expect(result).toBeNull(); - }); + expect(getFile).toHaveBeenCalledTimes(1); + expect(result).toBeNull(); + }, + ); it("still retries transient errors even after encountering file too big in different call", async () => { - // First call with transient error should retry - const getFile = vi - .fn() - .mockRejectedValueOnce(new Error("Network request for 'getFile' failed!")) - .mockResolvedValueOnce({ file_path: "voice/file_0.oga" }); - - fetchRemoteMedia.mockResolvedValueOnce({ - buffer: Buffer.from("audio"), - contentType: "audio/ogg", - fileName: "file_0.oga", - }); - saveMediaBuffer.mockResolvedValueOnce({ - path: "/tmp/file_0.oga", - contentType: "audio/ogg", - }); - - const promise = resolveMedia(makeCtx("voice", getFile), 10_000_000, "tok123"); + const getFile = setupTransientGetFileRetry(); + const promise = resolveMedia(makeCtx("voice", getFile), MAX_MEDIA_BYTES, BOT_TOKEN); await vi.advanceTimersByTimeAsync(5000); const result = await promise; diff --git a/src/tui/tui-event-handlers.test.ts b/src/tui/tui-event-handlers.test.ts index 10b619d0d..989d902da 100644 --- a/src/tui/tui-event-handlers.test.ts +++ b/src/tui/tui-event-handlers.test.ts @@ -83,14 +83,33 @@ describe("tui-event-handlers: handleAgentEvent", () => { }; }; - it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => { - const state = makeState({ currentSessionId: "session-xyz", activeChatRunId: "run-123" }); - const { chatLog, tui, setActivityStatus } = makeContext(state); - const { handleAgentEvent } = createEventHandlers({ + const createHandlersHarness = (params?: { + state?: Partial; + chatLog?: HandlerChatLog; + }) => { + const state = makeState(params?.state); + const context = makeContext(state); + const chatLog = (params?.chatLog ?? context.chatLog) as MockChatLog & HandlerChatLog; + const handlers = createEventHandlers({ chatLog, - tui, + tui: context.tui, state, - setActivityStatus, + setActivityStatus: context.setActivityStatus, + loadHistory: context.loadHistory, + isLocalRunId: context.isLocalRunId, + forgetLocalRunId: context.forgetLocalRunId, + }); + return { + ...context, + state, + chatLog, + ...handlers, + }; + }; + + it("processes tool events when runId matches activeChatRunId (even if sessionId differs)", () => { + const { chatLog, tui, handleAgentEvent } = createHandlersHarness({ + state: { currentSessionId: "session-xyz", activeChatRunId: "run-123" }, }); const evt: AgentEvent = { @@ -111,13 +130,8 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); it("ignores tool events when runId does not match activeChatRunId", () => { - const state = makeState({ activeChatRunId: "run-1" }); - const { chatLog, tui, setActivityStatus } = makeContext(state); - const { handleAgentEvent } = createEventHandlers({ - chatLog, - tui, - state, - setActivityStatus, + const { chatLog, tui, handleAgentEvent } = createHandlersHarness({ + state: { activeChatRunId: "run-1" }, }); const evt: AgentEvent = { @@ -134,20 +148,17 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); it("processes lifecycle events when runId matches activeChatRunId", () => { - const state = makeState({ activeChatRunId: "run-9" }); - const { tui, setActivityStatus } = makeContext(state); - const { handleAgentEvent } = createEventHandlers({ - chatLog: { - startTool: vi.fn(), - updateToolResult: vi.fn(), - addSystem: vi.fn(), - updateAssistant: vi.fn(), - finalizeAssistant: vi.fn(), - dropAssistant: vi.fn(), - } as unknown as HandlerChatLog, - tui, - state, - setActivityStatus, + const chatLog = { + startTool: vi.fn(), + updateToolResult: vi.fn(), + addSystem: vi.fn(), + updateAssistant: vi.fn(), + finalizeAssistant: vi.fn(), + dropAssistant: vi.fn(), + } as unknown as HandlerChatLog; + const { tui, setActivityStatus, handleAgentEvent } = createHandlersHarness({ + state: { activeChatRunId: "run-9" }, + chatLog, }); const evt: AgentEvent = { @@ -163,13 +174,8 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); it("captures runId from chat events when activeChatRunId is unset", () => { - const state = makeState({ activeChatRunId: null }); - const { chatLog, tui, setActivityStatus } = makeContext(state); - const { handleChatEvent, handleAgentEvent } = createEventHandlers({ - chatLog, - tui, - state, - setActivityStatus, + const { state, chatLog, handleChatEvent, handleAgentEvent } = createHandlersHarness({ + state: { activeChatRunId: null }, }); const chatEvt: ChatEvent = { @@ -195,13 +201,8 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); it("clears run mapping when the session changes", () => { - const state = makeState({ activeChatRunId: null }); - const { chatLog, tui, setActivityStatus } = makeContext(state); - const { handleChatEvent, handleAgentEvent } = createEventHandlers({ - chatLog, - tui, - state, - setActivityStatus, + const { state, chatLog, tui, handleChatEvent, handleAgentEvent } = createHandlersHarness({ + state: { activeChatRunId: null }, }); handleChatEvent({ @@ -226,13 +227,8 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); it("accepts tool events after chat final for the same run", () => { - const state = makeState({ activeChatRunId: null }); - const { chatLog, tui, setActivityStatus } = makeContext(state); - const { handleChatEvent, handleAgentEvent } = createEventHandlers({ - chatLog, - tui, - state, - setActivityStatus, + const { state, chatLog, tui, handleChatEvent, handleAgentEvent } = createHandlersHarness({ + state: { activeChatRunId: null }, }); handleChatEvent({ @@ -253,14 +249,10 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); it("ignores lifecycle updates for non-active runs in the same session", () => { - const state = makeState({ activeChatRunId: "run-active" }); - const { chatLog, tui, setActivityStatus } = makeContext(state); - const { handleChatEvent, handleAgentEvent } = createEventHandlers({ - chatLog, - tui, - state, - setActivityStatus, - }); + const { state, tui, setActivityStatus, handleChatEvent, handleAgentEvent } = + createHandlersHarness({ + state: { activeChatRunId: "run-active" }, + }); handleChatEvent({ runId: "run-other", @@ -282,16 +274,11 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); it("suppresses tool events when verbose is off", () => { - const state = makeState({ - activeChatRunId: "run-123", - sessionInfo: { verboseLevel: "off" }, - }); - const { chatLog, tui, setActivityStatus } = makeContext(state); - const { handleAgentEvent } = createEventHandlers({ - chatLog, - tui, - state, - setActivityStatus, + const { chatLog, tui, handleAgentEvent } = createHandlersHarness({ + state: { + activeChatRunId: "run-123", + sessionInfo: { verboseLevel: "off" }, + }, }); handleAgentEvent({ @@ -305,16 +292,11 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); it("omits tool output when verbose is on (non-full)", () => { - const state = makeState({ - activeChatRunId: "run-123", - sessionInfo: { verboseLevel: "on" }, - }); - const { chatLog, tui, setActivityStatus } = makeContext(state); - const { handleAgentEvent } = createEventHandlers({ - chatLog, - tui, - state, - setActivityStatus, + const { chatLog, handleAgentEvent } = createHandlersHarness({ + state: { + activeChatRunId: "run-123", + sessionInfo: { verboseLevel: "on" }, + }, }); handleAgentEvent({ @@ -349,17 +331,8 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); it("refreshes history after a non-local chat final", () => { - const state = makeState({ activeChatRunId: null }); - const { chatLog, tui, setActivityStatus, loadHistory, isLocalRunId, forgetLocalRunId } = - makeContext(state); - const { handleChatEvent } = createEventHandlers({ - chatLog, - tui, - state, - setActivityStatus, - loadHistory, - isLocalRunId, - forgetLocalRunId, + const { state, loadHistory, handleChatEvent } = createHandlersHarness({ + state: { activeChatRunId: null }, }); handleChatEvent({ @@ -373,18 +346,10 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); function createConcurrentRunHarness(localContent = "partial") { - const state = makeState({ activeChatRunId: "run-active" }); - const { chatLog, tui, setActivityStatus, loadHistory, isLocalRunId, forgetLocalRunId } = - makeContext(state); - const { handleChatEvent } = createEventHandlers({ - chatLog, - tui, - state, - setActivityStatus, - loadHistory, - isLocalRunId, - forgetLocalRunId, - }); + const { state, chatLog, setActivityStatus, loadHistory, handleChatEvent } = + createHandlersHarness({ + state: { activeChatRunId: "run-active" }, + }); handleChatEvent({ runId: "run-active", @@ -446,13 +411,8 @@ describe("tui-event-handlers: handleAgentEvent", () => { }); it("drops streaming assistant when chat final has no message", () => { - const state = makeState({ activeChatRunId: null }); - const { chatLog, tui, setActivityStatus } = makeContext(state); - const { handleChatEvent } = createEventHandlers({ - chatLog, - tui, - state, - setActivityStatus, + const { state, chatLog, handleChatEvent } = createHandlersHarness({ + state: { activeChatRunId: null }, }); handleChatEvent({