443 lines
14 KiB
TypeScript
443 lines
14 KiB
TypeScript
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<typeof import("../../config/config.js")>();
|
|
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 MAIN_AGENT_SESSION_KEY = "agent:main:main";
|
|
const MAIN_AGENT_CHANNEL = "whatsapp";
|
|
|
|
type SessionsListResult = Awaited<ReturnType<ReturnType<typeof createSessionsListTool>["execute"]>>;
|
|
|
|
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: () => ({}),
|
|
},
|
|
},
|
|
},
|
|
]),
|
|
);
|
|
};
|
|
|
|
function createMainSessionsListTool() {
|
|
return createSessionsListTool({ agentSessionKey: MAIN_AGENT_SESSION_KEY });
|
|
}
|
|
|
|
async function executeMainSessionsList() {
|
|
return createMainSessionsListTool().execute("call1", {});
|
|
}
|
|
|
|
function createMainSessionsSendTool() {
|
|
return createSessionsSendTool({
|
|
agentSessionKey: MAIN_AGENT_SESSION_KEY,
|
|
agentChannel: MAIN_AGENT_CHANNEL,
|
|
});
|
|
}
|
|
|
|
function getFirstListedSession(result: SessionsListResult) {
|
|
const details = result.details as
|
|
| { sessions?: Array<{ key?: string; transcriptPath?: string }> }
|
|
| undefined;
|
|
return details?.sessions?.[0];
|
|
}
|
|
|
|
function expectWorkerTranscriptPath(
|
|
result: SessionsListResult,
|
|
params: { containsPath: string; sessionId: string },
|
|
) {
|
|
const session = getFirstListedSession(result);
|
|
expect(session).toMatchObject({ key: "agent:worker:main" });
|
|
const transcriptPath = String(session?.transcriptPath ?? "");
|
|
expect(path.normalize(transcriptPath)).toContain(path.normalize(params.containsPath));
|
|
expect(transcriptPath).toMatch(new RegExp(`${params.sessionId}\\.jsonl$`));
|
|
}
|
|
|
|
async function withStubbedStateDir<T>(
|
|
name: string,
|
|
run: (stateDir: string) => Promise<T>,
|
|
): Promise<T> {
|
|
const stateDir = path.join(os.tmpdir(), name);
|
|
vi.stubEnv("OPENCLAW_STATE_DIR", stateDir);
|
|
try {
|
|
return await run(stateDir);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
}
|
|
}
|
|
|
|
describe("sanitizeTextContent", () => {
|
|
it("strips minimax tool call XML and downgraded markers", () => {
|
|
const input =
|
|
'Hello <invoke name="tool">payload</invoke></minimax:tool_call> ' +
|
|
"[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 <think>secret</think> 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: "<think>secret</think>there" },
|
|
],
|
|
};
|
|
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 = createMainSessionsListTool();
|
|
const result = await tool.execute("call1", {});
|
|
expect(result.details).toMatchObject({
|
|
count: 1,
|
|
sessions: [{ key: MAIN_AGENT_SESSION_KEY }],
|
|
});
|
|
});
|
|
});
|
|
|
|
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 () => {
|
|
await withStubbedStateDir("openclaw-state-relative", async () => {
|
|
callGatewayMock.mockResolvedValueOnce({
|
|
path: "agents/main/sessions/sessions.json",
|
|
sessions: [
|
|
{
|
|
key: "agent:worker:main",
|
|
kind: "direct",
|
|
sessionId: "sess-worker",
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await executeMainSessionsList();
|
|
expectWorkerTranscriptPath(result, {
|
|
containsPath: path.join("agents", "worker", "sessions"),
|
|
sessionId: "sess-worker",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("resolves transcriptPath even when sessions.list does not return a store path", async () => {
|
|
await withStubbedStateDir("openclaw-state-no-path", async () => {
|
|
callGatewayMock.mockResolvedValueOnce({
|
|
sessions: [
|
|
{
|
|
key: "agent:worker:main",
|
|
kind: "direct",
|
|
sessionId: "sess-worker-no-path",
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await executeMainSessionsList();
|
|
expectWorkerTranscriptPath(result, {
|
|
containsPath: path.join("agents", "worker", "sessions"),
|
|
sessionId: "sess-worker-no-path",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("falls back to agent defaults when gateway path is non-string", async () => {
|
|
await withStubbedStateDir("openclaw-state-non-string-path", async () => {
|
|
callGatewayMock.mockResolvedValueOnce({
|
|
path: { raw: "agents/main/sessions/sessions.json" },
|
|
sessions: [
|
|
{
|
|
key: "agent:worker:main",
|
|
kind: "direct",
|
|
sessionId: "sess-worker-shape",
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await executeMainSessionsList();
|
|
expectWorkerTranscriptPath(result, {
|
|
containsPath: path.join("agents", "worker", "sessions"),
|
|
sessionId: "sess-worker-shape",
|
|
});
|
|
});
|
|
});
|
|
|
|
it("falls back to agent defaults when gateway path is '(multiple)'", async () => {
|
|
await withStubbedStateDir("openclaw-state-multiple", async (stateDir) => {
|
|
callGatewayMock.mockResolvedValueOnce({
|
|
path: "(multiple)",
|
|
sessions: [
|
|
{
|
|
key: "agent:worker:main",
|
|
kind: "direct",
|
|
sessionId: "sess-worker-multiple",
|
|
},
|
|
],
|
|
});
|
|
|
|
const result = await executeMainSessionsList();
|
|
expectWorkerTranscriptPath(result, {
|
|
containsPath: path.join(stateDir, "agents", "worker", "sessions"),
|
|
sessionId: "sess-worker-multiple",
|
|
});
|
|
});
|
|
});
|
|
|
|
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 result = await executeMainSessionsList();
|
|
const expectedSessionsDir = path.dirname(templateStorePath.replace("{agentId}", "worker"));
|
|
expectWorkerTranscriptPath(result, {
|
|
containsPath: expectedSessionsDir,
|
|
sessionId: "sess-worker-template",
|
|
});
|
|
});
|
|
});
|
|
|
|
describe("sessions_send gating", () => {
|
|
beforeEach(() => {
|
|
callGatewayMock.mockClear();
|
|
});
|
|
|
|
it("returns an error when neither sessionKey nor label is provided", async () => {
|
|
const tool = createMainSessionsSendTool();
|
|
|
|
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 = createMainSessionsSendTool();
|
|
|
|
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 = createMainSessionsSendTool();
|
|
|
|
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" });
|
|
});
|
|
});
|