import os from "node:os"; import path from "node:path"; import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js"; const callGatewayMock = vi.fn(); vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); type SessionsToolTestConfig = { session: { scope: "per-sender"; mainKey: string }; tools: { agentToAgent: { enabled: boolean }; sessions?: { visibility: "all" | "own" }; }; }; const loadConfigMock = vi.fn<() => SessionsToolTestConfig>(() => ({ session: { scope: "per-sender", mainKey: "main" }, tools: { agentToAgent: { enabled: false } }, })); vi.mock("../../config/config.js", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, loadConfig: () => loadConfigMock() as never, }; }); import { createSessionsListTool } from "./sessions-list-tool.js"; import { createSessionsSendTool } from "./sessions-send-tool.js"; let resolveAnnounceTarget: (typeof import("./sessions-announce-target.js"))["resolveAnnounceTarget"]; let setActivePluginRegistry: (typeof import("../../plugins/runtime.js"))["setActivePluginRegistry"]; const installRegistry = async () => { setActivePluginRegistry( createTestRegistry([ { pluginId: "discord", source: "test", plugin: { id: "discord", meta: { id: "discord", label: "Discord", selectionLabel: "Discord", docsPath: "/channels/discord", blurb: "Discord test stub.", }, capabilities: { chatTypes: ["direct", "channel", "thread"] }, config: { listAccountIds: () => ["default"], resolveAccount: () => ({}), }, }, }, { pluginId: "whatsapp", source: "test", plugin: { id: "whatsapp", meta: { id: "whatsapp", label: "WhatsApp", selectionLabel: "WhatsApp", docsPath: "/channels/whatsapp", blurb: "WhatsApp test stub.", preferSessionLookupForAnnounceTarget: true, }, capabilities: { chatTypes: ["direct", "group"] }, config: { listAccountIds: () => ["default"], resolveAccount: () => ({}), }, }, }, ]), ); }; describe("sanitizeTextContent", () => { it("strips minimax tool call XML and downgraded markers", () => { const input = 'Hello payload ' + "[Tool Call: foo (ID: 1)] world"; const result = sanitizeTextContent(input).trim(); expect(result).toBe("Hello world"); expect(result).not.toContain("invoke"); expect(result).not.toContain("Tool Call"); }); it("strips thinking tags", () => { const input = "Before secret after"; const result = sanitizeTextContent(input).trim(); expect(result).toBe("Before after"); }); }); beforeAll(async () => { ({ resolveAnnounceTarget } = await import("./sessions-announce-target.js")); ({ setActivePluginRegistry } = await import("../../plugins/runtime.js")); }); beforeEach(() => { loadConfigMock.mockReset(); loadConfigMock.mockReturnValue({ session: { scope: "per-sender", mainKey: "main" }, tools: { agentToAgent: { enabled: false } }, }); }); describe("extractAssistantText", () => { it("sanitizes blocks without injecting newlines", () => { const message = { role: "assistant", content: [ { type: "text", text: "Hi " }, { type: "text", text: "secretthere" }, ], }; expect(extractAssistantText(message)).toBe("Hi there"); }); it("rewrites error-ish assistant text only when the transcript marks it as an error", () => { const message = { role: "assistant", stopReason: "error", errorMessage: "500 Internal Server Error", content: [{ type: "text", text: "500 Internal Server Error" }], }; expect(extractAssistantText(message)).toBe("HTTP 500: Internal Server Error"); }); it("keeps normal status text that mentions billing", () => { const message = { role: "assistant", content: [ { type: "text", text: "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", }, ], }; expect(extractAssistantText(message)).toBe( "Firebase downgraded us to the free Spark plan. Check whether billing should be re-enabled.", ); }); }); describe("resolveAnnounceTarget", () => { beforeEach(async () => { callGatewayMock.mockClear(); await installRegistry(); }); it("derives non-WhatsApp announce targets from the session key", async () => { const target = await resolveAnnounceTarget({ sessionKey: "agent:main:discord:group:dev", displayKey: "agent:main:discord:group:dev", }); expect(target).toEqual({ channel: "discord", to: "channel:dev" }); expect(callGatewayMock).not.toHaveBeenCalled(); }); it("hydrates WhatsApp accountId from sessions.list when available", async () => { callGatewayMock.mockResolvedValueOnce({ sessions: [ { key: "agent:main:whatsapp:group:123@g.us", deliveryContext: { channel: "whatsapp", to: "123@g.us", accountId: "work", }, }, ], }); const target = await resolveAnnounceTarget({ sessionKey: "agent:main:whatsapp:group:123@g.us", displayKey: "agent:main:whatsapp:group:123@g.us", }); expect(target).toEqual({ channel: "whatsapp", to: "123@g.us", accountId: "work", }); expect(callGatewayMock).toHaveBeenCalledTimes(1); const first = callGatewayMock.mock.calls[0]?.[0] as { method?: string } | undefined; expect(first).toBeDefined(); expect(first?.method).toBe("sessions.list"); }); }); describe("sessions_list gating", () => { beforeEach(() => { callGatewayMock.mockClear(); callGatewayMock.mockResolvedValue({ path: "/tmp/sessions.json", sessions: [ { key: "agent:main:main", kind: "direct" }, { key: "agent:other:main", kind: "direct" }, ], }); }); it("filters out other agents when tools.agentToAgent.enabled is false", async () => { const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); const result = await tool.execute("call1", {}); expect(result.details).toMatchObject({ count: 1, sessions: [{ key: "agent:main:main" }], }); }); }); describe("sessions_list transcriptPath resolution", () => { beforeEach(() => { callGatewayMock.mockClear(); loadConfigMock.mockReturnValue({ session: { scope: "per-sender", mainKey: "main" }, tools: { agentToAgent: { enabled: true }, sessions: { visibility: "all" }, }, }); }); it("resolves cross-agent transcript paths from agent defaults when gateway store path is relative", async () => { const stateDir = path.join(os.tmpdir(), "openclaw-state-relative"); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); try { callGatewayMock.mockResolvedValueOnce({ path: "agents/main/sessions/sessions.json", sessions: [ { key: "agent:worker:main", kind: "direct", sessionId: "sess-worker", }, ], }); const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); const result = await tool.execute("call1", {}); const details = result.details as | { sessions?: Array<{ key?: string; transcriptPath?: string }> } | undefined; const session = details?.sessions?.[0]; expect(session).toMatchObject({ key: "agent:worker:main" }); const transcriptPath = String(session?.transcriptPath ?? ""); expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions")); expect(transcriptPath).toMatch(/sess-worker\.jsonl$/); } finally { vi.unstubAllEnvs(); } }); it("resolves transcriptPath even when sessions.list does not return a store path", async () => { const stateDir = path.join(os.tmpdir(), "openclaw-state-no-path"); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); try { callGatewayMock.mockResolvedValueOnce({ sessions: [ { key: "agent:worker:main", kind: "direct", sessionId: "sess-worker-no-path", }, ], }); const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); const result = await tool.execute("call1", {}); const details = result.details as | { sessions?: Array<{ key?: string; transcriptPath?: string }> } | undefined; const session = details?.sessions?.[0]; expect(session).toMatchObject({ key: "agent:worker:main" }); const transcriptPath = String(session?.transcriptPath ?? ""); expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions")); expect(transcriptPath).toMatch(/sess-worker-no-path\.jsonl$/); } finally { vi.unstubAllEnvs(); } }); it("falls back to agent defaults when gateway path is non-string", async () => { const stateDir = path.join(os.tmpdir(), "openclaw-state-non-string-path"); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); try { callGatewayMock.mockResolvedValueOnce({ path: { raw: "agents/main/sessions/sessions.json" }, sessions: [ { key: "agent:worker:main", kind: "direct", sessionId: "sess-worker-shape", }, ], }); const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); const result = await tool.execute("call1", {}); const details = result.details as | { sessions?: Array<{ key?: string; transcriptPath?: string }> } | undefined; const session = details?.sessions?.[0]; expect(session).toMatchObject({ key: "agent:worker:main" }); const transcriptPath = String(session?.transcriptPath ?? ""); expect(path.normalize(transcriptPath)).toContain(path.join("agents", "worker", "sessions")); expect(transcriptPath).toMatch(/sess-worker-shape\.jsonl$/); } finally { vi.unstubAllEnvs(); } }); it("falls back to agent defaults when gateway path is '(multiple)'", async () => { const stateDir = path.join(os.tmpdir(), "openclaw-state-multiple"); vi.stubEnv("OPENCLAW_STATE_DIR", stateDir); try { callGatewayMock.mockResolvedValueOnce({ path: "(multiple)", sessions: [ { key: "agent:worker:main", kind: "direct", sessionId: "sess-worker-multiple", }, ], }); const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); const result = await tool.execute("call1", {}); const details = result.details as | { sessions?: Array<{ key?: string; transcriptPath?: string }> } | undefined; const session = details?.sessions?.[0]; expect(session).toMatchObject({ key: "agent:worker:main" }); const transcriptPath = String(session?.transcriptPath ?? ""); expect(path.normalize(transcriptPath)).toContain( path.join(stateDir, "agents", "worker", "sessions"), ); expect(transcriptPath).toMatch(/sess-worker-multiple\.jsonl$/); } finally { vi.unstubAllEnvs(); } }); it("resolves absolute {agentId} template paths per session agent", async () => { const templateStorePath = "/tmp/openclaw/agents/{agentId}/sessions/sessions.json"; callGatewayMock.mockResolvedValueOnce({ path: templateStorePath, sessions: [ { key: "agent:worker:main", kind: "direct", sessionId: "sess-worker-template", }, ], }); const tool = createSessionsListTool({ agentSessionKey: "agent:main:main" }); const result = await tool.execute("call1", {}); const details = result.details as | { sessions?: Array<{ key?: string; transcriptPath?: string }> } | undefined; const session = details?.sessions?.[0]; expect(session).toMatchObject({ key: "agent:worker:main" }); const transcriptPath = String(session?.transcriptPath ?? ""); const expectedSessionsDir = path.dirname(templateStorePath.replace("{agentId}", "worker")); expect(path.normalize(transcriptPath)).toContain(path.normalize(expectedSessionsDir)); expect(transcriptPath).toMatch(/sess-worker-template\.jsonl$/); }); }); describe("sessions_send gating", () => { beforeEach(() => { callGatewayMock.mockClear(); }); it("returns an error when neither sessionKey nor label is provided", async () => { const tool = createSessionsSendTool({ agentSessionKey: "agent:main:main", agentChannel: "whatsapp", }); const result = await tool.execute("call-missing-target", { message: "hi", timeoutSeconds: 5, }); expect(result.details).toMatchObject({ status: "error", error: "Either sessionKey or label is required", }); expect(callGatewayMock).not.toHaveBeenCalled(); }); it("returns an error when label resolution fails", async () => { callGatewayMock.mockRejectedValueOnce(new Error("No session found with label: nope")); const tool = createSessionsSendTool({ agentSessionKey: "agent:main:main", agentChannel: "whatsapp", }); const result = await tool.execute("call-missing-label", { label: "nope", message: "hello", timeoutSeconds: 5, }); expect(result.details).toMatchObject({ status: "error", }); expect((result.details as { error?: string } | undefined)?.error ?? "").toContain( "No session found with label", ); expect(callGatewayMock).toHaveBeenCalledTimes(1); expect(callGatewayMock.mock.calls[0]?.[0]).toMatchObject({ method: "sessions.resolve" }); }); it("blocks cross-agent sends when tools.agentToAgent.enabled is false", async () => { const tool = createSessionsSendTool({ agentSessionKey: "agent:main:main", agentChannel: "whatsapp", }); const result = await tool.execute("call1", { sessionKey: "agent:other:main", message: "hi", timeoutSeconds: 0, }); expect(callGatewayMock).toHaveBeenCalledTimes(1); expect(callGatewayMock.mock.calls[0]?.[0]).toMatchObject({ method: "sessions.list" }); expect(result.details).toMatchObject({ status: "forbidden" }); }); });