// Qa Channel tests cover inbound plugin behavior. import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { setQaChannelRuntime } from "../api.js"; import { deleteQaBusMessage, editQaBusMessage, sendQaBusMessage } from "./bus-client.js"; import { handleQaInbound, isHttpMediaUrl } from "./inbound.js"; vi.mock("./bus-client.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, deleteQaBusMessage: vi.fn(async () => ({ message: {} })), editQaBusMessage: vi.fn(async () => ({ message: {} })), sendQaBusMessage: vi.fn(async () => ({ message: { id: "preview-1" } })), }; }); type HandleQaInboundParams = Parameters[0]; function createQaInboundParams( overrides: { accountConfig?: HandleQaInboundParams["account"]["config"]; message?: Partial; } = {}, ): HandleQaInboundParams { return { channelId: "qa-channel", channelLabel: "QA Channel", account: { accountId: "default", enabled: true, configured: true, baseUrl: "http://127.0.0.1:43123", botUserId: "openclaw", botDisplayName: "OpenClaw QA", pollTimeoutMs: 250, config: { allowFrom: ["*"], ...overrides.accountConfig, }, }, config: {}, message: { id: "msg-1", accountId: "default", direction: "inbound", conversation: { kind: "direct", id: "alice", }, senderId: "alice", senderName: "Alice", text: "ping", timestamp: 1_777_000_000_000, reactions: [], ...overrides.message, }, }; } function firstRunAssembledParams(runtime: ReturnType) { const call = vi.mocked(runtime.channel.inbound.dispatchReply).mock.calls[0]; if (!call) { throw new Error("expected assembled turn call"); } return call[0]; } describe("isHttpMediaUrl", () => { it("accepts only http and https urls", () => { expect(isHttpMediaUrl("https://example.com/image.png")).toBe(true); expect(isHttpMediaUrl("http://example.com/image.png")).toBe(true); expect(isHttpMediaUrl("file:///etc/passwd")).toBe(false); expect(isHttpMediaUrl("/etc/passwd")).toBe(false); expect(isHttpMediaUrl("data:text/plain;base64,SGVsbG8=")).toBe(false); }); }); describe("handleQaInbound", () => { beforeEach(() => { vi.clearAllMocks(); }); it("publishes partial replies as one edited preview before final delivery", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); await handleQaInbound( createQaInboundParams({ message: { conversation: { id: "qa-room", kind: "group" }, threadId: "42", }, }), ); const assembled = firstRunAssembledParams(runtime); await assembled.replyOptions?.onPartialReply?.({ text: "preview" }); await assembled.replyOptions?.onPartialReply?.({ text: "preview expanded" }); await assembled.delivery.deliver({ text: "final answer" }, { kind: "final" }); expect(sendQaBusMessage).toHaveBeenCalledOnce(); expect(sendQaBusMessage).toHaveBeenCalledWith( expect.objectContaining({ replyToId: "msg-1", text: "preview", threadId: "42", to: "thread:qa-room/42", }), ); expect(editQaBusMessage).toHaveBeenNthCalledWith( 1, expect.objectContaining({ messageId: "preview-1", text: "preview expanded" }), ); expect(editQaBusMessage).toHaveBeenNthCalledWith( 2, expect.objectContaining({ messageId: "preview-1", text: "final answer" }), ); }); it("treats deliveries without dispatcher metadata as final replies", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); await handleQaInbound(createQaInboundParams()); const assembled = firstRunAssembledParams(runtime); await assembled.replyOptions?.onPartialReply?.({ text: "preview" }); const missingDeliveryInfo = undefined as unknown as Parameters< typeof assembled.delivery.deliver >[1]; await assembled.delivery.deliver({ text: "final answer" }, missingDeliveryInfo); expect(sendQaBusMessage).toHaveBeenCalledOnce(); expect(editQaBusMessage).toHaveBeenCalledWith( expect.objectContaining({ messageId: "preview-1", text: "final answer" }), ); expect(deleteQaBusMessage).not.toHaveBeenCalled(); }); it("keeps block deliveries separate and retains tool calls discovered after a preview", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); await handleQaInbound(createQaInboundParams()); const assembled = firstRunAssembledParams(runtime); await assembled.replyOptions?.onPartialReply?.({ text: "preview" }); await assembled.replyOptions?.onToolStart?.({ phase: "start", name: "search", args: { query: "qa" }, }); await assembled.delivery.deliver({ text: "tool result" }, { kind: "block" }); await assembled.delivery.deliver({ text: "final answer" }, { kind: "final" }); expect(deleteQaBusMessage).toHaveBeenCalledOnce(); expect(sendQaBusMessage).toHaveBeenCalledTimes(3); expect(sendQaBusMessage).toHaveBeenNthCalledWith( 2, expect.objectContaining({ text: "tool result", toolCalls: [{ name: "search", arguments: { query: "[redacted]" } }], }), ); expect(sendQaBusMessage).toHaveBeenNthCalledWith( 3, expect.objectContaining({ text: "final answer", toolCalls: [{ name: "search", arguments: { query: "[redacted]" } }], }), ); }); it("deletes an active preview when reply dispatch fails", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); await handleQaInbound(createQaInboundParams()); const assembled = firstRunAssembledParams(runtime); await assembled.replyOptions?.onPartialReply?.({ text: "unfinished preview" }); assembled.delivery.onError?.(new Error("model failed"), { kind: "final" }); await vi.waitFor(() => { expect(deleteQaBusMessage).toHaveBeenCalledWith( expect.objectContaining({ messageId: "preview-1" }), ); }); }); it("deletes a preview after a queued edit fails", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); vi.mocked(editQaBusMessage).mockRejectedValueOnce(new Error("edit failed")); await handleQaInbound(createQaInboundParams()); const assembled = firstRunAssembledParams(runtime); await assembled.replyOptions?.onPartialReply?.({ text: "first preview" }); await expect( assembled.replyOptions?.onPartialReply?.({ text: "broken preview" }), ).rejects.toThrow("edit failed"); assembled.delivery.onError?.(new Error("dispatch failed"), { kind: "final" }); await vi.waitFor(() => { expect(deleteQaBusMessage).toHaveBeenCalledWith( expect.objectContaining({ messageId: "preview-1" }), ); }); }); it("escapes control characters in dispatch error logs", async () => { const runtime = createPluginRuntimeMock(); const warn = vi.spyOn(console, "warn").mockImplementation(() => undefined); const c1Control = String.fromCharCode(0x9b); const lineSeparator = String.fromCodePoint(0x2028); const paragraphSeparator = String.fromCodePoint(0x2029); vi.mocked(deleteQaBusMessage).mockRejectedValueOnce( new Error(`cleanup\nforged\u001b[31m${c1Control}32m${lineSeparator}next`), ); setQaChannelRuntime(runtime); try { await handleQaInbound(createQaInboundParams()); const assembled = firstRunAssembledParams(runtime); await assembled.replyOptions?.onPartialReply?.({ text: "unfinished preview" }); assembled.delivery.onError?.(new Error(`dispatch\r\nforged${paragraphSeparator}next`), { kind: "final", }); await vi.waitFor(() => { expect(warn).toHaveBeenCalledTimes(2); }); assembled.delivery.onError?.(undefined, { kind: "final" }); await vi.waitFor(() => { expect(warn).toHaveBeenCalledTimes(3); }); const output = warn.mock.calls.flat().join(" "); expect(output).not.toContain("\r"); expect(output).not.toContain("\n"); expect(output).not.toContain(String.fromCharCode(0x1b)); expect(output).not.toContain(c1Control); expect(output).not.toContain(lineSeparator); expect(output).not.toContain(paragraphSeparator); expect(output).toContain("dispatch\\u000d\\u000aforged\\u2029next"); expect(output).toContain("cleanup\\u000aforged\\u001b[31m\\u009b32m\\u2028next"); expect(output).toContain("[object Undefined]"); } finally { warn.mockRestore(); } }); it("marks group messages that match configured mention patterns", async () => { const runtime = createPluginRuntimeMock(); vi.mocked(runtime.channel.mentions.buildMentionRegexes).mockReturnValue([/\b@?openclaw\b/i]); setQaChannelRuntime(runtime); await handleQaInbound( createQaInboundParams({ message: { conversation: { kind: "channel", id: "qa-room", title: "QA Room", }, senderId: "alice", senderName: "Alice", text: "@openclaw ping", }, }), ); expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1); const assembled = firstRunAssembledParams(runtime); expect(assembled.replyPipeline).toEqual({}); expect(assembled.ctxPayload.WasMentioned).toBe(true); }); it("drops direct messages outside the configured sender allowlist", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); await handleQaInbound( createQaInboundParams({ accountConfig: { allowFrom: ["bob"], }, }), ); expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled(); }); it("allows direct messages from configured senders", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); await handleQaInbound( createQaInboundParams({ accountConfig: { allowFrom: ["alice"], }, }), ); expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1); const ctxPayload = firstRunAssembledParams(runtime).ctxPayload; expect(ctxPayload?.CommandAuthorized).toBe(true); expect(ctxPayload?.SenderId).toBe("alice"); }); it("routes native commands through a separate slash session to the conversation session", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); await handleQaInbound( createQaInboundParams({ message: { text: "/stop", nativeCommand: { name: "stop" }, }, }), ); const assembled = firstRunAssembledParams(runtime); expect(assembled.ctxPayload).toMatchObject({ CommandAuthorized: true, CommandSource: "native", CommandTargetSessionKey: assembled.routeSessionKey, CommandTurn: { body: "/stop", source: "native", }, }); expect(assembled.ctxPayload.SessionKey).toContain("qa-channel:slash:alice"); expect(assembled.ctxPayload.SessionKey).not.toBe(assembled.routeSessionKey); }); it("skips malformed inline attachment base64 without dropping the message", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); await handleQaInbound( createQaInboundParams({ message: { attachments: [ { id: "attachment-1", kind: "image", mimeType: "image/png", contentBase64: "AAA@@@", }, ], }, }), ); expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1); const ctxPayload = firstRunAssembledParams(runtime).ctxPayload; expect(ctxPayload.MediaPath).toBeUndefined(); expect(ctxPayload.MediaPaths).toBeUndefined(); }); it("uses allowFrom as the group sender fallback for allowlist policy", async () => { const runtime = createPluginRuntimeMock(); setQaChannelRuntime(runtime); await handleQaInbound( createQaInboundParams({ accountConfig: { allowFrom: ["alice"], groupPolicy: "allowlist", }, message: { conversation: { kind: "group", id: "qa-room", title: "QA Room", }, }, }), ); expect(runtime.channel.inbound.dispatchReply).toHaveBeenCalledTimes(1); }); it("skips configured group messages that miss mention activation", async () => { const runtime = createPluginRuntimeMock(); vi.mocked(runtime.channel.mentions.buildMentionRegexes).mockReturnValue([/\b@?openclaw\b/i]); setQaChannelRuntime(runtime); await handleQaInbound( createQaInboundParams({ accountConfig: { groups: { "qa-room": { requireMention: true, }, }, }, message: { conversation: { kind: "group", id: "qa-room", title: "QA Room", }, text: "plain group message", }, }), ); expect(runtime.channel.inbound.dispatchReply).not.toHaveBeenCalled(); }); });