test: optimize gateway infra memory and security coverage

This commit is contained in:
Peter Steinberger
2026-02-21 21:43:20 +00:00
parent 58254b3b57
commit cc2ff68947
24 changed files with 1163 additions and 1284 deletions

View File

@@ -52,11 +52,6 @@ describe("profile name validation", () => {
}); });
describe("port allocation", () => { describe("port allocation", () => {
it("allocates first port when none used", () => {
const usedPorts = new Set<number>();
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START);
});
it("allocates within an explicit range", () => { it("allocates within an explicit range", () => {
const usedPorts = new Set<number>(); const usedPorts = new Set<number>();
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20000); expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20000);
@@ -64,17 +59,29 @@ describe("port allocation", () => {
expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20001); expect(allocateCdpPort(usedPorts, { start: 20000, end: 20002 })).toBe(20001);
}); });
it("skips used ports and returns next available", () => { it("allocates next available port from default range", () => {
const usedPorts = new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]); const cases = [
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 2); { name: "none used", used: new Set<number>(), expected: CDP_PORT_RANGE_START },
}); {
name: "sequentially used start ports",
used: new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 1]),
expected: CDP_PORT_RANGE_START + 2,
},
{
name: "first gap wins",
used: new Set([CDP_PORT_RANGE_START, CDP_PORT_RANGE_START + 2]),
expected: CDP_PORT_RANGE_START + 1,
},
{
name: "ignores outside-range ports",
used: new Set([1, 2, 3, 50000]),
expected: CDP_PORT_RANGE_START,
},
] as const;
it("finds first gap in used ports", () => { for (const testCase of cases) {
const usedPorts = new Set([ expect(allocateCdpPort(testCase.used), testCase.name).toBe(testCase.expected);
CDP_PORT_RANGE_START, }
CDP_PORT_RANGE_START + 2, // gap at +1
]);
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START + 1);
}); });
it("returns null when all ports are exhausted", () => { it("returns null when all ports are exhausted", () => {
@@ -84,11 +91,6 @@ describe("port allocation", () => {
} }
expect(allocateCdpPort(usedPorts)).toBeNull(); expect(allocateCdpPort(usedPorts)).toBeNull();
}); });
it("handles ports outside range in used set", () => {
const usedPorts = new Set([1, 2, 3, 50000]); // ports outside range
expect(allocateCdpPort(usedPorts)).toBe(CDP_PORT_RANGE_START);
});
}); });
describe("getUsedPorts", () => { describe("getUsedPorts", () => {
@@ -167,23 +169,27 @@ describe("port collision prevention", () => {
}); });
describe("color allocation", () => { describe("color allocation", () => {
it("allocates first color when none used", () => {
const usedColors = new Set<string>();
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[0]);
});
it("allocates next unused color from palette", () => { it("allocates next unused color from palette", () => {
const usedColors = new Set([PROFILE_COLORS[0].toUpperCase()]); const cases = [
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[1]); { name: "none used", used: new Set<string>(), expected: PROFILE_COLORS[0] },
}); {
name: "first color used",
it("skips multiple used colors", () => { used: new Set([PROFILE_COLORS[0].toUpperCase()]),
const usedColors = new Set([ expected: PROFILE_COLORS[1],
PROFILE_COLORS[0].toUpperCase(), },
PROFILE_COLORS[1].toUpperCase(), {
PROFILE_COLORS[2].toUpperCase(), name: "multiple used colors",
]); used: new Set([
expect(allocateColor(usedColors)).toBe(PROFILE_COLORS[3]); PROFILE_COLORS[0].toUpperCase(),
PROFILE_COLORS[1].toUpperCase(),
PROFILE_COLORS[2].toUpperCase(),
]),
expected: PROFILE_COLORS[3],
},
] as const;
for (const testCase of cases) {
expect(allocateColor(testCase.used), testCase.name).toBe(testCase.expected);
}
}); });
it("handles case-insensitive color matching", () => { it("handles case-insensitive color matching", () => {
@@ -215,7 +221,7 @@ describe("color allocation", () => {
}); });
describe("getUsedColors", () => { describe("getUsedColors", () => {
it("returns empty set for undefined profiles", () => { it("returns empty set when no color profiles are configured", () => {
expect(getUsedColors(undefined)).toEqual(new Set()); expect(getUsedColors(undefined)).toEqual(new Set());
}); });

View File

@@ -123,7 +123,7 @@ describe("callGateway url resolution", () => {
label: "falls back to loopback when local bind is auto without tailnet IP", label: "falls back to loopback when local bind is auto without tailnet IP",
tailnetIp: undefined, tailnetIp: undefined,
}, },
])("$label", async ({ tailnetIp }) => { ])("local auto-bind: $label", async ({ tailnetIp }) => {
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } }); loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
resolveGatewayPort.mockReturnValue(18800); resolveGatewayPort.mockReturnValue(18800);
pickPrimaryTailnetIPv4.mockReturnValue(tailnetIp); pickPrimaryTailnetIPv4.mockReturnValue(tailnetIp);
@@ -218,7 +218,7 @@ describe("callGateway url resolution", () => {
call: () => callGatewayCli({ method: "health" }), call: () => callGatewayCli({ method: "health" }),
expectedScopes: ["operator.admin", "operator.approvals", "operator.pairing"], expectedScopes: ["operator.admin", "operator.approvals", "operator.pairing"],
}, },
])("$label", async ({ call, expectedScopes }) => { ])("scope selection: $label", async ({ call, expectedScopes }) => {
setLocalLoopbackGatewayConfig(); setLocalLoopbackGatewayConfig();
await call(); await call();
expect(lastClientOptions?.scopes).toEqual(expectedScopes); expect(lastClientOptions?.scopes).toEqual(expectedScopes);

View File

@@ -32,33 +32,6 @@ describe("buildMessageWithAttachments", () => {
}; };
expect(() => buildMessageWithAttachments("x", [bad])).toThrow(/image/); expect(() => buildMessageWithAttachments("x", [bad])).toThrow(/image/);
}); });
it("rejects invalid base64 content", () => {
const bad: ChatAttachment = {
type: "image",
mimeType: "image/png",
fileName: "dot.png",
content: "%not-base64%",
};
expect(() => buildMessageWithAttachments("x", [bad])).toThrow(/base64/);
});
it("rejects images over limit", () => {
const big = "A".repeat(10_000);
const att: ChatAttachment = {
type: "image",
mimeType: "image/png",
fileName: "big.png",
content: big,
};
const fromSpy = vi.spyOn(Buffer, "from");
expect(() => buildMessageWithAttachments("x", [att], { maxBytes: 16 })).toThrow(
/exceeds size limit/i,
);
const base64Calls = fromSpy.mock.calls.filter((args) => (args as unknown[])[1] === "base64");
expect(base64Calls).toHaveLength(0);
fromSpy.mockRestore();
});
}); });
describe("parseMessageWithAttachments", () => { describe("parseMessageWithAttachments", () => {
@@ -80,45 +53,6 @@ describe("parseMessageWithAttachments", () => {
expect(parsed.images[0]?.data).toBe(PNG_1x1); expect(parsed.images[0]?.data).toBe(PNG_1x1);
}); });
it("rejects invalid base64 content", async () => {
await expect(
parseMessageWithAttachments(
"x",
[
{
type: "image",
mimeType: "image/png",
fileName: "dot.png",
content: "%not-base64%",
},
],
{ log: { warn: () => {} } },
),
).rejects.toThrow(/base64/i);
});
it("rejects images over limit", async () => {
const big = "A".repeat(10_000);
const fromSpy = vi.spyOn(Buffer, "from");
await expect(
parseMessageWithAttachments(
"x",
[
{
type: "image",
mimeType: "image/png",
fileName: "big.png",
content: big,
},
],
{ maxBytes: 16, log: { warn: () => {} } },
),
).rejects.toThrow(/exceeds size limit/i);
const base64Calls = fromSpy.mock.calls.filter((args) => (args as unknown[])[1] === "base64");
expect(base64Calls).toHaveLength(0);
fromSpy.mockRestore();
});
it("sniffs mime when missing", async () => { it("sniffs mime when missing", async () => {
const logs: string[] = []; const logs: string[] = [];
const parsed = await parseMessageWithAttachments( const parsed = await parseMessageWithAttachments(
@@ -219,3 +153,43 @@ describe("parseMessageWithAttachments", () => {
expect(logs.some((l) => /non-image/i.test(l))).toBe(true); expect(logs.some((l) => /non-image/i.test(l))).toBe(true);
}); });
}); });
describe("shared attachment validation", () => {
it("rejects invalid base64 content for both builder and parser", async () => {
const bad: ChatAttachment = {
type: "image",
mimeType: "image/png",
fileName: "dot.png",
content: "%not-base64%",
};
expect(() => buildMessageWithAttachments("x", [bad])).toThrow(/base64/i);
await expect(
parseMessageWithAttachments("x", [bad], { log: { warn: () => {} } }),
).rejects.toThrow(/base64/i);
});
it("rejects images over limit for both builder and parser without decoding base64", async () => {
const big = "A".repeat(10_000);
const att: ChatAttachment = {
type: "image",
mimeType: "image/png",
fileName: "big.png",
content: big,
};
const fromSpy = vi.spyOn(Buffer, "from");
try {
expect(() => buildMessageWithAttachments("x", [att], { maxBytes: 16 })).toThrow(
/exceeds size limit/i,
);
await expect(
parseMessageWithAttachments("x", [att], { maxBytes: 16, log: { warn: () => {} } }),
).rejects.toThrow(/exceeds size limit/i);
const base64Calls = fromSpy.mock.calls.filter((args) => (args as unknown[])[1] === "base64");
expect(base64Calls).toHaveLength(0);
} finally {
fromSpy.mockRestore();
}
});
});

View File

@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs/promises"; import fs from "node:fs/promises";
import os from "node:os"; import os from "node:os";
import path from "node:path"; import path from "node:path";
import { describe, expect, it } from "vitest"; import { beforeAll, describe, expect, it } from "vitest";
import { captureEnv } from "../test-utils/env.js"; import { captureEnv } from "../test-utils/env.js";
import { startGatewayServer } from "./server.js"; import { startGatewayServer } from "./server.js";
import { extractPayloadText } from "./test-helpers.agent-results.js"; import { extractPayloadText } from "./test-helpers.agent-results.js";
@@ -15,7 +15,14 @@ import {
import { installOpenAiResponsesMock } from "./test-helpers.openai-mock.js"; import { installOpenAiResponsesMock } from "./test-helpers.openai-mock.js";
import { buildOpenAiResponsesProviderConfig } from "./test-openai-responses-model.js"; import { buildOpenAiResponsesProviderConfig } from "./test-openai-responses-model.js";
let writeConfigFile: typeof import("../config/config.js").writeConfigFile;
let resolveConfigPath: typeof import("../config/config.js").resolveConfigPath;
describe("gateway e2e", () => { describe("gateway e2e", () => {
beforeAll(async () => {
({ writeConfigFile, resolveConfigPath } = await import("../config/config.js"));
});
it( it(
"runs a mock OpenAI tool call end-to-end via gateway agent loop", "runs a mock OpenAI tool call end-to-end via gateway agent loop",
{ timeout: 90_000 }, { timeout: 90_000 },
@@ -148,7 +155,6 @@ describe("gateway e2e", () => {
await prompter.intro("Wizard E2E"); await prompter.intro("Wizard E2E");
await prompter.note("write token"); await prompter.note("write token");
const token = await prompter.text({ message: "token" }); const token = await prompter.text({ message: "token" });
const { writeConfigFile } = await import("../config/config.js");
await writeConfigFile({ await writeConfigFile({
gateway: { auth: { mode: "token", token: String(token) } }, gateway: { auth: { mode: "token", token: String(token) } },
}); });
@@ -196,7 +202,6 @@ describe("gateway e2e", () => {
expect(didSendToken).toBe(true); expect(didSendToken).toBe(true);
expect(next.status).toBe("done"); expect(next.status).toBe("done");
const { resolveConfigPath } = await import("../config/config.js");
const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8")); const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8"));
const token = (parsed as Record<string, unknown>)?.gateway as const token = (parsed as Record<string, unknown>)?.gateway as
| Record<string, unknown> | Record<string, unknown>

View File

@@ -11,14 +11,16 @@ import {
} from "./net.js"; } from "./net.js";
describe("resolveHostName", () => { describe("resolveHostName", () => {
it("returns hostname without port for IPv4/hostnames", () => { it("normalizes IPv4/hostname and IPv6 host forms", () => {
expect(resolveHostName("localhost:18789")).toBe("localhost"); const cases = [
expect(resolveHostName("127.0.0.1:18789")).toBe("127.0.0.1"); { input: "localhost:18789", expected: "localhost" },
}); { input: "127.0.0.1:18789", expected: "127.0.0.1" },
{ input: "[::1]:18789", expected: "::1" },
it("handles bracketed and unbracketed IPv6 loopback hosts", () => { { input: "::1", expected: "::1" },
expect(resolveHostName("[::1]:18789")).toBe("::1"); ] as const;
expect(resolveHostName("::1")).toBe("::1"); for (const testCase of cases) {
expect(resolveHostName(testCase.input), testCase.input).toBe(testCase.expected);
}
}); });
}); });
@@ -204,27 +206,36 @@ describe("resolveClientIp", () => {
}); });
describe("resolveGatewayListenHosts", () => { describe("resolveGatewayListenHosts", () => {
it("returns the input host when not loopback", async () => { it("resolves listen hosts for non-loopback and loopback variants", async () => {
const hosts = await resolveGatewayListenHosts("0.0.0.0", { const cases = [
canBindToHost: async () => { {
throw new Error("should not be called"); name: "non-loopback host passthrough",
host: "0.0.0.0",
canBindToHost: async () => {
throw new Error("should not be called");
},
expected: ["0.0.0.0"],
}, },
}); {
expect(hosts).toEqual(["0.0.0.0"]); name: "loopback with IPv6 available",
}); host: "127.0.0.1",
canBindToHost: async () => true,
expected: ["127.0.0.1", "::1"],
},
{
name: "loopback with IPv6 unavailable",
host: "127.0.0.1",
canBindToHost: async () => false,
expected: ["127.0.0.1"],
},
] as const;
it("adds ::1 when IPv6 loopback is available", async () => { for (const testCase of cases) {
const hosts = await resolveGatewayListenHosts("127.0.0.1", { const hosts = await resolveGatewayListenHosts(testCase.host, {
canBindToHost: async () => true, canBindToHost: testCase.canBindToHost,
}); });
expect(hosts).toEqual(["127.0.0.1", "::1"]); expect(hosts, testCase.name).toEqual(testCase.expected);
}); }
it("keeps only IPv4 loopback when IPv6 is unavailable", async () => {
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
canBindToHost: async () => false,
});
expect(hosts).toEqual(["127.0.0.1"]);
}); });
}); });
@@ -233,49 +244,48 @@ describe("pickPrimaryLanIPv4", () => {
vi.restoreAllMocks(); vi.restoreAllMocks();
}); });
it("returns en0 IPv4 address when available", () => { it("prefers en0, then eth0, then any non-internal IPv4, otherwise undefined", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({ const cases = [
lo0: [ {
{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, name: "prefers en0",
] as unknown as os.NetworkInterfaceInfo[], interfaces: {
en0: [ lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
{ address: "192.168.1.42", family: "IPv4", internal: false, netmask: "" }, en0: [{ address: "192.168.1.42", family: "IPv4", internal: false, netmask: "" }],
] as unknown as os.NetworkInterfaceInfo[], },
}); expected: "192.168.1.42",
expect(pickPrimaryLanIPv4()).toBe("192.168.1.42"); },
}); {
name: "falls back to eth0",
interfaces: {
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
eth0: [{ address: "10.0.0.5", family: "IPv4", internal: false, netmask: "" }],
},
expected: "10.0.0.5",
},
{
name: "falls back to any non-internal interface",
interfaces: {
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
wlan0: [{ address: "172.16.0.99", family: "IPv4", internal: false, netmask: "" }],
},
expected: "172.16.0.99",
},
{
name: "no non-internal interface",
interfaces: {
lo: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
},
expected: undefined,
},
] as const;
it("returns eth0 IPv4 address when en0 is absent", () => { for (const testCase of cases) {
vi.spyOn(os, "networkInterfaces").mockReturnValue({ vi.spyOn(os, "networkInterfaces").mockReturnValue(
lo: [ testCase.interfaces as unknown as ReturnType<typeof os.networkInterfaces>,
{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }, );
] as unknown as os.NetworkInterfaceInfo[], expect(pickPrimaryLanIPv4(), testCase.name).toBe(testCase.expected);
eth0: [ vi.restoreAllMocks();
{ address: "10.0.0.5", family: "IPv4", internal: false, netmask: "" }, }
] as unknown as os.NetworkInterfaceInfo[],
});
expect(pickPrimaryLanIPv4()).toBe("10.0.0.5");
});
it("falls back to any non-internal IPv4 interface", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo: [
{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" },
] as unknown as os.NetworkInterfaceInfo[],
wlan0: [
{ address: "172.16.0.99", family: "IPv4", internal: false, netmask: "" },
] as unknown as os.NetworkInterfaceInfo[],
});
expect(pickPrimaryLanIPv4()).toBe("172.16.0.99");
});
it("returns undefined when only internal interfaces exist", () => {
vi.spyOn(os, "networkInterfaces").mockReturnValue({
lo: [
{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" },
] as unknown as os.NetworkInterfaceInfo[],
});
expect(pickPrimaryLanIPv4()).toBeUndefined();
}); });
}); });
@@ -312,40 +322,28 @@ describe("isPrivateOrLoopbackAddress", () => {
}); });
describe("isSecureWebSocketUrl", () => { describe("isSecureWebSocketUrl", () => {
describe("wss:// (TLS) URLs", () => { it("accepts secure websocket/loopback ws URLs and rejects unsafe inputs", () => {
it("returns true for wss:// regardless of host", () => { const cases = [
expect(isSecureWebSocketUrl("wss://127.0.0.1:18789")).toBe(true); { input: "wss://127.0.0.1:18789", expected: true },
expect(isSecureWebSocketUrl("wss://localhost:18789")).toBe(true); { input: "wss://localhost:18789", expected: true },
expect(isSecureWebSocketUrl("wss://remote.example.com:18789")).toBe(true); { input: "wss://remote.example.com:18789", expected: true },
expect(isSecureWebSocketUrl("wss://192.168.1.100:18789")).toBe(true); { input: "wss://192.168.1.100:18789", expected: true },
}); { input: "ws://127.0.0.1:18789", expected: true },
}); { input: "ws://localhost:18789", expected: true },
{ input: "ws://[::1]:18789", expected: true },
{ input: "ws://127.0.0.42:18789", expected: true },
{ input: "ws://remote.example.com:18789", expected: false },
{ input: "ws://192.168.1.100:18789", expected: false },
{ input: "ws://10.0.0.5:18789", expected: false },
{ input: "ws://100.64.0.1:18789", expected: false },
{ input: "not-a-url", expected: false },
{ input: "", expected: false },
{ input: "http://127.0.0.1:18789", expected: false },
{ input: "https://127.0.0.1:18789", expected: false },
] as const;
describe("ws:// (plaintext) URLs", () => { for (const testCase of cases) {
it("returns true for ws:// to loopback addresses", () => { expect(isSecureWebSocketUrl(testCase.input), testCase.input).toBe(testCase.expected);
expect(isSecureWebSocketUrl("ws://127.0.0.1:18789")).toBe(true); }
expect(isSecureWebSocketUrl("ws://localhost:18789")).toBe(true);
expect(isSecureWebSocketUrl("ws://[::1]:18789")).toBe(true);
expect(isSecureWebSocketUrl("ws://127.0.0.42:18789")).toBe(true);
});
it("returns false for ws:// to non-loopback addresses (CWE-319)", () => {
expect(isSecureWebSocketUrl("ws://remote.example.com:18789")).toBe(false);
expect(isSecureWebSocketUrl("ws://192.168.1.100:18789")).toBe(false);
expect(isSecureWebSocketUrl("ws://10.0.0.5:18789")).toBe(false);
expect(isSecureWebSocketUrl("ws://100.64.0.1:18789")).toBe(false);
});
});
describe("invalid URLs", () => {
it("returns false for invalid URLs", () => {
expect(isSecureWebSocketUrl("not-a-url")).toBe(false);
expect(isSecureWebSocketUrl("")).toBe(false);
});
it("returns false for non-WebSocket protocols", () => {
expect(isSecureWebSocketUrl("http://127.0.0.1:18789")).toBe(false);
expect(isSecureWebSocketUrl("https://127.0.0.1:18789")).toBe(false);
});
}); });
}); });

View File

@@ -13,10 +13,12 @@ import {
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks({ scope: "suite" });
let startGatewayServer: typeof import("./server.js").startGatewayServer;
let enabledServer: Awaited<ReturnType<typeof startServer>>; let enabledServer: Awaited<ReturnType<typeof startServer>>;
let enabledPort: number; let enabledPort: number;
beforeAll(async () => { beforeAll(async () => {
({ startGatewayServer } = await import("./server.js"));
enabledPort = await getFreePort(); enabledPort = await getFreePort();
enabledServer = await startServer(enabledPort); enabledServer = await startServer(enabledPort);
}); });
@@ -26,7 +28,6 @@ afterAll(async () => {
}); });
async function startServerWithDefaultConfig(port: number) { async function startServerWithDefaultConfig(port: number) {
const { startGatewayServer } = await import("./server.js");
return await startGatewayServer(port, { return await startGatewayServer(port, {
host: "127.0.0.1", host: "127.0.0.1",
auth: { mode: "token", token: "secret" }, auth: { mode: "token", token: "secret" },
@@ -36,7 +37,6 @@ async function startServerWithDefaultConfig(port: number) {
} }
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) { async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
const { startGatewayServer } = await import("./server.js");
return await startGatewayServer(port, { return await startGatewayServer(port, {
host: "127.0.0.1", host: "127.0.0.1",
auth: { mode: "token", token: "secret" }, auth: { mode: "token", token: "secret" },

View File

@@ -5,13 +5,29 @@
* support in the OpenResponses `/v1/responses` endpoint. * support in the OpenResponses `/v1/responses` endpoint.
*/ */
import { describe, it, expect } from "vitest"; import { beforeAll, describe, it, expect } from "vitest";
let InputImageContentPartSchema: typeof import("./open-responses.schema.js").InputImageContentPartSchema;
let InputFileContentPartSchema: typeof import("./open-responses.schema.js").InputFileContentPartSchema;
let ToolDefinitionSchema: typeof import("./open-responses.schema.js").ToolDefinitionSchema;
let CreateResponseBodySchema: typeof import("./open-responses.schema.js").CreateResponseBodySchema;
let OutputItemSchema: typeof import("./open-responses.schema.js").OutputItemSchema;
let buildAgentPrompt: typeof import("./openresponses-http.js").buildAgentPrompt;
describe("OpenResponses Feature Parity", () => { describe("OpenResponses Feature Parity", () => {
beforeAll(async () => {
({
InputImageContentPartSchema,
InputFileContentPartSchema,
ToolDefinitionSchema,
CreateResponseBodySchema,
OutputItemSchema,
} = await import("./open-responses.schema.js"));
({ buildAgentPrompt } = await import("./openresponses-http.js"));
});
describe("Schema Validation", () => { describe("Schema Validation", () => {
it("should validate input_image with url source", async () => { it("should validate input_image with url source", async () => {
const { InputImageContentPartSchema } = await import("./open-responses.schema.js");
const validImage = { const validImage = {
type: "input_image" as const, type: "input_image" as const,
source: { source: {
@@ -25,8 +41,6 @@ describe("OpenResponses Feature Parity", () => {
}); });
it("should validate input_image with base64 source", async () => { it("should validate input_image with base64 source", async () => {
const { InputImageContentPartSchema } = await import("./open-responses.schema.js");
const validImage = { const validImage = {
type: "input_image" as const, type: "input_image" as const,
source: { source: {
@@ -41,8 +55,6 @@ describe("OpenResponses Feature Parity", () => {
}); });
it("should reject input_image with invalid mime type", async () => { it("should reject input_image with invalid mime type", async () => {
const { InputImageContentPartSchema } = await import("./open-responses.schema.js");
const invalidImage = { const invalidImage = {
type: "input_image" as const, type: "input_image" as const,
source: { source: {
@@ -57,8 +69,6 @@ describe("OpenResponses Feature Parity", () => {
}); });
it("should validate input_file with url source", async () => { it("should validate input_file with url source", async () => {
const { InputFileContentPartSchema } = await import("./open-responses.schema.js");
const validFile = { const validFile = {
type: "input_file" as const, type: "input_file" as const,
source: { source: {
@@ -72,8 +82,6 @@ describe("OpenResponses Feature Parity", () => {
}); });
it("should validate input_file with base64 source", async () => { it("should validate input_file with base64 source", async () => {
const { InputFileContentPartSchema } = await import("./open-responses.schema.js");
const validFile = { const validFile = {
type: "input_file" as const, type: "input_file" as const,
source: { source: {
@@ -89,8 +97,6 @@ describe("OpenResponses Feature Parity", () => {
}); });
it("should validate tool definition", async () => { it("should validate tool definition", async () => {
const { ToolDefinitionSchema } = await import("./open-responses.schema.js");
const validTool = { const validTool = {
type: "function" as const, type: "function" as const,
function: { function: {
@@ -111,8 +117,6 @@ describe("OpenResponses Feature Parity", () => {
}); });
it("should reject tool definition without name", async () => { it("should reject tool definition without name", async () => {
const { ToolDefinitionSchema } = await import("./open-responses.schema.js");
const invalidTool = { const invalidTool = {
type: "function" as const, type: "function" as const,
function: { function: {
@@ -128,8 +132,6 @@ describe("OpenResponses Feature Parity", () => {
describe("CreateResponseBody Schema", () => { describe("CreateResponseBody Schema", () => {
it("should validate request with input_image", async () => { it("should validate request with input_image", async () => {
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
const validRequest = { const validRequest = {
model: "claude-sonnet-4-20250514", model: "claude-sonnet-4-20250514",
input: [ input: [
@@ -158,8 +160,6 @@ describe("OpenResponses Feature Parity", () => {
}); });
it("should validate request with client tools", async () => { it("should validate request with client tools", async () => {
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
const validRequest = { const validRequest = {
model: "claude-sonnet-4-20250514", model: "claude-sonnet-4-20250514",
input: [ input: [
@@ -192,8 +192,6 @@ describe("OpenResponses Feature Parity", () => {
}); });
it("should validate request with function_call_output for turn-based tools", async () => { it("should validate request with function_call_output for turn-based tools", async () => {
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
const validRequest = { const validRequest = {
model: "claude-sonnet-4-20250514", model: "claude-sonnet-4-20250514",
input: [ input: [
@@ -210,8 +208,6 @@ describe("OpenResponses Feature Parity", () => {
}); });
it("should validate complete turn-based tool flow", async () => { it("should validate complete turn-based tool flow", async () => {
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
const turn1Request = { const turn1Request = {
model: "claude-sonnet-4-20250514", model: "claude-sonnet-4-20250514",
input: [ input: [
@@ -254,8 +250,6 @@ describe("OpenResponses Feature Parity", () => {
describe("Response Resource Schema", () => { describe("Response Resource Schema", () => {
it("should validate response with function_call output", async () => { it("should validate response with function_call output", async () => {
const { OutputItemSchema } = await import("./open-responses.schema.js");
const functionCallOutput = { const functionCallOutput = {
type: "function_call" as const, type: "function_call" as const,
id: "msg_123", id: "msg_123",
@@ -271,8 +265,6 @@ describe("OpenResponses Feature Parity", () => {
describe("buildAgentPrompt", () => { describe("buildAgentPrompt", () => {
it("should convert function_call_output to tool entry", async () => { it("should convert function_call_output to tool entry", async () => {
const { buildAgentPrompt } = await import("./openresponses-http.js");
const result = buildAgentPrompt([ const result = buildAgentPrompt([
{ {
type: "function_call_output" as const, type: "function_call_output" as const,
@@ -286,8 +278,6 @@ describe("OpenResponses Feature Parity", () => {
}); });
it("should handle mixed message and function_call_output items", async () => { it("should handle mixed message and function_call_output items", async () => {
const { buildAgentPrompt } = await import("./openresponses-http.js");
const result = buildAgentPrompt([ const result = buildAgentPrompt([
{ {
type: "message" as const, type: "message" as const,

View File

@@ -11,7 +11,8 @@ import {
startServerWithClient, startServerWithClient,
} from "./test-helpers.js"; } from "./test-helpers.js";
const loadConfigHelpers = async () => await import("../config/config.js"); let readConfigFileSnapshot: typeof import("../config/config.js").readConfigFileSnapshot;
let writeConfigFile: typeof import("../config/config.js").writeConfigFile;
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks({ scope: "suite" });
@@ -77,7 +78,6 @@ const telegramPlugin: ChannelPlugin = {
}), }),
gateway: { gateway: {
logoutAccount: async ({ cfg }) => { logoutAccount: async ({ cfg }) => {
const { writeConfigFile } = await import("../config/config.js");
const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : {}; const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : {};
delete nextTelegram.botToken; delete nextTelegram.botToken;
await writeConfigFile({ await writeConfigFile({
@@ -118,6 +118,7 @@ let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"]; let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
beforeAll(async () => { beforeAll(async () => {
({ readConfigFileSnapshot, writeConfigFile } = await import("../config/config.js"));
setRegistry(defaultRegistry); setRegistry(defaultRegistry);
const started = await startServerWithClient(); const started = await startServerWithClient();
server = started.server; server = started.server;
@@ -177,7 +178,6 @@ describe("gateway server channels", () => {
test("channels.logout clears telegram bot token from config", async () => { test("channels.logout clears telegram bot token from config", async () => {
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined); vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
setRegistry(defaultRegistry); setRegistry(defaultRegistry);
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
await writeConfigFile({ await writeConfigFile({
channels: { channels: {
telegram: { telegram: {

View File

@@ -4,6 +4,36 @@ import { withServer } from "./test-with-server.js";
installGatewayTestHooks({ scope: "suite" }); installGatewayTestHooks({ scope: "suite" });
async function createFreshOperatorDevice(scopes: string[]) {
const { randomUUID } = await import("node:crypto");
const { tmpdir } = await import("node:os");
const { join } = await import("node:path");
const { buildDeviceAuthPayload } = await import("./device-auth.js");
const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } =
await import("../infra/device-identity.js");
const identity = loadOrCreateDeviceIdentity(
join(tmpdir(), `openclaw-talk-config-${randomUUID()}.json`),
);
const signedAtMs = Date.now();
const payload = buildDeviceAuthPayload({
deviceId: identity.deviceId,
clientId: "test",
clientMode: "test",
role: "operator",
scopes,
signedAtMs,
token: "secret",
});
return {
id: identity.deviceId,
publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem),
signature: signDevicePayload(identity.privateKeyPem, payload),
signedAt: signedAtMs,
};
}
describe("gateway talk.config", () => { describe("gateway talk.config", () => {
it("returns redacted talk config for read scope", async () => { it("returns redacted talk config for read scope", async () => {
const { writeConfigFile } = await import("../config/config.js"); const { writeConfigFile } = await import("../config/config.js");
@@ -21,7 +51,11 @@ describe("gateway talk.config", () => {
}); });
await withServer(async (ws) => { await withServer(async (ws) => {
await connectOk(ws, { token: "secret", scopes: ["operator.read"] }); await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
device: await createFreshOperatorDevice(["operator.read"]),
});
const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>( const res = await rpcReq<{ config?: { talk?: { apiKey?: string; voiceId?: string } } }>(
ws, ws,
"talk.config", "talk.config",
@@ -42,7 +76,11 @@ describe("gateway talk.config", () => {
}); });
await withServer(async (ws) => { await withServer(async (ws) => {
await connectOk(ws, { token: "secret", scopes: ["operator.read"] }); await connectOk(ws, {
token: "secret",
scopes: ["operator.read"],
device: await createFreshOperatorDevice(["operator.read"]),
});
const res = await rpcReq(ws, "talk.config", { includeSecrets: true }); const res = await rpcReq(ws, "talk.config", { includeSecrets: true });
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
expect(res.error?.message).toContain("missing scope: operator.talk.secrets"); expect(res.error?.message).toContain("missing scope: operator.talk.secrets");
@@ -61,6 +99,11 @@ describe("gateway talk.config", () => {
await connectOk(ws, { await connectOk(ws, {
token: "secret", token: "secret",
scopes: ["operator.read", "operator.write", "operator.talk.secrets"], scopes: ["operator.read", "operator.write", "operator.talk.secrets"],
device: await createFreshOperatorDevice([
"operator.read",
"operator.write",
"operator.talk.secrets",
]),
}); });
const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", { const res = await rpcReq<{ config?: { talk?: { apiKey?: string } } }>(ws, "talk.config", {
includeSecrets: true, includeSecrets: true,

View File

@@ -38,59 +38,51 @@ describe("readFirstUserMessageFromTranscript", () => {
storePath = nextStorePath; storePath = nextStorePath;
}); });
test("returns null when transcript file does not exist", () => { test("extracts first user text across supported content formats", () => {
const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath); const cases = [
expect(result).toBeNull(); {
}); sessionId: "test-session-1",
lines: [
JSON.stringify({ type: "session", version: 1, id: "test-session-1" }),
JSON.stringify({ message: { role: "user", content: "Hello world" } }),
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
],
expected: "Hello world",
},
{
sessionId: "test-session-2",
lines: [
JSON.stringify({ type: "session", version: 1, id: "test-session-2" }),
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "Array message content" }],
},
}),
],
expected: "Array message content",
},
{
sessionId: "test-session-2b",
lines: [
JSON.stringify({ type: "session", version: 1, id: "test-session-2b" }),
JSON.stringify({
message: {
role: "user",
content: [{ type: "input_text", text: "Input text content" }],
},
}),
],
expected: "Input text content",
},
] as const;
test("returns first user message from transcript with string content", () => { for (const testCase of cases) {
const sessionId = "test-session-1"; const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`);
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); fs.writeFileSync(transcriptPath, testCase.lines.join("\n"), "utf-8");
const lines = [ const result = readFirstUserMessageFromTranscript(testCase.sessionId, storePath);
JSON.stringify({ type: "session", version: 1, id: sessionId }), expect(result, testCase.sessionId).toBe(testCase.expected);
JSON.stringify({ message: { role: "user", content: "Hello world" } }), }
JSON.stringify({ message: { role: "assistant", content: "Hi there" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Hello world");
});
test("returns first user message from transcript with array content", () => {
const sessionId = "test-session-2";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({
message: {
role: "user",
content: [{ type: "text", text: "Array message content" }],
},
}),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Array message content");
});
test("returns first user message from transcript with input_text content", () => {
const sessionId = "test-session-2b";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({
message: {
role: "user",
content: [{ type: "input_text", text: "Input text content" }],
},
}),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Input text content");
}); });
test("skips non-user messages to find first user message", () => { test("skips non-user messages to find first user message", () => {
const sessionId = "test-session-3"; const sessionId = "test-session-3";
@@ -155,29 +147,6 @@ describe("readFirstUserMessageFromTranscript", () => {
expect(result).toBe("Valid message"); expect(result).toBe("Valid message");
}); });
test("uses sessionFile parameter when provided", () => {
const sessionId = "test-session-6";
const customPath = path.join(tmpDir, "custom-transcript.jsonl");
const lines = [
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
];
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath, customPath);
expect(result).toBe("Custom file message");
});
test("trims whitespace from message content", () => {
const sessionId = "test-session-7";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [JSON.stringify({ message: { role: "user", content: " Padded message " } })];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readFirstUserMessageFromTranscript(sessionId, storePath);
expect(result).toBe("Padded message");
});
test("returns null for empty content", () => { test("returns null for empty content", () => {
const sessionId = "test-session-8"; const sessionId = "test-session-8";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
@@ -201,11 +170,6 @@ describe("readLastMessagePreviewFromTranscript", () => {
storePath = nextStorePath; storePath = nextStorePath;
}); });
test("returns null when transcript file does not exist", () => {
const result = readLastMessagePreviewFromTranscript("nonexistent-session", storePath);
expect(result).toBeNull();
});
test("returns null for empty file", () => { test("returns null for empty file", () => {
const sessionId = "test-last-empty"; const sessionId = "test-last-empty";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
@@ -215,31 +179,33 @@ describe("readLastMessagePreviewFromTranscript", () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("returns last user message from transcript", () => { test("returns the last user or assistant message from transcript", () => {
const sessionId = "test-last-user"; const cases = [
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); {
const lines = [ sessionId: "test-last-user",
JSON.stringify({ message: { role: "user", content: "First user" } }), lines: [
JSON.stringify({ message: { role: "assistant", content: "First assistant" } }), JSON.stringify({ message: { role: "user", content: "First user" } }),
JSON.stringify({ message: { role: "user", content: "Last user message" } }), JSON.stringify({ message: { role: "assistant", content: "First assistant" } }),
]; JSON.stringify({ message: { role: "user", content: "Last user message" } }),
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); ],
expected: "Last user message",
},
{
sessionId: "test-last-assistant",
lines: [
JSON.stringify({ message: { role: "user", content: "User question" } }),
JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }),
],
expected: "Final assistant reply",
},
] as const;
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); for (const testCase of cases) {
expect(result).toBe("Last user message"); const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`);
}); fs.writeFileSync(transcriptPath, testCase.lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(testCase.sessionId, storePath);
test("returns last assistant message from transcript", () => { expect(result).toBe(testCase.expected);
const sessionId = "test-last-assistant"; }
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "user", content: "User question" } }),
JSON.stringify({ message: { role: "assistant", content: "Final assistant reply" } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Final assistant reply");
}); });
test("skips system messages to find last user/assistant", () => { test("skips system messages to find last user/assistant", () => {
@@ -268,7 +234,7 @@ describe("readLastMessagePreviewFromTranscript", () => {
expect(result).toBeNull(); expect(result).toBeNull();
}); });
test("handles malformed JSON lines gracefully", () => { test("handles malformed JSON lines gracefully (last preview)", () => {
const sessionId = "test-last-malformed"; const sessionId = "test-last-malformed";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [ const lines = [
@@ -281,59 +247,31 @@ describe("readLastMessagePreviewFromTranscript", () => {
expect(result).toBe("Valid first"); expect(result).toBe("Valid first");
}); });
test("handles array content format", () => { test("handles array/output_text content formats", () => {
const sessionId = "test-last-array"; const cases = [
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); {
const lines = [ sessionId: "test-last-array",
JSON.stringify({
message: { message: {
role: "assistant", role: "assistant",
content: [{ type: "text", text: "Array content response" }], content: [{ type: "text", text: "Array content response" }],
}, },
}), expected: "Array content response",
]; },
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); {
sessionId: "test-last-output-text",
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Array content response");
});
test("handles output_text content format", () => {
const sessionId = "test-last-output-text";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({
message: { message: {
role: "assistant", role: "assistant",
content: [{ type: "output_text", text: "Output text response" }], content: [{ type: "output_text", text: "Output text response" }],
}, },
}), expected: "Output text response",
]; },
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8"); ] as const;
for (const testCase of cases) {
const result = readLastMessagePreviewFromTranscript(sessionId, storePath); const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`);
expect(result).toBe("Output text response"); fs.writeFileSync(transcriptPath, JSON.stringify({ message: testCase.message }), "utf-8");
}); const result = readLastMessagePreviewFromTranscript(testCase.sessionId, storePath);
test("uses sessionFile parameter when provided", () => { expect(result, testCase.sessionId).toBe(testCase.expected);
const sessionId = "test-last-custom"; }
const customPath = path.join(tmpDir, "custom-last.jsonl");
const lines = [JSON.stringify({ message: { role: "user", content: "Custom file last" } })];
fs.writeFileSync(customPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath, customPath);
expect(result).toBe("Custom file last");
});
test("trims whitespace from message content", () => {
const sessionId = "test-last-trim";
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
const lines = [
JSON.stringify({ message: { role: "assistant", content: " Padded response " } }),
];
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
expect(result).toBe("Padded response");
}); });
test("skips empty content to find previous message", () => { test("skips empty content to find previous message", () => {
@@ -394,6 +332,67 @@ describe("readLastMessagePreviewFromTranscript", () => {
}); });
}); });
describe("shared transcript read behaviors", () => {
let tmpDir: string;
let storePath: string;
registerTempSessionStore("openclaw-session-fs-test-", (nextTmpDir, nextStorePath) => {
tmpDir = nextTmpDir;
storePath = nextStorePath;
});
test("returns null for missing transcript files", () => {
expect(readFirstUserMessageFromTranscript("missing-session", storePath)).toBeNull();
expect(readLastMessagePreviewFromTranscript("missing-session", storePath)).toBeNull();
});
test("uses sessionFile overrides when provided", () => {
const sessionId = "test-shared-custom";
const firstPath = path.join(tmpDir, "custom-first.jsonl");
const lastPath = path.join(tmpDir, "custom-last.jsonl");
fs.writeFileSync(
firstPath,
[
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "user", content: "Custom file message" } }),
].join("\n"),
"utf-8",
);
fs.writeFileSync(
lastPath,
JSON.stringify({ message: { role: "assistant", content: "Custom file last" } }),
"utf-8",
);
expect(readFirstUserMessageFromTranscript(sessionId, storePath, firstPath)).toBe(
"Custom file message",
);
expect(readLastMessagePreviewFromTranscript(sessionId, storePath, lastPath)).toBe(
"Custom file last",
);
});
test("trims whitespace in extracted previews", () => {
const firstSessionId = "test-shared-first-trim";
const lastSessionId = "test-shared-last-trim";
fs.writeFileSync(
path.join(tmpDir, `${firstSessionId}.jsonl`),
JSON.stringify({ message: { role: "user", content: " Padded message " } }),
"utf-8",
);
fs.writeFileSync(
path.join(tmpDir, `${lastSessionId}.jsonl`),
JSON.stringify({ message: { role: "assistant", content: " Padded response " } }),
"utf-8",
);
expect(readFirstUserMessageFromTranscript(firstSessionId, storePath)).toBe("Padded message");
expect(readLastMessagePreviewFromTranscript(lastSessionId, storePath)).toBe("Padded response");
});
});
describe("readSessionTitleFieldsFromTranscript cache", () => { describe("readSessionTitleFieldsFromTranscript cache", () => {
let tmpDir: string; let tmpDir: string;
let storePath: string; let storePath: string;
@@ -496,56 +495,53 @@ describe("readSessionMessages", () => {
expect(typeof marker.timestamp).toBe("number"); expect(typeof marker.timestamp).toBe("number");
}); });
test("reads cross-agent absolute sessionFile when storePath points to another agent dir", () => { test("reads cross-agent absolute sessionFile across store-root layouts", () => {
const sessionId = "cross-agent-default-root"; const cases = [
const sessionFile = path.join(tmpDir, "agents", "ops", "sessions", `${sessionId}.jsonl`); {
fs.mkdirSync(path.dirname(sessionFile), { recursive: true }); sessionId: "cross-agent-default-root",
fs.writeFileSync( sessionFile: path.join(
sessionFile, tmpDir,
[ "agents",
JSON.stringify({ type: "session", version: 1, id: sessionId }), "ops",
JSON.stringify({ message: { role: "user", content: "from-ops" } }), "sessions",
].join("\n"), "cross-agent-default-root.jsonl",
"utf-8", ),
); wrongStorePath: path.join(tmpDir, "agents", "main", "sessions", "sessions.json"),
message: { role: "user", content: "from-ops" },
},
{
sessionId: "cross-agent-custom-root",
sessionFile: path.join(
tmpDir,
"custom",
"agents",
"ops",
"sessions",
"cross-agent-custom-root.jsonl",
),
wrongStorePath: path.join(tmpDir, "custom", "agents", "main", "sessions", "sessions.json"),
message: { role: "assistant", content: "from-custom-ops" },
},
] as const;
const wrongStorePath = path.join(tmpDir, "agents", "main", "sessions", "sessions.json"); for (const testCase of cases) {
const out = readSessionMessages(sessionId, wrongStorePath, sessionFile); fs.mkdirSync(path.dirname(testCase.sessionFile), { recursive: true });
fs.writeFileSync(
testCase.sessionFile,
[
JSON.stringify({ type: "session", version: 1, id: testCase.sessionId }),
JSON.stringify({ message: testCase.message }),
].join("\n"),
"utf-8",
);
expect(out).toEqual([{ role: "user", content: "from-ops" }]); const out = readSessionMessages(
}); testCase.sessionId,
testCase.wrongStorePath,
test("reads cross-agent absolute sessionFile for custom per-agent store roots", () => { testCase.sessionFile,
const sessionId = "cross-agent-custom-root"; );
const sessionFile = path.join( expect(out).toEqual([testCase.message]);
tmpDir, }
"custom",
"agents",
"ops",
"sessions",
`${sessionId}.jsonl`,
);
fs.mkdirSync(path.dirname(sessionFile), { recursive: true });
fs.writeFileSync(
sessionFile,
[
JSON.stringify({ type: "session", version: 1, id: sessionId }),
JSON.stringify({ message: { role: "assistant", content: "from-custom-ops" } }),
].join("\n"),
"utf-8",
);
const wrongStorePath = path.join(
tmpDir,
"custom",
"agents",
"main",
"sessions",
"sessions.json",
);
const out = readSessionMessages(sessionId, wrongStorePath, sessionFile);
expect(out).toEqual([{ role: "assistant", content: "from-custom-ops" }]);
}); });
}); });
@@ -660,20 +656,28 @@ describe("resolveSessionTranscriptCandidates", () => {
}); });
describe("resolveSessionTranscriptCandidates safety", () => { describe("resolveSessionTranscriptCandidates safety", () => {
test("keeps cross-agent absolute sessionFile when storePath agent context differs", () => { test("keeps cross-agent absolute sessionFile for standard and custom store roots", () => {
const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json"; const cases = [
const sessionFile = "/tmp/openclaw/agents/ops/sessions/sess-safe.jsonl"; {
const candidates = resolveSessionTranscriptCandidates("sess-safe", storePath, sessionFile); storePath: "/tmp/openclaw/agents/main/sessions/sessions.json",
sessionFile: "/tmp/openclaw/agents/ops/sessions/sess-safe.jsonl",
},
{
storePath: "/srv/custom/agents/main/sessions/sessions.json",
sessionFile: "/srv/custom/agents/ops/sessions/sess-safe.jsonl",
},
] as const;
expect(candidates.map((value) => path.resolve(value))).toContain(path.resolve(sessionFile)); for (const testCase of cases) {
}); const candidates = resolveSessionTranscriptCandidates(
"sess-safe",
test("keeps cross-agent absolute sessionFile for custom per-agent store roots", () => { testCase.storePath,
const storePath = "/srv/custom/agents/main/sessions/sessions.json"; testCase.sessionFile,
const sessionFile = "/srv/custom/agents/ops/sessions/sess-safe.jsonl"; );
const candidates = resolveSessionTranscriptCandidates("sess-safe", storePath, sessionFile); expect(candidates.map((value) => path.resolve(value))).toContain(
path.resolve(testCase.sessionFile),
expect(candidates.map((value) => path.resolve(value))).toContain(path.resolve(sessionFile)); );
}
}); });
test("drops unsafe session IDs instead of producing traversal paths", () => { test("drops unsafe session IDs instead of producing traversal paths", () => {
@@ -717,38 +721,33 @@ describe("archiveSessionTranscripts", () => {
vi.unstubAllEnvs(); vi.unstubAllEnvs();
}); });
test("archives existing transcript file and returns archived path", () => { test("archives transcript from default and explicit sessionFile paths", () => {
const sessionId = "sess-archive-1"; const cases = [
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`); {
fs.writeFileSync(transcriptPath, '{"type":"session"}\n', "utf-8"); sessionId: "sess-archive-1",
transcriptPath: path.join(tmpDir, "sess-archive-1.jsonl"),
args: { sessionId: "sess-archive-1", storePath, reason: "reset" as const },
},
{
sessionId: "sess-archive-2",
transcriptPath: path.join(tmpDir, "custom-transcript.jsonl"),
args: {
sessionId: "sess-archive-2",
storePath: undefined,
sessionFile: path.join(tmpDir, "custom-transcript.jsonl"),
reason: "reset" as const,
},
},
] as const;
const archived = archiveSessionTranscripts({ for (const testCase of cases) {
sessionId, fs.writeFileSync(testCase.transcriptPath, '{"type":"session"}\n', "utf-8");
storePath, const archived = archiveSessionTranscripts(testCase.args);
reason: "reset", expect(archived).toHaveLength(1);
}); expect(archived[0]).toContain(".reset.");
expect(fs.existsSync(testCase.transcriptPath)).toBe(false);
expect(archived).toHaveLength(1); expect(fs.existsSync(archived[0])).toBe(true);
expect(archived[0]).toContain(".reset."); }
expect(fs.existsSync(transcriptPath)).toBe(false);
expect(fs.existsSync(archived[0])).toBe(true);
});
test("archives transcript found via explicit sessionFile path", () => {
const sessionId = "sess-archive-2";
const customPath = path.join(tmpDir, "custom-transcript.jsonl");
fs.writeFileSync(customPath, '{"type":"session"}\n', "utf-8");
const archived = archiveSessionTranscripts({
sessionId,
storePath: undefined,
sessionFile: customPath,
reason: "reset",
});
expect(archived).toHaveLength(1);
expect(fs.existsSync(customPath)).toBe(false);
expect(fs.existsSync(archived[0])).toBe(true);
}); });
test("returns empty array when no transcript files exist", () => { test("returns empty array when no transcript files exist", () => {

View File

@@ -382,120 +382,45 @@ describe("listSessionsFromStore search", () => {
} as SessionEntry, } as SessionEntry,
}); });
test("returns all sessions when search is empty", () => { test("returns all sessions when search is empty or missing", () => {
const store = makeStore(); const cases = [{ opts: { search: "" } }, { opts: {} }] as const;
const result = listSessionsFromStore({ for (const testCase of cases) {
cfg: baseCfg, const result = listSessionsFromStore({
storePath: "/tmp/sessions.json", cfg: baseCfg,
store, storePath: "/tmp/sessions.json",
opts: { search: "" }, store: makeStore(),
}); opts: testCase.opts,
expect(result.sessions.length).toBe(3); });
expect(result.sessions).toHaveLength(3);
}
}); });
test("returns all sessions when search is undefined", () => { test("filters sessions across display metadata and key fields", () => {
const store = makeStore(); const cases = [
const result = listSessionsFromStore({ { search: "WORK PROJECT", expectedKey: "agent:main:work-project" },
cfg: baseCfg, { search: "reunion", expectedKey: "agent:main:personal-chat" },
storePath: "/tmp/sessions.json", { search: "discord", expectedKey: "agent:main:discord:group:dev-team" },
store, { search: "sess-personal", expectedKey: "agent:main:personal-chat" },
opts: {}, { search: "dev-team", expectedKey: "agent:main:discord:group:dev-team" },
}); { search: "alpha", expectedKey: "agent:main:work-project" },
expect(result.sessions.length).toBe(3); { search: " personal ", expectedKey: "agent:main:personal-chat" },
}); { search: "nonexistent-term", expectedKey: undefined },
] as const;
test("filters by displayName case-insensitively", () => { for (const testCase of cases) {
const store = makeStore(); const result = listSessionsFromStore({
const result = listSessionsFromStore({ cfg: baseCfg,
cfg: baseCfg, storePath: "/tmp/sessions.json",
storePath: "/tmp/sessions.json", store: makeStore(),
store, opts: { search: testCase.search },
opts: { search: "WORK PROJECT" }, });
}); if (!testCase.expectedKey) {
expect(result.sessions.length).toBe(1); expect(result.sessions).toHaveLength(0);
expect(result.sessions[0].displayName).toBe("Work Project Alpha"); continue;
}); }
expect(result.sessions).toHaveLength(1);
test("filters by subject", () => { expect(result.sessions[0].key).toBe(testCase.expectedKey);
const store = makeStore(); }
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "reunion" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].subject).toBe("Family Reunion Planning");
});
test("filters by label", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "discord" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].label).toBe("discord");
});
test("filters by sessionId", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "sess-personal" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].sessionId).toBe("sess-personal-1");
});
test("filters by key", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "dev-team" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].key).toBe("agent:main:discord:group:dev-team");
});
test("returns empty array when no matches", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "nonexistent-term" },
});
expect(result.sessions.length).toBe(0);
});
test("matches partial strings", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: "alpha" },
});
expect(result.sessions.length).toBe(1);
expect(result.sessions[0].displayName).toBe("Work Project Alpha");
});
test("trims whitespace from search query", () => {
const store = makeStore();
const result = listSessionsFromStore({
cfg: baseCfg,
storePath: "/tmp/sessions.json",
store,
opts: { search: " personal " },
});
expect(result.sessions.length).toBe(1);
}); });
test("hides cron run alias session keys from sessions list", () => { test("hides cron run alias session keys from sessions list", () => {

View File

@@ -215,11 +215,6 @@ describe("hooks", () => {
expect(isMessageReceivedEvent(event)).toBe(true); expect(isMessageReceivedEvent(event)).toBe(true);
}); });
it("returns false for non-message events", () => {
const event = createInternalHookEvent("command", "new", "test-session");
expect(isMessageReceivedEvent(event)).toBe(false);
});
it("returns false for message:sent events", () => { it("returns false for message:sent events", () => {
const context: MessageSentHookContext = { const context: MessageSentHookContext = {
to: "+1234567890", to: "+1234567890",
@@ -230,14 +225,6 @@ describe("hooks", () => {
const event = createInternalHookEvent("message", "sent", "test-session", context); const event = createInternalHookEvent("message", "sent", "test-session", context);
expect(isMessageReceivedEvent(event)).toBe(false); expect(isMessageReceivedEvent(event)).toBe(false);
}); });
it("returns false when context is missing required fields", () => {
const event = createInternalHookEvent("message", "received", "test-session", {
from: "+1234567890",
// missing channelId
});
expect(isMessageReceivedEvent(event)).toBe(false);
});
}); });
describe("isMessageSentEvent", () => { describe("isMessageSentEvent", () => {
@@ -266,11 +253,6 @@ describe("hooks", () => {
expect(isMessageSentEvent(event)).toBe(true); expect(isMessageSentEvent(event)).toBe(true);
}); });
it("returns false for non-message events", () => {
const event = createInternalHookEvent("command", "new", "test-session");
expect(isMessageSentEvent(event)).toBe(false);
});
it("returns false for message:received events", () => { it("returns false for message:received events", () => {
const context: MessageReceivedHookContext = { const context: MessageReceivedHookContext = {
from: "+1234567890", from: "+1234567890",
@@ -280,14 +262,41 @@ describe("hooks", () => {
const event = createInternalHookEvent("message", "received", "test-session", context); const event = createInternalHookEvent("message", "received", "test-session", context);
expect(isMessageSentEvent(event)).toBe(false); expect(isMessageSentEvent(event)).toBe(false);
}); });
});
it("returns false when context is missing required fields", () => { describe("message type-guard shared negatives", () => {
const event = createInternalHookEvent("message", "sent", "test-session", { it("returns false for non-message and missing-context shapes", () => {
const cases: Array<{
match: (event: ReturnType<typeof createInternalHookEvent>) => boolean;
}> = [
{
match: isMessageReceivedEvent,
},
{
match: isMessageSentEvent,
},
];
const nonMessageEvent = createInternalHookEvent("command", "new", "test-session");
const missingReceivedContext = createInternalHookEvent(
"message",
"received",
"test-session",
{
from: "+1234567890",
// missing channelId
},
);
const missingSentContext = createInternalHookEvent("message", "sent", "test-session", {
to: "+1234567890", to: "+1234567890",
channelId: "whatsapp", channelId: "whatsapp",
// missing success // missing success
}); });
expect(isMessageSentEvent(event)).toBe(false);
for (const testCase of cases) {
expect(testCase.match(nonMessageEvent)).toBe(false);
}
expect(isMessageReceivedEvent(missingReceivedContext)).toBe(false);
expect(isMessageSentEvent(missingSentContext)).toBe(false);
}); });
}); });

View File

@@ -17,37 +17,26 @@ describe("format-duration", () => {
expect(formatDurationCompact(-100)).toBeUndefined(); expect(formatDurationCompact(-100)).toBeUndefined();
}); });
it("formats milliseconds for sub-second durations", () => { it("formats compact units and omits trailing zero components", () => {
expect(formatDurationCompact(500)).toBe("500ms"); const cases = [
expect(formatDurationCompact(999)).toBe("999ms"); [500, "500ms"],
}); [999, "999ms"],
[1000, "1s"],
it("formats seconds", () => { [45000, "45s"],
expect(formatDurationCompact(1000)).toBe("1s"); [59000, "59s"],
expect(formatDurationCompact(45000)).toBe("45s"); [60000, "1m"], // not "1m0s"
expect(formatDurationCompact(59000)).toBe("59s"); [65000, "1m5s"],
}); [90000, "1m30s"],
[3600000, "1h"], // not "1h0m"
it("formats minutes and seconds", () => { [3660000, "1h1m"],
expect(formatDurationCompact(60000)).toBe("1m"); [5400000, "1h30m"],
expect(formatDurationCompact(65000)).toBe("1m5s"); [86400000, "1d"], // not "1d0h"
expect(formatDurationCompact(90000)).toBe("1m30s"); [90000000, "1d1h"],
}); [172800000, "2d"],
] as const;
it("omits trailing zero components", () => { for (const [input, expected] of cases) {
expect(formatDurationCompact(60000)).toBe("1m"); // not "1m0s" expect(formatDurationCompact(input), String(input)).toBe(expected);
expect(formatDurationCompact(3600000)).toBe("1h"); // not "1h0m" }
expect(formatDurationCompact(86400000)).toBe("1d"); // not "1d0h"
});
it("formats hours and minutes", () => {
expect(formatDurationCompact(3660000)).toBe("1h1m");
expect(formatDurationCompact(5400000)).toBe("1h30m");
});
it("formats days and hours", () => {
expect(formatDurationCompact(90000000)).toBe("1d1h");
expect(formatDurationCompact(172800000)).toBe("2d");
}); });
it("supports spaced option", () => { it("supports spaced option", () => {
@@ -65,25 +54,27 @@ describe("format-duration", () => {
}); });
describe("formatDurationHuman", () => { describe("formatDurationHuman", () => {
it("returns fallback for invalid input", () => { it("returns fallback for invalid duration input", () => {
for (const value of [null, undefined, -100]) { for (const value of [null, undefined, -100]) {
expect(formatDurationHuman(value)).toBe("n/a"); expect(formatDurationHuman(value)).toBe("n/a");
} }
expect(formatDurationHuman(null, "unknown")).toBe("unknown"); expect(formatDurationHuman(null, "unknown")).toBe("unknown");
}); });
it("formats single unit", () => { it("formats single-unit outputs and day threshold behavior", () => {
expect(formatDurationHuman(500)).toBe("500ms"); const cases = [
expect(formatDurationHuman(5000)).toBe("5s"); [500, "500ms"],
expect(formatDurationHuman(180000)).toBe("3m"); [5000, "5s"],
expect(formatDurationHuman(7200000)).toBe("2h"); [180000, "3m"],
expect(formatDurationHuman(172800000)).toBe("2d"); [7200000, "2h"],
}); [23 * 3600000, "23h"],
[24 * 3600000, "1d"],
it("uses 24h threshold for days", () => { [25 * 3600000, "1d"], // rounds
expect(formatDurationHuman(23 * 3600000)).toBe("23h"); [172800000, "2d"],
expect(formatDurationHuman(24 * 3600000)).toBe("1d"); ] as const;
expect(formatDurationHuman(25 * 3600000)).toBe("1d"); // rounds for (const [input, expected] of cases) {
expect(formatDurationHuman(input), String(input)).toBe(expected);
}
}); });
}); });
@@ -166,20 +157,27 @@ describe("format-datetime", () => {
describe("format-relative", () => { describe("format-relative", () => {
describe("formatTimeAgo", () => { describe("formatTimeAgo", () => {
it("returns fallback for invalid input", () => { it("returns fallback for invalid elapsed input", () => {
for (const value of [null, undefined, -100]) { for (const value of [null, undefined, -100]) {
expect(formatTimeAgo(value)).toBe("unknown"); expect(formatTimeAgo(value)).toBe("unknown");
} }
expect(formatTimeAgo(null, { fallback: "n/a" })).toBe("n/a"); expect(formatTimeAgo(null, { fallback: "n/a" })).toBe("n/a");
}); });
it("formats with 'ago' suffix by default", () => { it("formats relative age around key unit boundaries", () => {
expect(formatTimeAgo(0)).toBe("just now"); const cases = [
expect(formatTimeAgo(29000)).toBe("just now"); // rounds to <1m [0, "just now"],
expect(formatTimeAgo(30000)).toBe("1m ago"); // 30s rounds to 1m [29000, "just now"], // rounds to <1m
expect(formatTimeAgo(300000)).toBe("5m ago"); [30000, "1m ago"], // 30s rounds to 1m
expect(formatTimeAgo(7200000)).toBe("2h ago"); [300000, "5m ago"],
expect(formatTimeAgo(172800000)).toBe("2d ago"); [7200000, "2h ago"],
[47 * 3600000, "47h ago"],
[48 * 3600000, "2d ago"],
[172800000, "2d ago"],
] as const;
for (const [input, expected] of cases) {
expect(formatTimeAgo(input), String(input)).toBe(expected);
}
}); });
it("omits suffix when suffix: false", () => { it("omits suffix when suffix: false", () => {
@@ -187,15 +185,10 @@ describe("format-relative", () => {
expect(formatTimeAgo(300000, { suffix: false })).toBe("5m"); expect(formatTimeAgo(300000, { suffix: false })).toBe("5m");
expect(formatTimeAgo(7200000, { suffix: false })).toBe("2h"); expect(formatTimeAgo(7200000, { suffix: false })).toBe("2h");
}); });
it("uses 48h threshold before switching to days", () => {
expect(formatTimeAgo(47 * 3600000)).toBe("47h ago");
expect(formatTimeAgo(48 * 3600000)).toBe("2d ago");
});
}); });
describe("formatRelativeTimestamp", () => { describe("formatRelativeTimestamp", () => {
it("returns fallback for invalid input", () => { it("returns fallback for invalid timestamp input", () => {
for (const value of [null, undefined]) { for (const value of [null, undefined]) {
expect(formatRelativeTimestamp(value)).toBe("n/a"); expect(formatRelativeTimestamp(value)).toBe("n/a");
} }

View File

@@ -168,15 +168,19 @@ describe("resolveHeartbeatIntervalMs", () => {
}); });
describe("resolveHeartbeatPrompt", () => { describe("resolveHeartbeatPrompt", () => {
it("uses the default prompt when unset", () => { it("uses default or trimmed override prompts", () => {
expect(resolveHeartbeatPrompt({})).toBe(HEARTBEAT_PROMPT); const cases = [
}); { cfg: {} as OpenClawConfig, expected: HEARTBEAT_PROMPT },
{
it("uses a trimmed override when configured", () => { cfg: {
const cfg: OpenClawConfig = { agents: { defaults: { heartbeat: { prompt: " ping " } } },
agents: { defaults: { heartbeat: { prompt: " ping " } } }, } as OpenClawConfig,
}; expected: "ping",
expect(resolveHeartbeatPrompt(cfg)).toBe("ping"); },
] as const;
for (const testCase of cases) {
expect(resolveHeartbeatPrompt(testCase.cfg)).toBe(testCase.expected);
}
}); });
}); });
@@ -323,67 +327,61 @@ describe("resolveHeartbeatDeliveryTarget", () => {
}); });
}); });
it("parses threadId from :topic: suffix in heartbeat to", () => { it("parses optional telegram :topic: threadId suffix", () => {
const cfg: OpenClawConfig = { const cases = [
agents: { { to: "-100111:topic:42", expectedTo: "-100111", expectedThreadId: 42 },
defaults: { { to: "-100111", expectedTo: "-100111", expectedThreadId: undefined },
heartbeat: { target: "telegram", to: "-100111:topic:42" }, ] as const;
for (const testCase of cases) {
const cfg: OpenClawConfig = {
agents: {
defaults: {
heartbeat: { target: "telegram", to: testCase.to },
},
}, },
}, };
}; const result = resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry });
const result = resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry }); expect(result.channel).toBe("telegram");
expect(result.channel).toBe("telegram"); expect(result.to).toBe(testCase.expectedTo);
expect(result.to).toBe("-100111"); expect(result.threadId).toBe(testCase.expectedThreadId);
expect(result.threadId).toBe(42); }
}); });
it("heartbeat to without :topic: has no threadId", () => { it("handles explicit heartbeat accountId allow/deny", () => {
const cfg: OpenClawConfig = { const cases = [
agents: { {
defaults: { accountId: "work",
heartbeat: { target: "telegram", to: "-100111" }, expected: {
channel: "telegram",
to: "123",
accountId: "work",
lastChannel: undefined,
lastAccountId: undefined,
}, },
}, },
}; {
const result = resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry }); accountId: "missing",
expect(result.to).toBe("-100111"); expected: {
expect(result.threadId).toBeUndefined(); channel: "none",
}); reason: "unknown-account",
accountId: "missing",
lastChannel: undefined,
lastAccountId: undefined,
},
},
] as const;
it("uses explicit heartbeat accountId when provided", () => { for (const testCase of cases) {
const cfg: OpenClawConfig = { const cfg: OpenClawConfig = {
agents: { agents: {
defaults: { defaults: {
heartbeat: { target: "telegram", to: "123", accountId: "work" }, heartbeat: { target: "telegram", to: "123", accountId: testCase.accountId },
},
}, },
}, channels: { telegram: { accounts: { work: { botToken: "token" } } } },
channels: { telegram: { accounts: { work: { botToken: "token" } } } }, };
}; expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual(testCase.expected);
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({ }
channel: "telegram",
to: "123",
accountId: "work",
lastChannel: undefined,
lastAccountId: undefined,
});
});
it("skips when explicit heartbeat accountId is unknown", () => {
const cfg: OpenClawConfig = {
agents: {
defaults: {
heartbeat: { target: "telegram", to: "123", accountId: "missing" },
},
},
channels: { telegram: { accounts: { work: { botToken: "token" } } } },
};
expect(resolveHeartbeatDeliveryTarget({ cfg, entry: baseEntry })).toEqual({
channel: "none",
reason: "unknown-account",
accountId: "missing",
lastChannel: undefined,
lastAccountId: undefined,
});
}); });
it("prefers per-agent heartbeat overrides when provided", () => { it("prefers per-agent heartbeat overrides when provided", () => {

View File

@@ -15,37 +15,22 @@ function okResponse(body = "ok"): Response {
describe("fetchWithSsrFGuard hardening", () => { describe("fetchWithSsrFGuard hardening", () => {
type LookupFn = NonNullable<Parameters<typeof fetchWithSsrFGuard>[0]["lookupFn"]>; type LookupFn = NonNullable<Parameters<typeof fetchWithSsrFGuard>[0]["lookupFn"]>;
it("blocks private IP literal URLs before fetch", async () => { it("blocks private and legacy loopback literals before fetch", async () => {
const fetchImpl = vi.fn(); const blockedUrls = [
await expect( "http://127.0.0.1:8080/internal",
fetchWithSsrFGuard({ "http://0177.0.0.1:8080/internal",
url: "http://127.0.0.1:8080/internal", "http://0x7f000001/internal",
fetchImpl, ];
}), for (const url of blockedUrls) {
).rejects.toThrow(/private|internal|blocked/i); const fetchImpl = vi.fn();
expect(fetchImpl).not.toHaveBeenCalled(); await expect(
}); fetchWithSsrFGuard({
url,
it("blocks legacy loopback literal URLs before fetch", async () => { fetchImpl,
const fetchImpl = vi.fn(); }),
await expect( ).rejects.toThrow(/private|internal|blocked/i);
fetchWithSsrFGuard({ expect(fetchImpl).not.toHaveBeenCalled();
url: "http://0177.0.0.1:8080/internal", }
fetchImpl,
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).not.toHaveBeenCalled();
});
it("blocks unsupported packed-hex loopback literal URLs before fetch", async () => {
const fetchImpl = vi.fn();
await expect(
fetchWithSsrFGuard({
url: "http://0x7f000001/internal",
fetchImpl,
}),
).rejects.toThrow(/private|internal|blocked/i);
expect(fetchImpl).not.toHaveBeenCalled();
}); });
it("blocks redirect chains that hop to private hosts", async () => { it("blocks redirect chains that hop to private hosts", async () => {

View File

@@ -59,27 +59,23 @@ const unsupportedLegacyIpv4Cases = [
const nonIpHostnameCases = ["example.com", "abc.123.example", "1password.com", "0x.example.com"]; const nonIpHostnameCases = ["example.com", "abc.123.example", "1password.com", "0x.example.com"];
describe("ssrf ip classification", () => { describe("ssrf ip classification", () => {
it.each(privateIpCases)("classifies %s as private", (address) => { it("classifies blocked ip literals as private", () => {
expect(isPrivateIpAddress(address)).toBe(true); const blockedCases = [...privateIpCases, ...malformedIpv6Cases, ...unsupportedLegacyIpv4Cases];
}); for (const address of blockedCases) {
it.each(publicIpCases)("classifies %s as public", (address) => {
expect(isPrivateIpAddress(address)).toBe(false);
});
it.each(malformedIpv6Cases)("fails closed for malformed IPv6 %s", (address) => {
expect(isPrivateIpAddress(address)).toBe(true);
});
it.each(unsupportedLegacyIpv4Cases)(
"fails closed for unsupported legacy IPv4 literal %s",
(address) => {
expect(isPrivateIpAddress(address)).toBe(true); expect(isPrivateIpAddress(address)).toBe(true);
}, }
); });
it.each(nonIpHostnameCases)("does not treat hostname %s as an IP literal", (hostname) => { it("classifies public ip literals as non-private", () => {
expect(isPrivateIpAddress(hostname)).toBe(false); for (const address of publicIpCases) {
expect(isPrivateIpAddress(address)).toBe(false);
}
});
it("does not treat hostnames as ip literals", () => {
for (const hostname of nonIpHostnameCases) {
expect(isPrivateIpAddress(hostname)).toBe(false);
}
}); });
}); });

View File

@@ -124,8 +124,6 @@ describe("resolveOpenClawPackageRoot", () => {
}); });
it("falls back when argv1 realpath throws", async () => { it("falls back when argv1 realpath throws", async () => {
const { resolveOpenClawPackageRootSync } = await import("./openclaw-root.js");
const project = fx("realpath-throw-scenario"); const project = fx("realpath-throw-scenario");
const argv1 = path.join(project, "node_modules", ".bin", "openclaw"); const argv1 = path.join(project, "node_modules", ".bin", "openclaw");
const pkgRoot = path.join(project, "node_modules", "openclaw"); const pkgRoot = path.join(project, "node_modules", "openclaw");
@@ -158,8 +156,6 @@ describe("resolveOpenClawPackageRoot", () => {
}); });
it("async resolver returns null when no package roots exist", async () => { it("async resolver returns null when no package roots exist", async () => {
const { resolveOpenClawPackageRoot } = await import("./openclaw-root.js");
await expect(resolveOpenClawPackageRoot({ cwd: fx("missing") })).resolves.toBeNull(); await expect(resolveOpenClawPackageRoot({ cwd: fx("missing") })).resolves.toBeNull();
}); });
}); });

View File

@@ -161,17 +161,22 @@ describe("delivery-queue", () => {
}); });
describe("computeBackoffMs", () => { describe("computeBackoffMs", () => {
it("returns 0 for retryCount 0", () => { it("returns scheduled backoff values and clamps at max retry", () => {
expect(computeBackoffMs(0)).toBe(0); const cases = [
}); { retryCount: 0, expected: 0 },
{ retryCount: 1, expected: 5_000 },
{ retryCount: 2, expected: 25_000 },
{ retryCount: 3, expected: 120_000 },
{ retryCount: 4, expected: 600_000 },
// Beyond defined schedule -- clamps to last value.
{ retryCount: 5, expected: 600_000 },
] as const;
it("returns correct backoff for each retry", () => { for (const testCase of cases) {
expect(computeBackoffMs(1)).toBe(5_000); expect(computeBackoffMs(testCase.retryCount), String(testCase.retryCount)).toBe(
expect(computeBackoffMs(2)).toBe(25_000); testCase.expected,
expect(computeBackoffMs(3)).toBe(120_000); );
expect(computeBackoffMs(4)).toBe(600_000); }
// Beyond defined schedule -- clamps to last value.
expect(computeBackoffMs(5)).toBe(600_000);
}); });
}); });
@@ -383,28 +388,36 @@ describe("DirectoryCache", () => {
expect(cache.get("a", cfg)).toBeUndefined(); expect(cache.get("a", cfg)).toBeUndefined();
}); });
it("evicts oldest keys when max size is exceeded", () => { it("evicts least-recent entries when capacity is exceeded", () => {
const cache = new DirectoryCache<string>(60_000, 2); const cases = [
cache.set("a", "value-a", cfg); {
cache.set("b", "value-b", cfg); actions: [
cache.set("c", "value-c", cfg); ["set", "a", "value-a"],
["set", "b", "value-b"],
["set", "c", "value-c"],
] as const,
expected: { a: undefined, b: "value-b", c: "value-c" },
},
{
actions: [
["set", "a", "value-a"],
["set", "b", "value-b"],
["set", "a", "value-a2"],
["set", "c", "value-c"],
] as const,
expected: { a: "value-a2", b: undefined, c: "value-c" },
},
] as const;
expect(cache.get("a", cfg)).toBeUndefined(); for (const testCase of cases) {
expect(cache.get("b", cfg)).toBe("value-b"); const cache = new DirectoryCache<string>(60_000, 2);
expect(cache.get("c", cfg)).toBe("value-c"); for (const action of testCase.actions) {
}); cache.set(action[1], action[2], cfg);
}
it("refreshes insertion order on key updates", () => { expect(cache.get("a", cfg)).toBe(testCase.expected.a);
const cache = new DirectoryCache<string>(60_000, 2); expect(cache.get("b", cfg)).toBe(testCase.expected.b);
cache.set("a", "value-a", cfg); expect(cache.get("c", cfg)).toBe(testCase.expected.c);
cache.set("b", "value-b", cfg); }
cache.set("a", "value-a2", cfg);
cache.set("c", "value-c", cfg);
// Updating "a" should keep it and evict older "b".
expect(cache.get("a", cfg)).toBe("value-a2");
expect(cache.get("b", cfg)).toBeUndefined();
expect(cache.get("c", cfg)).toBe("value-c");
}); });
}); });
@@ -470,103 +483,128 @@ describe("buildOutboundResultEnvelope", () => {
}); });
describe("formatOutboundDeliverySummary", () => { describe("formatOutboundDeliverySummary", () => {
it("falls back when result is missing", () => { it("formats fallback and channel-specific detail variants", () => {
expect(formatOutboundDeliverySummary("telegram")).toBe( const cases = [
"✅ Sent via Telegram. Message ID: unknown", {
); name: "fallback telegram",
expect(formatOutboundDeliverySummary("imessage")).toBe( channel: "telegram" as const,
"✅ Sent via iMessage. Message ID: unknown", result: undefined,
); expected: "✅ Sent via Telegram. Message ID: unknown",
}); },
{
name: "fallback imessage",
channel: "imessage" as const,
result: undefined,
expected: "✅ Sent via iMessage. Message ID: unknown",
},
{
name: "telegram with chat detail",
channel: "telegram" as const,
result: {
channel: "telegram" as const,
messageId: "m1",
chatId: "c1",
},
expected: "✅ Sent via Telegram. Message ID: m1 (chat c1)",
},
{
name: "discord with channel detail",
channel: "discord" as const,
result: {
channel: "discord" as const,
messageId: "d1",
channelId: "chan",
},
expected: "✅ Sent via Discord. Message ID: d1 (channel chan)",
},
] as const;
it("adds chat or channel details", () => { for (const testCase of cases) {
expect( expect(formatOutboundDeliverySummary(testCase.channel, testCase.result), testCase.name).toBe(
formatOutboundDeliverySummary("telegram", { testCase.expected,
channel: "telegram", );
messageId: "m1", }
chatId: "c1",
}),
).toBe("✅ Sent via Telegram. Message ID: m1 (chat c1)");
expect(
formatOutboundDeliverySummary("discord", {
channel: "discord",
messageId: "d1",
channelId: "chan",
}),
).toBe("✅ Sent via Discord. Message ID: d1 (channel chan)");
}); });
}); });
describe("buildOutboundDeliveryJson", () => { describe("buildOutboundDeliveryJson", () => {
it("builds direct delivery payloads", () => { it("builds direct delivery payloads across provider-specific fields", () => {
expect( const cases = [
buildOutboundDeliveryJson({ {
channel: "telegram", name: "telegram direct payload",
to: "123", input: {
result: { channel: "telegram", messageId: "m1", chatId: "c1" }, channel: "telegram" as const,
mediaUrl: "https://example.com/a.png", to: "123",
}), result: { channel: "telegram" as const, messageId: "m1", chatId: "c1" },
).toEqual({ mediaUrl: "https://example.com/a.png",
channel: "telegram", },
via: "direct", expected: {
to: "123", channel: "telegram",
messageId: "m1", via: "direct",
mediaUrl: "https://example.com/a.png", to: "123",
chatId: "c1", messageId: "m1",
}); mediaUrl: "https://example.com/a.png",
}); chatId: "c1",
},
},
{
name: "whatsapp metadata",
input: {
channel: "whatsapp" as const,
to: "+1",
result: { channel: "whatsapp" as const, messageId: "w1", toJid: "jid" },
},
expected: {
channel: "whatsapp",
via: "direct",
to: "+1",
messageId: "w1",
mediaUrl: null,
toJid: "jid",
},
},
{
name: "signal timestamp",
input: {
channel: "signal" as const,
to: "+1",
result: { channel: "signal" as const, messageId: "s1", timestamp: 123 },
},
expected: {
channel: "signal",
via: "direct",
to: "+1",
messageId: "s1",
mediaUrl: null,
timestamp: 123,
},
},
] as const;
it("supports whatsapp metadata when present", () => { for (const testCase of cases) {
expect( expect(buildOutboundDeliveryJson(testCase.input), testCase.name).toEqual(testCase.expected);
buildOutboundDeliveryJson({ }
channel: "whatsapp",
to: "+1",
result: { channel: "whatsapp", messageId: "w1", toJid: "jid" },
}),
).toEqual({
channel: "whatsapp",
via: "direct",
to: "+1",
messageId: "w1",
mediaUrl: null,
toJid: "jid",
});
});
it("keeps timestamp for signal", () => {
expect(
buildOutboundDeliveryJson({
channel: "signal",
to: "+1",
result: { channel: "signal", messageId: "s1", timestamp: 123 },
}),
).toEqual({
channel: "signal",
via: "direct",
to: "+1",
messageId: "s1",
mediaUrl: null,
timestamp: 123,
});
}); });
}); });
describe("formatGatewaySummary", () => { describe("formatGatewaySummary", () => {
it("formats gateway summaries with channel", () => { it("formats default and custom gateway action summaries", () => {
expect(formatGatewaySummary({ channel: "whatsapp", messageId: "m1" })).toBe( const cases = [
"✅ Sent via gateway (whatsapp). Message ID: m1", {
); name: "default send action",
}); input: { channel: "whatsapp", messageId: "m1" },
expected: "✅ Sent via gateway (whatsapp). Message ID: m1",
},
{
name: "custom action",
input: { action: "Poll sent", channel: "discord", messageId: "p1" },
expected: "✅ Poll sent via gateway (discord). Message ID: p1",
},
] as const;
it("supports custom actions", () => { for (const testCase of cases) {
expect( expect(formatGatewaySummary(testCase.input), testCase.name).toBe(testCase.expected);
formatGatewaySummary({ }
action: "Poll sent",
channel: "discord",
messageId: "p1",
}),
).toBe("✅ Poll sent via gateway (discord). Message ID: p1");
}); });
}); });
@@ -741,45 +779,50 @@ describe("resolveOutboundSessionRoute", () => {
}); });
describe("normalizeOutboundPayloadsForJson", () => { describe("normalizeOutboundPayloadsForJson", () => {
it("normalizes payloads with mediaUrl and mediaUrls", () => { it("normalizes payloads for JSON output", () => {
expect( const cases = [
normalizeOutboundPayloadsForJson([
{ text: "hi" },
{ text: "photo", mediaUrl: "https://x.test/a.jpg" },
{ text: "multi", mediaUrls: ["https://x.test/1.png"] },
]),
).toEqual([
{ text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined },
{ {
text: "photo", input: [
mediaUrl: "https://x.test/a.jpg", { text: "hi" },
mediaUrls: ["https://x.test/a.jpg"], { text: "photo", mediaUrl: "https://x.test/a.jpg" },
channelData: undefined, { text: "multi", mediaUrls: ["https://x.test/1.png"] },
],
expected: [
{ text: "hi", mediaUrl: null, mediaUrls: undefined, channelData: undefined },
{
text: "photo",
mediaUrl: "https://x.test/a.jpg",
mediaUrls: ["https://x.test/a.jpg"],
channelData: undefined,
},
{
text: "multi",
mediaUrl: null,
mediaUrls: ["https://x.test/1.png"],
channelData: undefined,
},
],
}, },
{ {
text: "multi", input: [
mediaUrl: null, {
mediaUrls: ["https://x.test/1.png"], text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png",
channelData: undefined, },
],
expected: [
{
text: "",
mediaUrl: null,
mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"],
channelData: undefined,
},
],
}, },
]); ] as const;
});
it("keeps mediaUrl null for multi MEDIA tags", () => { for (const testCase of cases) {
expect( expect(normalizeOutboundPayloadsForJson(testCase.input)).toEqual(testCase.expected);
normalizeOutboundPayloadsForJson([ }
{
text: "MEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png",
},
]),
).toEqual([
{
text: "",
mediaUrl: null,
mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"],
channelData: undefined,
},
]);
}); });
}); });
@@ -792,22 +835,29 @@ describe("normalizeOutboundPayloads", () => {
}); });
describe("formatOutboundPayloadLog", () => { describe("formatOutboundPayloadLog", () => {
it("trims trailing text and appends media lines", () => { it("formats text+media and media-only logs", () => {
expect( const cases = [
formatOutboundPayloadLog({ {
text: "hello ", name: "text with media lines",
mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"], input: {
}), text: "hello ",
).toBe("hello\nMEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png"); mediaUrls: ["https://x.test/a.png", "https://x.test/b.png"],
}); },
expected: "hello\nMEDIA:https://x.test/a.png\nMEDIA:https://x.test/b.png",
},
{
name: "media only",
input: {
text: "",
mediaUrls: ["https://x.test/a.png"],
},
expected: "MEDIA:https://x.test/a.png",
},
] as const;
it("logs media-only payloads", () => { for (const testCase of cases) {
expect( expect(formatOutboundPayloadLog(testCase.input), testCase.name).toBe(testCase.expected);
formatOutboundPayloadLog({ }
text: "",
mediaUrls: ["https://x.test/a.png"],
}),
).toBe("MEDIA:https://x.test/a.png");
}); });
}); });
@@ -825,22 +875,6 @@ describe("resolveOutboundTarget", () => {
setActivePluginRegistry(createTestRegistry()); setActivePluginRegistry(createTestRegistry());
}); });
it("rejects whatsapp with empty target even when allowFrom configured", () => {
const cfg: OpenClawConfig = {
channels: { whatsapp: { allowFrom: ["+1555"] } },
};
const res = resolveOutboundTarget({
channel: "whatsapp",
to: "",
cfg,
mode: "explicit",
});
expect(res.ok).toBe(false);
if (!res.ok) {
expect(res.error.message).toContain("WhatsApp");
}
});
it.each([ it.each([
{ {
name: "normalizes whatsapp target when provided", name: "normalizes whatsapp target when provided",
@@ -860,6 +894,16 @@ describe("resolveOutboundTarget", () => {
}, },
expected: { ok: true as const, to: "120363401234567890@g.us" }, expected: { ok: true as const, to: "120363401234567890@g.us" },
}, },
{
name: "rejects whatsapp with empty target in explicit mode even with cfg allowFrom",
input: {
channel: "whatsapp" as const,
to: "",
cfg: { channels: { whatsapp: { allowFrom: ["+1555"] } } } as OpenClawConfig,
mode: "explicit" as const,
},
expectedErrorIncludes: "WhatsApp",
},
{ {
name: "rejects whatsapp with empty target and allowFrom (no silent fallback)", name: "rejects whatsapp with empty target and allowFrom (no silent fallback)",
input: { channel: "whatsapp" as const, to: "", allowFrom: ["+1555"] }, input: { channel: "whatsapp" as const, to: "", allowFrom: ["+1555"] },
@@ -901,19 +945,18 @@ describe("resolveOutboundTarget", () => {
} }
}); });
it("rejects telegram with missing target", () => { it("rejects invalid non-whatsapp targets", () => {
const res = resolveOutboundTarget({ channel: "telegram", to: " " }); const cases = [
expect(res.ok).toBe(false); { input: { channel: "telegram" as const, to: " " }, expectedErrorIncludes: "Telegram" },
if (!res.ok) { { input: { channel: "webchat" as const, to: "x" }, expectedErrorIncludes: "WebChat" },
expect(res.error.message).toContain("Telegram"); ] as const;
}
});
it("rejects webchat delivery", () => { for (const testCase of cases) {
const res = resolveOutboundTarget({ channel: "webchat", to: "x" }); const res = resolveOutboundTarget(testCase.input);
expect(res.ok).toBe(false); expect(res.ok).toBe(false);
if (!res.ok) { if (!res.ok) {
expect(res.error.message).toContain("WebChat"); expect(res.error.message).toContain(testCase.expectedErrorIncludes);
}
} }
}); });
}); });

View File

@@ -164,7 +164,7 @@ describe("fetchAntigravityUsage", () => {
project: { id: "projects/beta" }, project: { id: "projects/beta" },
expectedBody: JSON.stringify({ project: "projects/beta" }), expectedBody: JSON.stringify({ project: "projects/beta" }),
}, },
])("$name", async ({ project, expectedBody }) => { ])("project payload: $name", async ({ project, expectedBody }) => {
let capturedBody: string | undefined; let capturedBody: string | undefined;
const mockFetch = createEndpointFetch({ const mockFetch = createEndpointFetch({
loadCodeAssist: () => loadCodeAssist: () =>
@@ -228,7 +228,7 @@ describe("fetchAntigravityUsage", () => {
}, },
expectedPlan: "Basic Plan", expectedPlan: "Basic Plan",
}, },
])("$name", async ({ loadCodeAssist, expectedPlan }) => { ])("plan label: $name", async ({ loadCodeAssist, expectedPlan }) => {
const mockFetch = createEndpointFetch({ const mockFetch = createEndpointFetch({
loadCodeAssist: () => makeResponse(200, loadCodeAssist), loadCodeAssist: () => makeResponse(200, loadCodeAssist),
fetchAvailableModels: () => makeResponse(500, "Error"), fetchAvailableModels: () => makeResponse(500, "Error"),

View File

@@ -8,40 +8,27 @@ describe("splitMediaFromOutput", () => {
expect(result.text).toBe("Hello world"); expect(result.text).toBe("Hello world");
}); });
it("accepts absolute media paths", () => { it("accepts supported media path variants", () => {
const result = splitMediaFromOutput("MEDIA:/Users/pete/My File.png"); const pathCases = [
expect(result.mediaUrls).toEqual(["/Users/pete/My File.png"]); ["/Users/pete/My File.png", "MEDIA:/Users/pete/My File.png"],
expect(result.text).toBe(""); ["/Users/pete/My File.png", 'MEDIA:"/Users/pete/My File.png"'],
}); ["~/Pictures/My File.png", "MEDIA:~/Pictures/My File.png"],
["../../etc/passwd", "MEDIA:../../etc/passwd"],
it("accepts quoted absolute media paths", () => { ["./screenshots/image.png", "MEDIA:./screenshots/image.png"],
const result = splitMediaFromOutput('MEDIA:"/Users/pete/My File.png"'); ["media/inbound/image.png", "MEDIA:media/inbound/image.png"],
expect(result.mediaUrls).toEqual(["/Users/pete/My File.png"]); ["./screenshot.png", " MEDIA:./screenshot.png"],
expect(result.text).toBe(""); ["C:\\Users\\pete\\Pictures\\snap.png", "MEDIA:C:\\Users\\pete\\Pictures\\snap.png"],
}); [
"/tmp/tts-fAJy8C/voice-1770246885083.opus",
it("accepts tilde media paths", () => { "MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus",
const result = splitMediaFromOutput("MEDIA:~/Pictures/My File.png"); ],
expect(result.mediaUrls).toEqual(["~/Pictures/My File.png"]); ["image.png", "MEDIA:image.png"],
expect(result.text).toBe(""); ] as const;
}); for (const [expectedPath, input] of pathCases) {
const result = splitMediaFromOutput(input);
it("accepts traversal-like media paths (validated at load time)", () => { expect(result.mediaUrls).toEqual([expectedPath]);
const result = splitMediaFromOutput("MEDIA:../../etc/passwd"); expect(result.text).toBe("");
expect(result.mediaUrls).toEqual(["../../etc/passwd"]); }
expect(result.text).toBe("");
});
it("captures safe relative media paths", () => {
const result = splitMediaFromOutput("MEDIA:./screenshots/image.png");
expect(result.mediaUrls).toEqual(["./screenshots/image.png"]);
expect(result.text).toBe("");
});
it("accepts sandbox-relative media paths", () => {
const result = splitMediaFromOutput("MEDIA:media/inbound/image.png");
expect(result.mediaUrls).toEqual(["media/inbound/image.png"]);
expect(result.text).toBe("");
}); });
it("keeps audio_as_voice detection stable across calls", () => { it("keeps audio_as_voice detection stable across calls", () => {
@@ -59,30 +46,6 @@ describe("splitMediaFromOutput", () => {
expect(result.text).toBe(input); expect(result.text).toBe(input);
}); });
it("parses MEDIA tags with leading whitespace", () => {
const result = splitMediaFromOutput(" MEDIA:./screenshot.png");
expect(result.mediaUrls).toEqual(["./screenshot.png"]);
expect(result.text).toBe("");
});
it("accepts Windows-style paths", () => {
const result = splitMediaFromOutput("MEDIA:C:\\Users\\pete\\Pictures\\snap.png");
expect(result.mediaUrls).toEqual(["C:\\Users\\pete\\Pictures\\snap.png"]);
expect(result.text).toBe("");
});
it("accepts TTS temp file paths", () => {
const result = splitMediaFromOutput("MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus");
expect(result.mediaUrls).toEqual(["/tmp/tts-fAJy8C/voice-1770246885083.opus"]);
expect(result.text).toBe("");
});
it("accepts bare filenames with extensions", () => {
const result = splitMediaFromOutput("MEDIA:image.png");
expect(result.mediaUrls).toEqual(["image.png"]);
expect(result.text).toBe("");
});
it("rejects bare words without file extensions", () => { it("rejects bare words without file extensions", () => {
const result = splitMediaFromOutput("MEDIA:screenshot"); const result = splitMediaFromOutput("MEDIA:screenshot");
expect(result.mediaUrls).toBeUndefined(); expect(result.mediaUrls).toBeUndefined();

View File

@@ -11,56 +11,59 @@ import {
} from "./mmr.js"; } from "./mmr.js";
describe("tokenize", () => { describe("tokenize", () => {
it("extracts alphanumeric tokens and lowercases", () => { it("normalizes, filters, and deduplicates token sets", () => {
const result = tokenize("Hello World 123"); const cases = [
expect(result).toEqual(new Set(["hello", "world", "123"])); {
}); name: "alphanumeric lowercase",
input: "Hello World 123",
expected: ["hello", "world", "123"],
},
{ name: "empty string", input: "", expected: [] },
{ name: "special chars only", input: "!@#$%^&*()", expected: [] },
{
name: "underscores",
input: "hello_world test_case",
expected: ["hello_world", "test_case"],
},
{
name: "dedupe repeated tokens",
input: "hello hello world world",
expected: ["hello", "world"],
},
] as const;
it("handles empty string", () => { for (const testCase of cases) {
expect(tokenize("")).toEqual(new Set()); expect(tokenize(testCase.input), testCase.name).toEqual(new Set(testCase.expected));
}); }
it("handles special characters only", () => {
expect(tokenize("!@#$%^&*()")).toEqual(new Set());
});
it("handles underscores in tokens", () => {
const result = tokenize("hello_world test_case");
expect(result).toEqual(new Set(["hello_world", "test_case"]));
});
it("deduplicates repeated tokens", () => {
const result = tokenize("hello hello world world");
expect(result).toEqual(new Set(["hello", "world"]));
}); });
}); });
describe("jaccardSimilarity", () => { describe("jaccardSimilarity", () => {
it("returns 1 for identical sets", () => { it("computes expected scores for overlap edge cases", () => {
const set = new Set(["a", "b", "c"]); const cases = [
expect(jaccardSimilarity(set, set)).toBe(1); {
}); name: "identical sets",
left: new Set(["a", "b", "c"]),
right: new Set(["a", "b", "c"]),
expected: 1,
},
{ name: "disjoint sets", left: new Set(["a", "b"]), right: new Set(["c", "d"]), expected: 0 },
{ name: "two empty sets", left: new Set(), right: new Set(), expected: 1 },
{ name: "left non-empty right empty", left: new Set(["a"]), right: new Set(), expected: 0 },
{ name: "left empty right non-empty", left: new Set(), right: new Set(["a"]), expected: 0 },
{
name: "partial overlap",
left: new Set(["a", "b", "c"]),
right: new Set(["b", "c", "d"]),
expected: 0.5,
},
] as const;
it("returns 0 for disjoint sets", () => { for (const testCase of cases) {
const setA = new Set(["a", "b"]); expect(jaccardSimilarity(testCase.left, testCase.right), testCase.name).toBe(
const setB = new Set(["c", "d"]); testCase.expected,
expect(jaccardSimilarity(setA, setB)).toBe(0); );
}); }
it("returns 1 for two empty sets", () => {
expect(jaccardSimilarity(new Set(), new Set())).toBe(1);
});
it("returns 0 when one set is empty", () => {
expect(jaccardSimilarity(new Set(["a"]), new Set())).toBe(0);
expect(jaccardSimilarity(new Set(), new Set(["a"]))).toBe(0);
});
it("computes correct similarity for partial overlap", () => {
const setA = new Set(["a", "b", "c"]);
const setB = new Set(["b", "c", "d"]);
// Intersection: {b, c} = 2, Union: {a, b, c, d} = 4
expect(jaccardSimilarity(setA, setB)).toBe(0.5);
}); });
it("is symmetric", () => { it("is symmetric", () => {
@@ -71,40 +74,47 @@ describe("jaccardSimilarity", () => {
}); });
describe("textSimilarity", () => { describe("textSimilarity", () => {
it("returns 1 for identical text", () => { it("computes expected text-level similarity cases", () => {
expect(textSimilarity("hello world", "hello world")).toBe(1); const cases = [
}); { name: "identical", left: "hello world", right: "hello world", expected: 1 },
{ name: "same words reordered", left: "hello world", right: "world hello", expected: 1 },
{ name: "different text", left: "hello world", right: "foo bar", expected: 0 },
{ name: "case insensitive", left: "Hello World", right: "hello world", expected: 1 },
] as const;
it("returns 1 for same words different order", () => { for (const testCase of cases) {
expect(textSimilarity("hello world", "world hello")).toBe(1); expect(textSimilarity(testCase.left, testCase.right), testCase.name).toBe(testCase.expected);
}); }
it("returns 0 for completely different text", () => {
expect(textSimilarity("hello world", "foo bar")).toBe(0);
});
it("handles case insensitivity", () => {
expect(textSimilarity("Hello World", "hello world")).toBe(1);
}); });
}); });
describe("computeMMRScore", () => { describe("computeMMRScore", () => {
it("returns pure relevance when lambda=1", () => { it("balances relevance and diversity across lambda settings", () => {
expect(computeMMRScore(0.8, 0.5, 1)).toBe(0.8); const cases = [
}); {
name: "lambda=1 relevance only",
relevance: 0.8,
similarity: 0.5,
lambda: 1,
expected: 0.8,
},
{
name: "lambda=0 diversity only",
relevance: 0.8,
similarity: 0.5,
lambda: 0,
expected: -0.5,
},
{ name: "lambda=0.5 mixed", relevance: 0.8, similarity: 0.6, lambda: 0.5, expected: 0.1 },
{ name: "default lambda math", relevance: 1.0, similarity: 0.5, lambda: 0.7, expected: 0.55 },
] as const;
it("returns negative similarity when lambda=0", () => { for (const testCase of cases) {
expect(computeMMRScore(0.8, 0.5, 0)).toBe(-0.5); expect(
}); computeMMRScore(testCase.relevance, testCase.similarity, testCase.lambda),
testCase.name,
it("balances relevance and diversity at lambda=0.5", () => { ).toBeCloseTo(testCase.expected);
// 0.5 * 0.8 - 0.5 * 0.6 = 0.4 - 0.3 = 0.1 }
expect(computeMMRScore(0.8, 0.6, 0.5)).toBeCloseTo(0.1);
});
it("computes correctly with default lambda=0.7", () => {
// 0.7 * 1.0 - 0.3 * 0.5 = 0.7 - 0.15 = 0.55
expect(computeMMRScore(1.0, 0.5, 0.7)).toBeCloseTo(0.55);
}); });
}); });

View File

@@ -1507,58 +1507,30 @@ describe("QmdMemoryManager", () => {
await manager.close(); await manager.close();
}); });
it("treats plain-text no-results stdout as an empty result set", async () => { it("treats plain-text no-results markers from stdout/stderr as empty result sets", async () => {
spawnMock.mockImplementation((_cmd: string, args: string[]) => { const cases = [
if (args[0] === "search") { { name: "stdout with punctuation", stream: "stdout", payload: "No results found." },
const child = createMockChild({ autoClose: false }); { name: "stdout without punctuation", stream: "stdout", payload: "No results found\n\n" },
emitAndClose(child, "stdout", "No results found."); { name: "stderr", stream: "stderr", payload: "No results found.\n" },
return child; ] as const;
}
return createMockChild();
});
const { manager } = await createManager(); for (const testCase of cases) {
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, testCase.stream, testCase.payload);
return child;
}
return createMockChild();
});
await expect( const { manager } = await createManager();
manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }), await expect(
).resolves.toEqual([]); manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }),
await manager.close(); testCase.name,
}); ).resolves.toEqual([]);
await manager.close();
it("treats plain-text no-results stdout without punctuation as empty", async () => { }
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stdout", "No results found\n\n");
return child;
}
return createMockChild();
});
const { manager } = await createManager();
await expect(
manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }),
).resolves.toEqual([]);
await manager.close();
});
it("treats plain-text no-results stderr as an empty result set", async () => {
spawnMock.mockImplementation((_cmd: string, args: string[]) => {
if (args[0] === "search") {
const child = createMockChild({ autoClose: false });
emitAndClose(child, "stderr", "No results found.\n");
return child;
}
return createMockChild();
});
const { manager } = await createManager();
await expect(
manager.search("missing", { sessionKey: "agent:main:slack:dm:u123" }),
).resolves.toEqual([]);
await manager.close();
}); });
it("throws when stdout is empty without the no-results marker", async () => { it("throws when stdout is empty without the no-results marker", async () => {

View File

@@ -18,66 +18,60 @@ describe("resolveAgentRoute", () => {
expect(route.matchedBy).toBe("default"); expect(route.matchedBy).toBe("default");
}); });
test("dmScope=per-peer isolates DM sessions by sender id", () => { test("dmScope controls direct-message session key isolation", () => {
const cfg: OpenClawConfig = { const cases = [
session: { dmScope: "per-peer" }, { dmScope: "per-peer" as const, expected: "agent:main:direct:+15551234567" },
}; {
const route = resolveAgentRoute({ dmScope: "per-channel-peer" as const,
cfg, expected: "agent:main:whatsapp:direct:+15551234567",
channel: "whatsapp",
accountId: null,
peer: { kind: "direct", id: "+15551234567" },
});
expect(route.sessionKey).toBe("agent:main:direct:+15551234567");
});
test("dmScope=per-channel-peer isolates DM sessions per channel and sender", () => {
const cfg: OpenClawConfig = {
session: { dmScope: "per-channel-peer" },
};
const route = resolveAgentRoute({
cfg,
channel: "whatsapp",
accountId: null,
peer: { kind: "direct", id: "+15551234567" },
});
expect(route.sessionKey).toBe("agent:main:whatsapp:direct:+15551234567");
});
test("identityLinks collapses per-peer DM sessions across providers", () => {
const cfg: OpenClawConfig = {
session: {
dmScope: "per-peer",
identityLinks: {
alice: ["telegram:111111111", "discord:222222222222222222"],
},
}, },
}; ];
const route = resolveAgentRoute({ for (const testCase of cases) {
cfg, const cfg: OpenClawConfig = {
channel: "telegram", session: { dmScope: testCase.dmScope },
accountId: null, };
peer: { kind: "direct", id: "111111111" }, const route = resolveAgentRoute({
}); cfg,
expect(route.sessionKey).toBe("agent:main:direct:alice"); channel: "whatsapp",
accountId: null,
peer: { kind: "direct", id: "+15551234567" },
});
expect(route.sessionKey).toBe(testCase.expected);
}
}); });
test("identityLinks applies to per-channel-peer DM sessions", () => { test("identityLinks applies to direct-message scopes", () => {
const cfg: OpenClawConfig = { const cases = [
session: { {
dmScope: "per-channel-peer", dmScope: "per-peer" as const,
identityLinks: { channel: "telegram",
alice: ["telegram:111111111", "discord:222222222222222222"], peerId: "111111111",
}, expected: "agent:main:direct:alice",
}, },
}; {
const route = resolveAgentRoute({ dmScope: "per-channel-peer" as const,
cfg, channel: "discord",
channel: "discord", peerId: "222222222222222222",
accountId: null, expected: "agent:main:discord:direct:alice",
peer: { kind: "direct", id: "222222222222222222" }, },
}); ];
expect(route.sessionKey).toBe("agent:main:discord:direct:alice"); for (const testCase of cases) {
const cfg: OpenClawConfig = {
session: {
dmScope: testCase.dmScope,
identityLinks: {
alice: ["telegram:111111111", "discord:222222222222222222"],
},
},
};
const route = resolveAgentRoute({
cfg,
channel: testCase.channel,
accountId: null,
peer: { kind: "direct", id: testCase.peerId },
});
expect(route.sessionKey).toBe(testCase.expected);
}
}); });
test("peer binding wins over account binding", () => { test("peer binding wins over account binding", () => {

View File

@@ -8,24 +8,28 @@ describe("stripReasoningTagsFromText", () => {
expect(stripReasoningTagsFromText(input)).toBe(input); expect(stripReasoningTagsFromText(input)).toBe(input);
}); });
it("strips proper think tags", () => { it("strips reasoning-tag variants", () => {
const input = "Hello <think>internal reasoning</think> world!"; const cases = [
expect(stripReasoningTagsFromText(input)).toBe("Hello world!"); {
}); name: "strips proper think tags",
input: "Hello <think>internal reasoning</think> world!",
it("strips thinking tags", () => { expected: "Hello world!",
const input = "Before <thinking>some thought</thinking> after"; },
expect(stripReasoningTagsFromText(input)).toBe("Before after"); {
}); name: "strips thinking tags",
input: "Before <thinking>some thought</thinking> after",
it("strips thought tags", () => { expected: "Before after",
const input = "A <thought>hmm</thought> B"; },
expect(stripReasoningTagsFromText(input)).toBe("A B"); { name: "strips thought tags", input: "A <thought>hmm</thought> B", expected: "A B" },
}); {
name: "strips antthinking tags",
it("strips antthinking tags", () => { input: "X <antthinking>internal</antthinking> Y",
const input = "X <antthinking>internal</antthinking> Y"; expected: "X Y",
expect(stripReasoningTagsFromText(input)).toBe("X Y"); },
] as const;
for (const { name, input, expected } of cases) {
expect(stripReasoningTagsFromText(input), name).toBe(expected);
}
}); });
it("strips multiple reasoning blocks", () => { it("strips multiple reasoning blocks", () => {
@@ -35,20 +39,19 @@ describe("stripReasoningTagsFromText", () => {
}); });
describe("code block preservation (issue #3952)", () => { describe("code block preservation (issue #3952)", () => {
it("preserves think tags inside fenced code blocks", () => { it("preserves tags inside code examples", () => {
const input = "Use the tag like this:\n```\n<think>reasoning</think>\n```\nThat's it!"; const cases = [
expect(stripReasoningTagsFromText(input)).toBe(input); "Use the tag like this:\n```\n<think>reasoning</think>\n```\nThat's it!",
}); "The `<think>` tag is used for reasoning. Don't forget the closing `</think>` tag.",
"Example:\n```xml\n<think>\n <thought>nested</thought>\n</think>\n```\nDone!",
it("preserves think tags inside inline code", () => { "Use `<think>` to open and `</think>` to close.",
const input = "Example:\n```\n<think>reasoning</think>\n```",
"The `<think>` tag is used for reasoning. Don't forget the closing `</think>` tag."; "Use `<final>` for final answers in code: ```\n<final>42</final>\n```",
expect(stripReasoningTagsFromText(input)).toBe(input); "First `<think>` then ```\n<thinking>block</thinking>\n``` then `<thought>`",
}); ] as const;
for (const input of cases) {
it("preserves tags in fenced code blocks with language specifier", () => { expect(stripReasoningTagsFromText(input)).toBe(input);
const input = "Example:\n```xml\n<think>\n <thought>nested</thought>\n</think>\n```\nDone!"; }
expect(stripReasoningTagsFromText(input)).toBe(input);
}); });
it("handles mixed real tags and code tags", () => { it("handles mixed real tags and code tags", () => {
@@ -56,30 +59,10 @@ describe("stripReasoningTagsFromText", () => {
expect(stripReasoningTagsFromText(input)).toBe("Visible text with `<think>` example."); expect(stripReasoningTagsFromText(input)).toBe("Visible text with `<think>` example.");
}); });
it("preserves both opening and closing tags in backticks", () => {
const input = "Use `<think>` to open and `</think>` to close.";
expect(stripReasoningTagsFromText(input)).toBe(input);
});
it("preserves think tags in code block at EOF without trailing newline", () => {
const input = "Example:\n```\n<think>reasoning</think>\n```";
expect(stripReasoningTagsFromText(input)).toBe(input);
});
it("preserves final tags inside code blocks", () => {
const input = "Use `<final>` for final answers in code: ```\n<final>42</final>\n```";
expect(stripReasoningTagsFromText(input)).toBe(input);
});
it("handles code block followed by real tags", () => { it("handles code block followed by real tags", () => {
const input = "```\n<think>code</think>\n```\n<think>real hidden</think>visible"; const input = "```\n<think>code</think>\n```\n<think>real hidden</think>visible";
expect(stripReasoningTagsFromText(input)).toBe("```\n<think>code</think>\n```\nvisible"); expect(stripReasoningTagsFromText(input)).toBe("```\n<think>code</think>\n```\nvisible");
}); });
it("handles multiple code blocks with tags", () => {
const input = "First `<think>` then ```\n<thinking>block</thinking>\n``` then `<thought>`";
expect(stripReasoningTagsFromText(input)).toBe(input);
});
}); });
describe("edge cases", () => { describe("edge cases", () => {
@@ -100,11 +83,8 @@ describe("stripReasoningTagsFromText", () => {
expect(stripReasoningTagsFromText(input)).toBe("A B"); expect(stripReasoningTagsFromText(input)).toBe("A B");
}); });
it("handles empty input", () => { it("handles empty and null-ish inputs", () => {
expect(stripReasoningTagsFromText("")).toBe(""); expect(stripReasoningTagsFromText("")).toBe("");
});
it("handles null-ish input", () => {
expect(stripReasoningTagsFromText(null as unknown as string)).toBe(null); expect(stripReasoningTagsFromText(null as unknown as string)).toBe(null);
}); });