import { beforeEach, describe, expect, it, vi } from "vitest"; import { AcpRuntimeError } from "../../acp/runtime/errors.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js"; import { createInternalHookEventPayload } from "../../test-utils/internal-hook-event-payload.js"; import type { MsgContext } from "../templating.js"; import type { GetReplyOptions, ReplyPayload } from "../types.js"; import type { ReplyDispatcher } from "./reply-dispatcher.js"; import { buildTestCtx } from "./test-ctx.js"; type AbortResult = { handled: boolean; aborted: boolean; stoppedSubagents?: number }; const mocks = vi.hoisted(() => ({ routeReply: vi.fn(async (_params: unknown) => ({ ok: true, messageId: "mock" })), tryFastAbortFromMessage: vi.fn<() => Promise>(async () => ({ handled: false, aborted: false, })), })); const diagnosticMocks = vi.hoisted(() => ({ logMessageQueued: vi.fn(), logMessageProcessed: vi.fn(), logSessionStateChange: vi.fn(), })); const hookMocks = vi.hoisted(() => ({ runner: { hasHooks: vi.fn(() => false), runMessageReceived: vi.fn(async () => {}), }, })); const internalHookMocks = vi.hoisted(() => ({ createInternalHookEvent: vi.fn(), triggerInternalHook: vi.fn(async () => {}), })); const acpMocks = vi.hoisted(() => ({ listAcpSessionEntries: vi.fn(async () => []), readAcpSessionEntry: vi.fn<() => unknown>(() => null), upsertAcpSessionMeta: vi.fn(async () => null), requireAcpRuntimeBackend: vi.fn<() => unknown>(), })); const sessionBindingMocks = vi.hoisted(() => ({ listBySession: vi.fn<(targetSessionKey: string) => SessionBindingRecord[]>(() => []), })); const ttsMocks = vi.hoisted(() => { const state = { synthesizeFinalAudio: false, }; return { state, maybeApplyTtsToPayload: vi.fn(async (paramsUnknown: unknown) => { const params = paramsUnknown as { payload: ReplyPayload; kind: "tool" | "block" | "final"; }; if ( state.synthesizeFinalAudio && params.kind === "final" && typeof params.payload?.text === "string" && params.payload.text.trim() ) { return { ...params.payload, mediaUrl: "https://example.com/tts-synth.opus", audioAsVoice: true, }; } return params.payload; }), normalizeTtsAutoMode: vi.fn((value: unknown) => typeof value === "string" ? value : undefined, ), resolveTtsConfig: vi.fn((_cfg: OpenClawConfig) => ({ mode: "final" })), }; }); vi.mock("./route-reply.js", () => ({ isRoutableChannel: (channel: string | undefined) => Boolean( channel && ["telegram", "slack", "discord", "signal", "imessage", "whatsapp", "feishu"].includes( channel, ), ), routeReply: mocks.routeReply, })); vi.mock("./abort.js", () => ({ tryFastAbortFromMessage: mocks.tryFastAbortFromMessage, formatAbortReplyText: (stoppedSubagents?: number) => { if (typeof stoppedSubagents !== "number" || stoppedSubagents <= 0) { return "⚙️ Agent was aborted."; } const label = stoppedSubagents === 1 ? "sub-agent" : "sub-agents"; return `⚙️ Agent was aborted. Stopped ${stoppedSubagents} ${label}.`; }, })); vi.mock("../../logging/diagnostic.js", () => ({ logMessageQueued: diagnosticMocks.logMessageQueued, logMessageProcessed: diagnosticMocks.logMessageProcessed, logSessionStateChange: diagnosticMocks.logSessionStateChange, })); vi.mock("../../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => hookMocks.runner, })); vi.mock("../../hooks/internal-hooks.js", () => ({ createInternalHookEvent: internalHookMocks.createInternalHookEvent, triggerInternalHook: internalHookMocks.triggerInternalHook, })); vi.mock("../../acp/runtime/session-meta.js", () => ({ listAcpSessionEntries: acpMocks.listAcpSessionEntries, readAcpSessionEntry: acpMocks.readAcpSessionEntry, upsertAcpSessionMeta: acpMocks.upsertAcpSessionMeta, })); vi.mock("../../acp/runtime/registry.js", () => ({ requireAcpRuntimeBackend: acpMocks.requireAcpRuntimeBackend, })); vi.mock("../../infra/outbound/session-binding-service.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, getSessionBindingService: () => ({ bind: vi.fn(async () => { throw new Error("bind not mocked"); }), getCapabilities: vi.fn(() => ({ adapterAvailable: true, bindSupported: true, unbindSupported: true, placements: ["current", "child"] as const, })), listBySession: (targetSessionKey: string) => sessionBindingMocks.listBySession(targetSessionKey), resolveByConversation: vi.fn(() => null), touch: vi.fn(), unbind: vi.fn(async () => []), }), }; }); vi.mock("../../tts/tts.js", () => ({ maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params), normalizeTtsAutoMode: (value: unknown) => ttsMocks.normalizeTtsAutoMode(value), resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg), })); const { dispatchReplyFromConfig } = await import("./dispatch-from-config.js"); const { resetInboundDedupe } = await import("./inbound-dedupe.js"); const { __testing: acpManagerTesting } = await import("../../acp/control-plane/manager.js"); const noAbortResult = { handled: false, aborted: false } as const; const emptyConfig = {} as OpenClawConfig; type DispatchReplyArgs = Parameters[0]; function createDispatcher(): ReplyDispatcher { return { sendToolResult: vi.fn(() => true), sendBlockReply: vi.fn(() => true), sendFinalReply: vi.fn(() => true), waitForIdle: vi.fn(async () => {}), getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })), markComplete: vi.fn(), }; } function setNoAbort() { mocks.tryFastAbortFromMessage.mockResolvedValue(noAbortResult); } function createAcpRuntime(events: Array>) { return { ensureSession: vi.fn( async (input: { sessionKey: string; mode: string; agent: string }) => ({ sessionKey: input.sessionKey, backend: "acpx", runtimeSessionName: `${input.sessionKey}:${input.mode}`, }) as { sessionKey: string; backend: string; runtimeSessionName: string }, ), runTurn: vi.fn(async function* (_params: { text?: string }) { for (const event of events) { yield event; } }), cancel: vi.fn(async () => {}), close: vi.fn(async () => {}), }; } function firstToolResultPayload(dispatcher: ReplyDispatcher): ReplyPayload | undefined { return (dispatcher.sendToolResult as ReturnType).mock.calls[0]?.[0] as | ReplyPayload | undefined; } async function dispatchTwiceWithFreshDispatchers(params: Omit) { await dispatchReplyFromConfig({ ...params, dispatcher: createDispatcher(), }); await dispatchReplyFromConfig({ ...params, dispatcher: createDispatcher(), }); } describe("dispatchReplyFromConfig", () => { beforeEach(() => { acpManagerTesting.resetAcpSessionManagerForTests(); resetInboundDedupe(); mocks.routeReply.mockReset(); mocks.routeReply.mockResolvedValue({ ok: true, messageId: "mock" }); acpMocks.listAcpSessionEntries.mockReset().mockResolvedValue([]); diagnosticMocks.logMessageQueued.mockClear(); diagnosticMocks.logMessageProcessed.mockClear(); diagnosticMocks.logSessionStateChange.mockClear(); hookMocks.runner.hasHooks.mockClear(); hookMocks.runner.hasHooks.mockReturnValue(false); hookMocks.runner.runMessageReceived.mockClear(); internalHookMocks.createInternalHookEvent.mockClear(); internalHookMocks.createInternalHookEvent.mockImplementation(createInternalHookEventPayload); internalHookMocks.triggerInternalHook.mockClear(); acpMocks.readAcpSessionEntry.mockReset(); acpMocks.readAcpSessionEntry.mockReturnValue(null); acpMocks.upsertAcpSessionMeta.mockReset(); acpMocks.upsertAcpSessionMeta.mockResolvedValue(null); acpMocks.requireAcpRuntimeBackend.mockReset(); sessionBindingMocks.listBySession.mockReset(); sessionBindingMocks.listBySession.mockReturnValue([]); ttsMocks.state.synthesizeFinalAudio = false; ttsMocks.maybeApplyTtsToPayload.mockClear(); ttsMocks.normalizeTtsAutoMode.mockClear(); ttsMocks.resolveTtsConfig.mockClear(); ttsMocks.resolveTtsConfig.mockReturnValue({ mode: "final", }); }); it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", Surface: undefined, OriginatingChannel: "slack", OriginatingTo: "channel:C123", }); const replyResolver = async ( _ctx: MsgContext, _opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(mocks.routeReply).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("routes when OriginatingChannel differs from Provider", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", AccountId: "acc-1", MessageThreadId: 123, GroupChannel: "ops-room", OriginatingChannel: "telegram", OriginatingTo: "telegram:999", }); const replyResolver = async ( _ctx: MsgContext, _opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); expect(mocks.routeReply).toHaveBeenCalledWith( expect.objectContaining({ channel: "telegram", to: "telegram:999", accountId: "acc-1", threadId: 123, isGroup: true, groupId: "telegram:999", }), ); }); it("forces suppressTyping when routing to a different originating channel", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", OriginatingChannel: "telegram", OriginatingTo: "telegram:999", }); const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => { expect(opts?.suppressTyping).toBe(true); expect(opts?.typingPolicy).toBe("system_event"); return { text: "hi" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); }); it("forces suppressTyping for internal webchat turns", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "webchat", Surface: "webchat", OriginatingChannel: "webchat", OriginatingTo: "session:abc", }); const replyResolver = async (_ctx: MsgContext, opts?: GetReplyOptions) => { expect(opts?.suppressTyping).toBe(true); expect(opts?.typingPolicy).toBe("internal_webchat"); return { text: "hi" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); }); it("routes when provider is webchat but surface carries originating channel metadata", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "webchat", Surface: "telegram", OriginatingChannel: "telegram", OriginatingTo: "telegram:999", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); expect(mocks.routeReply).toHaveBeenCalledWith( expect.objectContaining({ channel: "telegram", to: "telegram:999", }), ); }); it("routes Feishu replies when provider is webchat and origin metadata points to Feishu", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "webchat", Surface: "feishu", OriginatingChannel: "feishu", OriginatingTo: "ou_feishu_direct_123", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); expect(mocks.routeReply).toHaveBeenCalledWith( expect.objectContaining({ channel: "feishu", to: "ou_feishu_direct_123", }), ); }); it("does not route when provider already matches originating channel", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", Surface: "webchat", OriginatingChannel: "telegram", OriginatingTo: "telegram:999", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(mocks.routeReply).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("does not route external origin replies when current surface is internal webchat without explicit delivery", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "webchat", Surface: "webchat", OriginatingChannel: "imessage", OriginatingTo: "imessage:+15550001111", }); const replyResolver = async ( _ctx: MsgContext, _opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(mocks.routeReply).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("routes external origin replies for internal webchat turns when explicit delivery is set", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "webchat", Surface: "webchat", OriginatingChannel: "imessage", OriginatingTo: "imessage:+15550001111", ExplicitDeliverRoute: true, }); const replyResolver = async ( _ctx: MsgContext, _opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); expect(mocks.routeReply).toHaveBeenCalledWith( expect.objectContaining({ channel: "imessage", to: "imessage:+15550001111", }), ); }); it("routes media-only tool results when summaries are suppressed", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", ChatType: "group", AccountId: "acc-1", OriginatingChannel: "telegram", OriginatingTo: "telegram:999", }); const replyResolver = async ( _ctx: MsgContext, opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => { expect(opts?.onToolResult).toBeDefined(); await opts?.onToolResult?.({ text: "NO_REPLY", mediaUrls: ["https://example.com/tts-routed.opus"], }); return undefined; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); expect(mocks.routeReply).toHaveBeenCalledTimes(1); const routed = mocks.routeReply.mock.calls[0]?.[0] as { payload?: ReplyPayload } | undefined; expect(routed?.payload?.mediaUrls).toEqual(["https://example.com/tts-routed.opus"]); expect(routed?.payload?.text).toBeUndefined(); }); it("provides onToolResult in DM sessions", async () => { setNoAbort(); mocks.routeReply.mockClear(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", ChatType: "direct", }); const replyResolver = async ( _ctx: MsgContext, opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => { expect(opts?.onToolResult).toBeDefined(); expect(typeof opts?.onToolResult).toBe("function"); return { text: "hi" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("suppresses group tool summaries but still forwards tool media", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", ChatType: "group", }); const replyResolver = async ( _ctx: MsgContext, opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => { expect(opts?.onToolResult).toBeDefined(); await opts?.onToolResult?.({ text: "🔧 exec: ls" }); await opts?.onToolResult?.({ text: "NO_REPLY", mediaUrls: ["https://example.com/tts-group.opus"], }); return { text: "hi" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1); const sent = firstToolResultPayload(dispatcher); expect(sent?.mediaUrls).toEqual(["https://example.com/tts-group.opus"]); expect(sent?.text).toBeUndefined(); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("delivers deterministic exec approval tool payloads in groups", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", ChatType: "group", }); const replyResolver = async ( _ctx: MsgContext, opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => { await opts?.onToolResult?.({ text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", channelData: { execApproval: { approvalId: "117ba06d-1111-2222-3333-444444444444", approvalSlug: "117ba06d", allowedDecisions: ["allow-once", "allow-always", "deny"], }, }, }); return { text: "NO_REPLY" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1); expect(firstToolResultPayload(dispatcher)).toEqual( expect.objectContaining({ text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", channelData: { execApproval: { approvalId: "117ba06d-1111-2222-3333-444444444444", approvalSlug: "117ba06d", allowedDecisions: ["allow-once", "allow-always", "deny"], }, }, }), ); expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" }); }); it("sends tool results via dispatcher in DM sessions", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", ChatType: "direct", }); const replyResolver = async ( _ctx: MsgContext, opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => { // Simulate tool result emission await opts?.onToolResult?.({ text: "🔧 exec: ls" }); return { text: "done" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).toHaveBeenCalledWith( expect.objectContaining({ text: "🔧 exec: ls" }), ); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("suppresses native tool summaries but still forwards tool media", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", ChatType: "direct", CommandSource: "native", }); const replyResolver = async ( _ctx: MsgContext, opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => { expect(opts?.onToolResult).toBeDefined(); await opts?.onToolResult?.({ text: "🔧 tools/sessions_send" }); await opts?.onToolResult?.({ mediaUrl: "https://example.com/tts-native.opus", }); return { text: "hi" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1); const sent = firstToolResultPayload(dispatcher); expect(sent?.mediaUrl).toBe("https://example.com/tts-native.opus"); expect(sent?.text).toBeUndefined(); expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1); }); it("delivers deterministic exec approval tool payloads for native commands", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", CommandSource: "native", }); const replyResolver = async ( _ctx: MsgContext, opts?: GetReplyOptions, _cfg?: OpenClawConfig, ) => { await opts?.onToolResult?.({ text: "Approval required.\n\n```txt\n/approve 117ba06d allow-once\n```", channelData: { execApproval: { approvalId: "117ba06d-1111-2222-3333-444444444444", approvalSlug: "117ba06d", allowedDecisions: ["allow-once", "allow-always", "deny"], }, }, }); return { text: "NO_REPLY" } satisfies ReplyPayload; }; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).toHaveBeenCalledTimes(1); expect(firstToolResultPayload(dispatcher)).toEqual( expect.objectContaining({ channelData: { execApproval: { approvalId: "117ba06d-1111-2222-3333-444444444444", approvalSlug: "117ba06d", allowedDecisions: ["allow-once", "allow-always", "deny"], }, }, }), ); expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "NO_REPLY" }); }); it("fast-aborts without calling the reply resolver", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ handled: true, aborted: true, }); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", Body: "/stop", }); const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(replyResolver).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "⚙️ Agent was aborted.", }); }); it("fast-abort reply includes stopped subagent count when provided", async () => { mocks.tryFastAbortFromMessage.mockResolvedValue({ handled: true, aborted: true, stoppedSubagents: 2, }); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", Body: "/stop", }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver: vi.fn(async () => ({ text: "hi" }) as ReplyPayload), }); expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "⚙️ Agent was aborted. Stopped 2 sub-agents.", }); }); it("routes ACP sessions through the runtime branch and streams block replies", async () => { setNoAbort(); const runtime = createAcpRuntime([ { type: "text_delta", text: "hello " }, { type: "text_delta", text: "world" }, { type: "done" }, ]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, stream: { coalesceIdleMs: 0, maxChunkChars: 128 }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", BodyForAgent: "write a test", }); const replyResolver = vi.fn(async () => ({ text: "fallback" }) as ReplyPayload); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(replyResolver).not.toHaveBeenCalled(); expect(runtime.ensureSession).toHaveBeenCalledWith( expect.objectContaining({ sessionKey: "agent:codex-acp:session-1", agent: "codex", mode: "persistent", }), ); const blockCalls = (dispatcher.sendBlockReply as ReturnType).mock.calls; expect(blockCalls.length).toBeGreaterThan(0); const streamedText = blockCalls.map((call) => (call[0] as ReplyPayload).text ?? "").join(""); expect(streamedText).toContain("hello"); expect(streamedText).toContain("world"); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); it("posts a one-time resolved-session-id notice in thread after the first ACP turn", async () => { setNoAbort(); const runtime = createAcpRuntime([{ type: "text_delta", text: "hello" }, { type: "done" }]); const pendingAcp = { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", identity: { state: "pending" as const, source: "ensure" as const, lastUpdatedAt: Date.now(), acpxSessionId: "acpx-123", agentSessionId: "inner-123", }, mode: "persistent" as const, state: "idle" as const, lastActivityAt: Date.now(), }; const resolvedAcp = { ...pendingAcp, identity: { ...pendingAcp.identity, state: "resolved" as const, source: "status" as const, }, }; acpMocks.readAcpSessionEntry.mockImplementation(() => { const runTurnStarted = runtime.runTurn.mock.calls.length > 0; return { sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: runTurnStarted ? resolvedAcp : pendingAcp, }; }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", MessageThreadId: "thread-1", BodyForAgent: "show ids", }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver: vi.fn() }); const finalCalls = (dispatcher.sendFinalReply as ReturnType).mock.calls; expect(finalCalls.length).toBe(1); const finalPayload = finalCalls[0]?.[0] as ReplyPayload | undefined; expect(finalPayload?.text).toContain("Session ids resolved"); expect(finalPayload?.text).toContain("agent session id: inner-123"); expect(finalPayload?.text).toContain("acpx session id: acpx-123"); expect(finalPayload?.text).toContain("codex resume inner-123"); }); it("posts resolved-session-id notice when ACP session is bound even without MessageThreadId", async () => { setNoAbort(); const runtime = createAcpRuntime([{ type: "text_delta", text: "hello" }, { type: "done" }]); const pendingAcp = { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", identity: { state: "pending" as const, source: "ensure" as const, lastUpdatedAt: Date.now(), acpxSessionId: "acpx-123", agentSessionId: "inner-123", }, mode: "persistent" as const, state: "idle" as const, lastActivityAt: Date.now(), }; const resolvedAcp = { ...pendingAcp, identity: { ...pendingAcp.identity, state: "resolved" as const, source: "status" as const, }, }; acpMocks.readAcpSessionEntry.mockImplementation(() => { const runTurnStarted = runtime.runTurn.mock.calls.length > 0; return { sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: runTurnStarted ? resolvedAcp : pendingAcp, }; }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); sessionBindingMocks.listBySession.mockReturnValue([ { bindingId: "default:thread-1", targetSessionKey: "agent:codex-acp:session-1", targetKind: "session", conversation: { channel: "discord", accountId: "default", conversationId: "thread-1", }, status: "active", boundAt: Date.now(), }, ]); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", AccountId: "default", SessionKey: "agent:codex-acp:session-1", MessageThreadId: undefined, BodyForAgent: "show ids", }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver: vi.fn() }); const finalCalls = (dispatcher.sendFinalReply as ReturnType).mock.calls; expect(finalCalls.length).toBe(1); const finalPayload = finalCalls[0]?.[0] as ReplyPayload | undefined; expect(finalPayload?.text).toContain("Session ids resolved"); expect(finalPayload?.text).toContain("agent session id: inner-123"); expect(finalPayload?.text).toContain("acpx session id: acpx-123"); }); it("honors send-policy deny before ACP runtime dispatch", async () => { setNoAbort(); const runtime = createAcpRuntime([ { type: "text_delta", text: "should-not-run" }, { type: "done" }, ]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, session: { sendPolicy: { default: "deny", }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", BodyForAgent: "write a test", }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher }); expect(runtime.runTurn).not.toHaveBeenCalled(); expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); it("routes ACP slash commands through the normal command pipeline", async () => { setNoAbort(); const runtime = createAcpRuntime([{ type: "done" }]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, session: { sendPolicy: { default: "deny", }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", CommandBody: "/acp cancel", BodyForCommands: "/acp cancel", BodyForAgent: "/acp cancel", }); const replyResolver = vi.fn(async () => ({ text: "command output" }) as ReplyPayload); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(replyResolver).toHaveBeenCalledTimes(1); expect(runtime.runTurn).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).toHaveBeenCalledWith({ text: "command output", }); }); it("routes ACP reset tails through ACP after command handling", async () => { setNoAbort(); const runtime = createAcpRuntime([ { type: "text_delta", text: "tail accepted" }, { type: "done" }, ]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, session: { sendPolicy: { default: "deny", }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", CommandSource: "native", SessionKey: "discord:slash:owner", CommandTargetSessionKey: "agent:codex-acp:session-1", CommandBody: "/new continue with deployment", BodyForCommands: "/new continue with deployment", BodyForAgent: "/new continue with deployment", }); const replyResolver = vi.fn(async (resolverCtx: MsgContext) => { resolverCtx.Body = "continue with deployment"; resolverCtx.RawBody = "continue with deployment"; resolverCtx.CommandBody = "continue with deployment"; resolverCtx.BodyForCommands = "continue with deployment"; resolverCtx.BodyForAgent = "continue with deployment"; resolverCtx.AcpDispatchTailAfterReset = true; return undefined; }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(replyResolver).toHaveBeenCalledTimes(1); expect(runtime.runTurn).toHaveBeenCalledTimes(1); expect(runtime.runTurn.mock.calls[0]?.[0]).toMatchObject({ text: "continue with deployment", }); }); it("does not bypass ACP slash aliases when text commands are disabled on native surfaces", async () => { setNoAbort(); const runtime = createAcpRuntime([{ type: "done" }]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, commands: { text: false, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", CommandBody: "/acp cancel", BodyForCommands: "/acp cancel", BodyForAgent: "/acp cancel", CommandSource: "text", }); const replyResolver = vi.fn(async () => ({ text: "should not bypass" }) as ReplyPayload); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(runtime.runTurn).toHaveBeenCalledTimes(1); expect(replyResolver).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); it("does not bypass ACP dispatch for unauthorized bang-prefixed messages", async () => { setNoAbort(); const runtime = createAcpRuntime([{ type: "done" }]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, session: { sendPolicy: { default: "deny", }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", CommandBody: "!poll", BodyForCommands: "!poll", BodyForAgent: "!poll", CommandAuthorized: false, }); const replyResolver = vi.fn(async () => ({ text: "should not bypass" }) as ReplyPayload); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(runtime.runTurn).not.toHaveBeenCalled(); expect(replyResolver).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); it("does not bypass ACP dispatch for bang-prefixed messages when text commands are disabled", async () => { setNoAbort(); const runtime = createAcpRuntime([{ type: "done" }]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, commands: { text: false, }, session: { sendPolicy: { default: "deny", }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", CommandBody: "!poll", BodyForCommands: "!poll", BodyForAgent: "!poll", CommandAuthorized: true, CommandSource: "text", }); const replyResolver = vi.fn(async () => ({ text: "should not bypass" }) as ReplyPayload); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(runtime.runTurn).not.toHaveBeenCalled(); expect(replyResolver).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); it("coalesces tiny ACP token deltas into normal Discord text spacing", async () => { setNoAbort(); const runtime = createAcpRuntime([ { type: "text_delta", text: "What" }, { type: "text_delta", text: " do" }, { type: "text_delta", text: " you" }, { type: "text_delta", text: " want" }, { type: "text_delta", text: " to" }, { type: "text_delta", text: " work" }, { type: "text_delta", text: " on?" }, { type: "done" }, ]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, stream: { coalesceIdleMs: 0, maxChunkChars: 256 }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", BodyForAgent: "test spacing", }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher }); const blockTexts = (dispatcher.sendBlockReply as ReturnType).mock.calls .map((call) => ((call[0] as ReplyPayload).text ?? "").trim()) .filter(Boolean); expect(blockTexts).toEqual(["What do you want to work on?"]); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); it("generates final-mode TTS audio after ACP block streaming completes", async () => { setNoAbort(); ttsMocks.state.synthesizeFinalAudio = true; const runtime = createAcpRuntime([ { type: "text_delta", text: "Hello from ACP streaming." }, { type: "done" }, ]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, stream: { coalesceIdleMs: 0, maxChunkChars: 256 }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", BodyForAgent: "stream this", }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher }); const finalPayload = (dispatcher.sendFinalReply as ReturnType).mock .calls[0]?.[0] as ReplyPayload | undefined; expect(finalPayload?.mediaUrl).toBe("https://example.com/tts-synth.opus"); expect(finalPayload?.text).toBeUndefined(); }); it("routes ACP block output to originating channel without parent dispatcher duplicates", async () => { setNoAbort(); mocks.routeReply.mockClear(); const runtime = createAcpRuntime([ { type: "text_delta", text: "thread chunk" }, { type: "done" }, ]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, stream: { coalesceIdleMs: 0, maxChunkChars: 128 }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", OriginatingChannel: "telegram", OriginatingTo: "telegram:thread-1", SessionKey: "agent:codex-acp:session-1", BodyForAgent: "write a test", }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher }); expect(mocks.routeReply).toHaveBeenCalled(); expect(mocks.routeReply).toHaveBeenCalledWith( expect.objectContaining({ channel: "telegram", to: "telegram:thread-1", }), ); expect(dispatcher.sendBlockReply).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).not.toHaveBeenCalled(); }); it("closes oneshot ACP sessions after the turn completes", async () => { setNoAbort(); const runtime = createAcpRuntime([{ type: "done" }]); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:oneshot-1", storeSessionKey: "agent:codex-acp:oneshot-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:oneshot", mode: "oneshot", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockReturnValue({ id: "acpx", runtime, }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:oneshot-1", BodyForAgent: "run once", }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher }); expect(runtime.close).toHaveBeenCalledWith( expect.objectContaining({ reason: "oneshot-complete", }), ); }); it("emits an explicit ACP policy error when dispatch is disabled", async () => { setNoAbort(); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); const cfg = { acp: { enabled: true, dispatch: { enabled: false }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", BodyForAgent: "write a test", }); const replyResolver = vi.fn(async () => ({ text: "fallback" }) as ReplyPayload); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(replyResolver).not.toHaveBeenCalled(); expect(acpMocks.requireAcpRuntimeBackend).not.toHaveBeenCalled(); const finalPayload = (dispatcher.sendFinalReply as ReturnType).mock .calls[0]?.[0] as ReplyPayload | undefined; expect(finalPayload?.text).toContain("ACP dispatch is disabled by policy"); }); it("fails closed when ACP metadata is missing for an ACP session key", async () => { setNoAbort(); acpMocks.readAcpSessionEntry.mockReturnValue(null); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex:acp:session-1", BodyForAgent: "hello", }); const replyResolver = vi.fn(async () => ({ text: "fallback" }) as ReplyPayload); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(replyResolver).not.toHaveBeenCalled(); expect(acpMocks.requireAcpRuntimeBackend).not.toHaveBeenCalled(); const finalPayload = (dispatcher.sendFinalReply as ReturnType).mock .calls[0]?.[0] as ReplyPayload | undefined; expect(finalPayload?.text).toContain("ACP metadata is missing"); }); it("surfaces backend-missing ACP errors in-thread without falling back", async () => { setNoAbort(); acpMocks.readAcpSessionEntry.mockReturnValue({ sessionKey: "agent:codex-acp:session-1", storeSessionKey: "agent:codex-acp:session-1", cfg: {}, storePath: "/tmp/mock-sessions.json", entry: {}, acp: { backend: "acpx", agent: "codex", runtimeSessionName: "runtime:1", mode: "persistent", state: "idle", lastActivityAt: Date.now(), }, }); acpMocks.requireAcpRuntimeBackend.mockImplementation(() => { throw new AcpRuntimeError( "ACP_BACKEND_MISSING", "ACP runtime backend is not configured. Install and enable the acpx runtime plugin.", ); }); const cfg = { acp: { enabled: true, dispatch: { enabled: true }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", SessionKey: "agent:codex-acp:session-1", BodyForAgent: "write a test", }); const replyResolver = vi.fn(async () => ({ text: "fallback" }) as ReplyPayload); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(replyResolver).not.toHaveBeenCalled(); const finalPayload = (dispatcher.sendFinalReply as ReturnType).mock .calls[0]?.[0] as ReplyPayload | undefined; expect(finalPayload?.text).toContain("ACP error (ACP_BACKEND_MISSING)"); expect(finalPayload?.text).toContain("Install and enable the acpx runtime plugin"); }); it("deduplicates inbound messages by MessageSid and origin", async () => { setNoAbort(); const cfg = emptyConfig; const ctx = buildTestCtx({ Provider: "whatsapp", OriginatingChannel: "whatsapp", OriginatingTo: "whatsapp:+15555550123", MessageSid: "msg-1", }); const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); await dispatchTwiceWithFreshDispatchers({ ctx, cfg, replyResolver, }); expect(replyResolver).toHaveBeenCalledTimes(1); }); it("suppresses local discord exec approval tool prompts when discord approvals are enabled", async () => { setNoAbort(); const cfg = { channels: { discord: { enabled: true, execApprovals: { enabled: true, approvers: ["123"], }, }, }, } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "discord", Surface: "discord", AccountId: "default", }); const replyResolver = vi.fn(async (_ctx: MsgContext, options?: GetReplyOptions) => { await options?.onToolResult?.({ text: "Approval required.", channelData: { execApproval: { approvalId: "12345678-1234-1234-1234-123456789012", approvalSlug: "12345678", allowedDecisions: ["allow-once", "allow-always", "deny"], }, }, }); return { text: "done" } as ReplyPayload; }); await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(dispatcher.sendToolResult).not.toHaveBeenCalled(); expect(dispatcher.sendFinalReply).toHaveBeenCalledWith( expect.objectContaining({ text: "done" }), ); }); it("deduplicates same-agent inbound replies across main and direct session keys", async () => { setNoAbort(); const cfg = emptyConfig; const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); const baseCtx = buildTestCtx({ Provider: "telegram", Surface: "telegram", OriginatingChannel: "telegram", OriginatingTo: "telegram:7463849194", MessageSid: "msg-1", SessionKey: "agent:main:main", }); await dispatchReplyFromConfig({ ctx: baseCtx, cfg, dispatcher: createDispatcher(), replyResolver, }); await dispatchReplyFromConfig({ ctx: { ...baseCtx, SessionKey: "agent:main:telegram:direct:7463849194", }, cfg, dispatcher: createDispatcher(), replyResolver, }); expect(replyResolver).toHaveBeenCalledTimes(1); }); it("emits message_received hook with originating channel metadata", async () => { setNoAbort(); hookMocks.runner.hasHooks.mockReturnValue(true); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", Surface: "slack", OriginatingChannel: "Telegram", OriginatingTo: "telegram:999", CommandBody: "/search hello", RawBody: "raw text", Body: "body text", Timestamp: 1710000000000, MessageSidFull: "sid-full", SenderId: "user-1", SenderName: "Alice", SenderUsername: "alice", SenderE164: "+15555550123", AccountId: "acc-1", GroupSpace: "guild-123", GroupChannel: "alerts", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(hookMocks.runner.runMessageReceived).toHaveBeenCalledWith( expect.objectContaining({ from: ctx.From, content: "/search hello", timestamp: 1710000000000, metadata: expect.objectContaining({ originatingChannel: "Telegram", originatingTo: "telegram:999", messageId: "sid-full", senderId: "user-1", senderName: "Alice", senderUsername: "alice", senderE164: "+15555550123", guildId: "guild-123", channelName: "alerts", }), }), expect.objectContaining({ channelId: "telegram", accountId: "acc-1", conversationId: "telegram:999", }), ); }); it("emits internal message:received hook when a session key is available", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", Surface: "telegram", SessionKey: "agent:main:main", CommandBody: "/help", MessageSid: "msg-42", GroupSpace: "guild-456", GroupChannel: "ops-room", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(internalHookMocks.createInternalHookEvent).toHaveBeenCalledWith( "message", "received", "agent:main:main", expect.objectContaining({ from: ctx.From, content: "/help", channelId: "telegram", messageId: "msg-42", metadata: expect.objectContaining({ guildId: "guild-456", channelName: "ops-room", }), }), ); expect(internalHookMocks.triggerInternalHook).toHaveBeenCalledTimes(1); }); it("skips internal message:received hook when session key is unavailable", async () => { setNoAbort(); const cfg = emptyConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "telegram", Surface: "telegram", CommandBody: "/help", }); (ctx as MsgContext).SessionKey = undefined; const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(internalHookMocks.createInternalHookEvent).not.toHaveBeenCalled(); expect(internalHookMocks.triggerInternalHook).not.toHaveBeenCalled(); }); it("emits diagnostics when enabled", async () => { setNoAbort(); const cfg = { diagnostics: { enabled: true } } as OpenClawConfig; const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "slack", Surface: "slack", SessionKey: "agent:main:main", MessageSid: "msg-1", To: "slack:C123", }); const replyResolver = async () => ({ text: "hi" }) satisfies ReplyPayload; await dispatchReplyFromConfig({ ctx, cfg, dispatcher, replyResolver }); expect(diagnosticMocks.logMessageQueued).toHaveBeenCalledTimes(1); expect(diagnosticMocks.logSessionStateChange).toHaveBeenCalledWith({ sessionKey: "agent:main:main", state: "processing", reason: "message_start", }); expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith( expect.objectContaining({ channel: "slack", outcome: "completed", sessionKey: "agent:main:main", }), ); }); it("marks diagnostics skipped for duplicate inbound messages", async () => { setNoAbort(); const cfg = { diagnostics: { enabled: true } } as OpenClawConfig; const ctx = buildTestCtx({ Provider: "whatsapp", OriginatingChannel: "whatsapp", OriginatingTo: "whatsapp:+15555550123", MessageSid: "msg-dup", }); const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload); await dispatchTwiceWithFreshDispatchers({ ctx, cfg, replyResolver, }); expect(replyResolver).toHaveBeenCalledTimes(1); expect(diagnosticMocks.logMessageProcessed).toHaveBeenCalledWith( expect.objectContaining({ channel: "whatsapp", outcome: "skipped", reason: "duplicate", }), ); }); it("suppresses isReasoning payloads from final replies (WhatsApp channel)", async () => { setNoAbort(); const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "whatsapp" }); const replyResolver = async () => [ { text: "Reasoning:\n_thinking..._", isReasoning: true }, { text: "The answer is 42" }, ] satisfies ReplyPayload[]; await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); const finalCalls = (dispatcher.sendFinalReply as ReturnType).mock.calls; expect(finalCalls).toHaveLength(1); expect(finalCalls[0][0]).toMatchObject({ text: "The answer is 42" }); }); it("suppresses isReasoning payloads from block replies (generic dispatch path)", async () => { setNoAbort(); const dispatcher = createDispatcher(); const ctx = buildTestCtx({ Provider: "whatsapp" }); const blockReplySentTexts: string[] = []; const replyResolver = async ( _ctx: MsgContext, opts?: GetReplyOptions, ): Promise => { // Simulate block reply with reasoning payload await opts?.onBlockReply?.({ text: "Reasoning:\n_thinking..._", isReasoning: true }); await opts?.onBlockReply?.({ text: "The answer is 42" }); return { text: "The answer is 42" }; }; // Capture what actually gets dispatched as block replies (dispatcher.sendBlockReply as ReturnType).mockImplementation( (payload: ReplyPayload) => { if (payload.text) { blockReplySentTexts.push(payload.text); } return true; }, ); await dispatchReplyFromConfig({ ctx, cfg: emptyConfig, dispatcher, replyResolver }); expect(blockReplySentTexts).not.toContain("Reasoning:\n_thinking..._"); expect(blockReplySentTexts).toContain("The answer is 42"); }); });