test: optimize gateway infra memory and security coverage
This commit is contained in:
@@ -123,7 +123,7 @@ describe("callGateway url resolution", () => {
|
||||
label: "falls back to loopback when local bind is auto without tailnet IP",
|
||||
tailnetIp: undefined,
|
||||
},
|
||||
])("$label", async ({ tailnetIp }) => {
|
||||
])("local auto-bind: $label", async ({ tailnetIp }) => {
|
||||
loadConfig.mockReturnValue({ gateway: { mode: "local", bind: "auto" } });
|
||||
resolveGatewayPort.mockReturnValue(18800);
|
||||
pickPrimaryTailnetIPv4.mockReturnValue(tailnetIp);
|
||||
@@ -218,7 +218,7 @@ describe("callGateway url resolution", () => {
|
||||
call: () => callGatewayCli({ method: "health" }),
|
||||
expectedScopes: ["operator.admin", "operator.approvals", "operator.pairing"],
|
||||
},
|
||||
])("$label", async ({ call, expectedScopes }) => {
|
||||
])("scope selection: $label", async ({ call, expectedScopes }) => {
|
||||
setLocalLoopbackGatewayConfig();
|
||||
await call();
|
||||
expect(lastClientOptions?.scopes).toEqual(expectedScopes);
|
||||
|
||||
@@ -32,33 +32,6 @@ describe("buildMessageWithAttachments", () => {
|
||||
};
|
||||
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", () => {
|
||||
@@ -80,45 +53,6 @@ describe("parseMessageWithAttachments", () => {
|
||||
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 () => {
|
||||
const logs: string[] = [];
|
||||
const parsed = await parseMessageWithAttachments(
|
||||
@@ -219,3 +153,43 @@ describe("parseMessageWithAttachments", () => {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
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 { startGatewayServer } from "./server.js";
|
||||
import { extractPayloadText } from "./test-helpers.agent-results.js";
|
||||
@@ -15,7 +15,14 @@ import {
|
||||
import { installOpenAiResponsesMock } from "./test-helpers.openai-mock.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", () => {
|
||||
beforeAll(async () => {
|
||||
({ writeConfigFile, resolveConfigPath } = await import("../config/config.js"));
|
||||
});
|
||||
|
||||
it(
|
||||
"runs a mock OpenAI tool call end-to-end via gateway agent loop",
|
||||
{ timeout: 90_000 },
|
||||
@@ -148,7 +155,6 @@ describe("gateway e2e", () => {
|
||||
await prompter.intro("Wizard E2E");
|
||||
await prompter.note("write token");
|
||||
const token = await prompter.text({ message: "token" });
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
await writeConfigFile({
|
||||
gateway: { auth: { mode: "token", token: String(token) } },
|
||||
});
|
||||
@@ -196,7 +202,6 @@ describe("gateway e2e", () => {
|
||||
expect(didSendToken).toBe(true);
|
||||
expect(next.status).toBe("done");
|
||||
|
||||
const { resolveConfigPath } = await import("../config/config.js");
|
||||
const parsed = JSON.parse(await fs.readFile(resolveConfigPath(), "utf8"));
|
||||
const token = (parsed as Record<string, unknown>)?.gateway as
|
||||
| Record<string, unknown>
|
||||
|
||||
@@ -11,14 +11,16 @@ import {
|
||||
} from "./net.js";
|
||||
|
||||
describe("resolveHostName", () => {
|
||||
it("returns hostname without port for IPv4/hostnames", () => {
|
||||
expect(resolveHostName("localhost:18789")).toBe("localhost");
|
||||
expect(resolveHostName("127.0.0.1:18789")).toBe("127.0.0.1");
|
||||
});
|
||||
|
||||
it("handles bracketed and unbracketed IPv6 loopback hosts", () => {
|
||||
expect(resolveHostName("[::1]:18789")).toBe("::1");
|
||||
expect(resolveHostName("::1")).toBe("::1");
|
||||
it("normalizes IPv4/hostname and IPv6 host forms", () => {
|
||||
const cases = [
|
||||
{ input: "localhost:18789", expected: "localhost" },
|
||||
{ input: "127.0.0.1:18789", expected: "127.0.0.1" },
|
||||
{ input: "[::1]:18789", expected: "::1" },
|
||||
{ input: "::1", expected: "::1" },
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
expect(resolveHostName(testCase.input), testCase.input).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -204,27 +206,36 @@ describe("resolveClientIp", () => {
|
||||
});
|
||||
|
||||
describe("resolveGatewayListenHosts", () => {
|
||||
it("returns the input host when not loopback", async () => {
|
||||
const hosts = await resolveGatewayListenHosts("0.0.0.0", {
|
||||
canBindToHost: async () => {
|
||||
throw new Error("should not be called");
|
||||
it("resolves listen hosts for non-loopback and loopback variants", async () => {
|
||||
const cases = [
|
||||
{
|
||||
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 () => {
|
||||
const hosts = await resolveGatewayListenHosts("127.0.0.1", {
|
||||
canBindToHost: async () => true,
|
||||
});
|
||||
expect(hosts).toEqual(["127.0.0.1", "::1"]);
|
||||
});
|
||||
|
||||
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"]);
|
||||
for (const testCase of cases) {
|
||||
const hosts = await resolveGatewayListenHosts(testCase.host, {
|
||||
canBindToHost: testCase.canBindToHost,
|
||||
});
|
||||
expect(hosts, testCase.name).toEqual(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -233,49 +244,48 @@ describe("pickPrimaryLanIPv4", () => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns en0 IPv4 address when available", () => {
|
||||
vi.spyOn(os, "networkInterfaces").mockReturnValue({
|
||||
lo0: [
|
||||
{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" },
|
||||
] as unknown as os.NetworkInterfaceInfo[],
|
||||
en0: [
|
||||
{ address: "192.168.1.42", family: "IPv4", internal: false, netmask: "" },
|
||||
] as unknown as os.NetworkInterfaceInfo[],
|
||||
});
|
||||
expect(pickPrimaryLanIPv4()).toBe("192.168.1.42");
|
||||
});
|
||||
it("prefers en0, then eth0, then any non-internal IPv4, otherwise undefined", () => {
|
||||
const cases = [
|
||||
{
|
||||
name: "prefers en0",
|
||||
interfaces: {
|
||||
lo0: [{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" }],
|
||||
en0: [{ address: "192.168.1.42", family: "IPv4", internal: false, netmask: "" }],
|
||||
},
|
||||
expected: "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", () => {
|
||||
vi.spyOn(os, "networkInterfaces").mockReturnValue({
|
||||
lo: [
|
||||
{ address: "127.0.0.1", family: "IPv4", internal: true, netmask: "" },
|
||||
] as unknown as os.NetworkInterfaceInfo[],
|
||||
eth0: [
|
||||
{ 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();
|
||||
for (const testCase of cases) {
|
||||
vi.spyOn(os, "networkInterfaces").mockReturnValue(
|
||||
testCase.interfaces as unknown as ReturnType<typeof os.networkInterfaces>,
|
||||
);
|
||||
expect(pickPrimaryLanIPv4(), testCase.name).toBe(testCase.expected);
|
||||
vi.restoreAllMocks();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -312,40 +322,28 @@ describe("isPrivateOrLoopbackAddress", () => {
|
||||
});
|
||||
|
||||
describe("isSecureWebSocketUrl", () => {
|
||||
describe("wss:// (TLS) URLs", () => {
|
||||
it("returns true for wss:// regardless of host", () => {
|
||||
expect(isSecureWebSocketUrl("wss://127.0.0.1:18789")).toBe(true);
|
||||
expect(isSecureWebSocketUrl("wss://localhost:18789")).toBe(true);
|
||||
expect(isSecureWebSocketUrl("wss://remote.example.com:18789")).toBe(true);
|
||||
expect(isSecureWebSocketUrl("wss://192.168.1.100:18789")).toBe(true);
|
||||
});
|
||||
});
|
||||
it("accepts secure websocket/loopback ws URLs and rejects unsafe inputs", () => {
|
||||
const cases = [
|
||||
{ input: "wss://127.0.0.1:18789", expected: true },
|
||||
{ input: "wss://localhost:18789", expected: true },
|
||||
{ input: "wss://remote.example.com:18789", expected: 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", () => {
|
||||
it("returns true for ws:// to loopback addresses", () => {
|
||||
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);
|
||||
});
|
||||
for (const testCase of cases) {
|
||||
expect(isSecureWebSocketUrl(testCase.input), testCase.input).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,10 +13,12 @@ import {
|
||||
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
let startGatewayServer: typeof import("./server.js").startGatewayServer;
|
||||
let enabledServer: Awaited<ReturnType<typeof startServer>>;
|
||||
let enabledPort: number;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ startGatewayServer } = await import("./server.js"));
|
||||
enabledPort = await getFreePort();
|
||||
enabledServer = await startServer(enabledPort);
|
||||
});
|
||||
@@ -26,7 +28,6 @@ afterAll(async () => {
|
||||
});
|
||||
|
||||
async function startServerWithDefaultConfig(port: number) {
|
||||
const { startGatewayServer } = await import("./server.js");
|
||||
return await startGatewayServer(port, {
|
||||
host: "127.0.0.1",
|
||||
auth: { mode: "token", token: "secret" },
|
||||
@@ -36,7 +37,6 @@ async function startServerWithDefaultConfig(port: number) {
|
||||
}
|
||||
|
||||
async function startServer(port: number, opts?: { openAiChatCompletionsEnabled?: boolean }) {
|
||||
const { startGatewayServer } = await import("./server.js");
|
||||
return await startGatewayServer(port, {
|
||||
host: "127.0.0.1",
|
||||
auth: { mode: "token", token: "secret" },
|
||||
|
||||
@@ -5,13 +5,29 @@
|
||||
* 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", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
InputImageContentPartSchema,
|
||||
InputFileContentPartSchema,
|
||||
ToolDefinitionSchema,
|
||||
CreateResponseBodySchema,
|
||||
OutputItemSchema,
|
||||
} = await import("./open-responses.schema.js"));
|
||||
({ buildAgentPrompt } = await import("./openresponses-http.js"));
|
||||
});
|
||||
|
||||
describe("Schema Validation", () => {
|
||||
it("should validate input_image with url source", async () => {
|
||||
const { InputImageContentPartSchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const validImage = {
|
||||
type: "input_image" as const,
|
||||
source: {
|
||||
@@ -25,8 +41,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
});
|
||||
|
||||
it("should validate input_image with base64 source", async () => {
|
||||
const { InputImageContentPartSchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const validImage = {
|
||||
type: "input_image" as const,
|
||||
source: {
|
||||
@@ -41,8 +55,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
});
|
||||
|
||||
it("should reject input_image with invalid mime type", async () => {
|
||||
const { InputImageContentPartSchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const invalidImage = {
|
||||
type: "input_image" as const,
|
||||
source: {
|
||||
@@ -57,8 +69,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
});
|
||||
|
||||
it("should validate input_file with url source", async () => {
|
||||
const { InputFileContentPartSchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const validFile = {
|
||||
type: "input_file" as const,
|
||||
source: {
|
||||
@@ -72,8 +82,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
});
|
||||
|
||||
it("should validate input_file with base64 source", async () => {
|
||||
const { InputFileContentPartSchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const validFile = {
|
||||
type: "input_file" as const,
|
||||
source: {
|
||||
@@ -89,8 +97,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
});
|
||||
|
||||
it("should validate tool definition", async () => {
|
||||
const { ToolDefinitionSchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const validTool = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
@@ -111,8 +117,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
});
|
||||
|
||||
it("should reject tool definition without name", async () => {
|
||||
const { ToolDefinitionSchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const invalidTool = {
|
||||
type: "function" as const,
|
||||
function: {
|
||||
@@ -128,8 +132,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
|
||||
describe("CreateResponseBody Schema", () => {
|
||||
it("should validate request with input_image", async () => {
|
||||
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const validRequest = {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
input: [
|
||||
@@ -158,8 +160,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
});
|
||||
|
||||
it("should validate request with client tools", async () => {
|
||||
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const validRequest = {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
input: [
|
||||
@@ -192,8 +192,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
});
|
||||
|
||||
it("should validate request with function_call_output for turn-based tools", async () => {
|
||||
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const validRequest = {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
input: [
|
||||
@@ -210,8 +208,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
});
|
||||
|
||||
it("should validate complete turn-based tool flow", async () => {
|
||||
const { CreateResponseBodySchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const turn1Request = {
|
||||
model: "claude-sonnet-4-20250514",
|
||||
input: [
|
||||
@@ -254,8 +250,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
|
||||
describe("Response Resource Schema", () => {
|
||||
it("should validate response with function_call output", async () => {
|
||||
const { OutputItemSchema } = await import("./open-responses.schema.js");
|
||||
|
||||
const functionCallOutput = {
|
||||
type: "function_call" as const,
|
||||
id: "msg_123",
|
||||
@@ -271,8 +265,6 @@ describe("OpenResponses Feature Parity", () => {
|
||||
|
||||
describe("buildAgentPrompt", () => {
|
||||
it("should convert function_call_output to tool entry", async () => {
|
||||
const { buildAgentPrompt } = await import("./openresponses-http.js");
|
||||
|
||||
const result = buildAgentPrompt([
|
||||
{
|
||||
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 () => {
|
||||
const { buildAgentPrompt } = await import("./openresponses-http.js");
|
||||
|
||||
const result = buildAgentPrompt([
|
||||
{
|
||||
type: "message" as const,
|
||||
|
||||
@@ -11,7 +11,8 @@ import {
|
||||
startServerWithClient,
|
||||
} 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" });
|
||||
|
||||
@@ -77,7 +78,6 @@ const telegramPlugin: ChannelPlugin = {
|
||||
}),
|
||||
gateway: {
|
||||
logoutAccount: async ({ cfg }) => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
const nextTelegram = cfg.channels?.telegram ? { ...cfg.channels.telegram } : {};
|
||||
delete nextTelegram.botToken;
|
||||
await writeConfigFile({
|
||||
@@ -118,6 +118,7 @@ let server: Awaited<ReturnType<typeof startServerWithClient>>["server"];
|
||||
let ws: Awaited<ReturnType<typeof startServerWithClient>>["ws"];
|
||||
|
||||
beforeAll(async () => {
|
||||
({ readConfigFileSnapshot, writeConfigFile } = await import("../config/config.js"));
|
||||
setRegistry(defaultRegistry);
|
||||
const started = await startServerWithClient();
|
||||
server = started.server;
|
||||
@@ -177,7 +178,6 @@ describe("gateway server channels", () => {
|
||||
test("channels.logout clears telegram bot token from config", async () => {
|
||||
vi.stubEnv("TELEGRAM_BOT_TOKEN", undefined);
|
||||
setRegistry(defaultRegistry);
|
||||
const { readConfigFileSnapshot, writeConfigFile } = await loadConfigHelpers();
|
||||
await writeConfigFile({
|
||||
channels: {
|
||||
telegram: {
|
||||
|
||||
@@ -4,6 +4,36 @@ import { withServer } from "./test-with-server.js";
|
||||
|
||||
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", () => {
|
||||
it("returns redacted talk config for read scope", async () => {
|
||||
const { writeConfigFile } = await import("../config/config.js");
|
||||
@@ -21,7 +51,11 @@ describe("gateway talk.config", () => {
|
||||
});
|
||||
|
||||
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 } } }>(
|
||||
ws,
|
||||
"talk.config",
|
||||
@@ -42,7 +76,11 @@ describe("gateway talk.config", () => {
|
||||
});
|
||||
|
||||
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 });
|
||||
expect(res.ok).toBe(false);
|
||||
expect(res.error?.message).toContain("missing scope: operator.talk.secrets");
|
||||
@@ -61,6 +99,11 @@ describe("gateway talk.config", () => {
|
||||
await connectOk(ws, {
|
||||
token: "secret",
|
||||
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", {
|
||||
includeSecrets: true,
|
||||
|
||||
@@ -38,59 +38,51 @@ describe("readFirstUserMessageFromTranscript", () => {
|
||||
storePath = nextStorePath;
|
||||
});
|
||||
|
||||
test("returns null when transcript file does not exist", () => {
|
||||
const result = readFirstUserMessageFromTranscript("nonexistent-session", storePath);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
test("extracts first user text across supported content formats", () => {
|
||||
const cases = [
|
||||
{
|
||||
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", () => {
|
||||
const sessionId = "test-session-1";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ type: "session", version: 1, id: sessionId }),
|
||||
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");
|
||||
for (const testCase of cases) {
|
||||
const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`);
|
||||
fs.writeFileSync(transcriptPath, testCase.lines.join("\n"), "utf-8");
|
||||
const result = readFirstUserMessageFromTranscript(testCase.sessionId, storePath);
|
||||
expect(result, testCase.sessionId).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
test("skips non-user messages to find first user message", () => {
|
||||
const sessionId = "test-session-3";
|
||||
@@ -155,29 +147,6 @@ describe("readFirstUserMessageFromTranscript", () => {
|
||||
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", () => {
|
||||
const sessionId = "test-session-8";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
@@ -201,11 +170,6 @@ describe("readLastMessagePreviewFromTranscript", () => {
|
||||
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", () => {
|
||||
const sessionId = "test-last-empty";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
@@ -215,31 +179,33 @@ describe("readLastMessagePreviewFromTranscript", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("returns last user message from transcript", () => {
|
||||
const sessionId = "test-last-user";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({ message: { role: "user", content: "First user" } }),
|
||||
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");
|
||||
test("returns the last user or assistant message from transcript", () => {
|
||||
const cases = [
|
||||
{
|
||||
sessionId: "test-last-user",
|
||||
lines: [
|
||||
JSON.stringify({ message: { role: "user", content: "First user" } }),
|
||||
JSON.stringify({ message: { role: "assistant", content: "First assistant" } }),
|
||||
JSON.stringify({ message: { role: "user", content: "Last user message" } }),
|
||||
],
|
||||
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);
|
||||
expect(result).toBe("Last user message");
|
||||
});
|
||||
|
||||
test("returns last assistant message from transcript", () => {
|
||||
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");
|
||||
for (const testCase of cases) {
|
||||
const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`);
|
||||
fs.writeFileSync(transcriptPath, testCase.lines.join("\n"), "utf-8");
|
||||
const result = readLastMessagePreviewFromTranscript(testCase.sessionId, storePath);
|
||||
expect(result).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
test("skips system messages to find last user/assistant", () => {
|
||||
@@ -268,7 +234,7 @@ describe("readLastMessagePreviewFromTranscript", () => {
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
test("handles malformed JSON lines gracefully", () => {
|
||||
test("handles malformed JSON lines gracefully (last preview)", () => {
|
||||
const sessionId = "test-last-malformed";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
@@ -281,59 +247,31 @@ describe("readLastMessagePreviewFromTranscript", () => {
|
||||
expect(result).toBe("Valid first");
|
||||
});
|
||||
|
||||
test("handles array content format", () => {
|
||||
const sessionId = "test-last-array";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
const lines = [
|
||||
JSON.stringify({
|
||||
test("handles array/output_text content formats", () => {
|
||||
const cases = [
|
||||
{
|
||||
sessionId: "test-last-array",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "Array content response" }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
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({
|
||||
expected: "Array content response",
|
||||
},
|
||||
{
|
||||
sessionId: "test-last-output-text",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "output_text", text: "Output text response" }],
|
||||
},
|
||||
}),
|
||||
];
|
||||
fs.writeFileSync(transcriptPath, lines.join("\n"), "utf-8");
|
||||
|
||||
const result = readLastMessagePreviewFromTranscript(sessionId, storePath);
|
||||
expect(result).toBe("Output text response");
|
||||
});
|
||||
test("uses sessionFile parameter when provided", () => {
|
||||
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");
|
||||
expected: "Output text response",
|
||||
},
|
||||
] as const;
|
||||
for (const testCase of cases) {
|
||||
const transcriptPath = path.join(tmpDir, `${testCase.sessionId}.jsonl`);
|
||||
fs.writeFileSync(transcriptPath, JSON.stringify({ message: testCase.message }), "utf-8");
|
||||
const result = readLastMessagePreviewFromTranscript(testCase.sessionId, storePath);
|
||||
expect(result, testCase.sessionId).toBe(testCase.expected);
|
||||
}
|
||||
});
|
||||
|
||||
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", () => {
|
||||
let tmpDir: string;
|
||||
let storePath: string;
|
||||
@@ -496,56 +495,53 @@ describe("readSessionMessages", () => {
|
||||
expect(typeof marker.timestamp).toBe("number");
|
||||
});
|
||||
|
||||
test("reads cross-agent absolute sessionFile when storePath points to another agent dir", () => {
|
||||
const sessionId = "cross-agent-default-root";
|
||||
const sessionFile = path.join(tmpDir, "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: "user", content: "from-ops" } }),
|
||||
].join("\n"),
|
||||
"utf-8",
|
||||
);
|
||||
test("reads cross-agent absolute sessionFile across store-root layouts", () => {
|
||||
const cases = [
|
||||
{
|
||||
sessionId: "cross-agent-default-root",
|
||||
sessionFile: path.join(
|
||||
tmpDir,
|
||||
"agents",
|
||||
"ops",
|
||||
"sessions",
|
||||
"cross-agent-default-root.jsonl",
|
||||
),
|
||||
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");
|
||||
const out = readSessionMessages(sessionId, wrongStorePath, sessionFile);
|
||||
for (const testCase of cases) {
|
||||
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" }]);
|
||||
});
|
||||
|
||||
test("reads cross-agent absolute sessionFile for custom per-agent store roots", () => {
|
||||
const sessionId = "cross-agent-custom-root";
|
||||
const sessionFile = path.join(
|
||||
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" }]);
|
||||
const out = readSessionMessages(
|
||||
testCase.sessionId,
|
||||
testCase.wrongStorePath,
|
||||
testCase.sessionFile,
|
||||
);
|
||||
expect(out).toEqual([testCase.message]);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -660,20 +656,28 @@ describe("resolveSessionTranscriptCandidates", () => {
|
||||
});
|
||||
|
||||
describe("resolveSessionTranscriptCandidates safety", () => {
|
||||
test("keeps cross-agent absolute sessionFile when storePath agent context differs", () => {
|
||||
const storePath = "/tmp/openclaw/agents/main/sessions/sessions.json";
|
||||
const sessionFile = "/tmp/openclaw/agents/ops/sessions/sess-safe.jsonl";
|
||||
const candidates = resolveSessionTranscriptCandidates("sess-safe", storePath, sessionFile);
|
||||
test("keeps cross-agent absolute sessionFile for standard and custom store roots", () => {
|
||||
const cases = [
|
||||
{
|
||||
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));
|
||||
});
|
||||
|
||||
test("keeps cross-agent absolute sessionFile for custom per-agent store roots", () => {
|
||||
const storePath = "/srv/custom/agents/main/sessions/sessions.json";
|
||||
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(sessionFile));
|
||||
for (const testCase of cases) {
|
||||
const candidates = resolveSessionTranscriptCandidates(
|
||||
"sess-safe",
|
||||
testCase.storePath,
|
||||
testCase.sessionFile,
|
||||
);
|
||||
expect(candidates.map((value) => path.resolve(value))).toContain(
|
||||
path.resolve(testCase.sessionFile),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("drops unsafe session IDs instead of producing traversal paths", () => {
|
||||
@@ -717,38 +721,33 @@ describe("archiveSessionTranscripts", () => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
test("archives existing transcript file and returns archived path", () => {
|
||||
const sessionId = "sess-archive-1";
|
||||
const transcriptPath = path.join(tmpDir, `${sessionId}.jsonl`);
|
||||
fs.writeFileSync(transcriptPath, '{"type":"session"}\n', "utf-8");
|
||||
test("archives transcript from default and explicit sessionFile paths", () => {
|
||||
const cases = [
|
||||
{
|
||||
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({
|
||||
sessionId,
|
||||
storePath,
|
||||
reason: "reset",
|
||||
});
|
||||
|
||||
expect(archived).toHaveLength(1);
|
||||
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);
|
||||
for (const testCase of cases) {
|
||||
fs.writeFileSync(testCase.transcriptPath, '{"type":"session"}\n', "utf-8");
|
||||
const archived = archiveSessionTranscripts(testCase.args);
|
||||
expect(archived).toHaveLength(1);
|
||||
expect(archived[0]).toContain(".reset.");
|
||||
expect(fs.existsSync(testCase.transcriptPath)).toBe(false);
|
||||
expect(fs.existsSync(archived[0])).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns empty array when no transcript files exist", () => {
|
||||
|
||||
@@ -382,120 +382,45 @@ describe("listSessionsFromStore search", () => {
|
||||
} as SessionEntry,
|
||||
});
|
||||
|
||||
test("returns all sessions when search is empty", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(3);
|
||||
test("returns all sessions when search is empty or missing", () => {
|
||||
const cases = [{ opts: { search: "" } }, { opts: {} }] as const;
|
||||
for (const testCase of cases) {
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store: makeStore(),
|
||||
opts: testCase.opts,
|
||||
});
|
||||
expect(result.sessions).toHaveLength(3);
|
||||
}
|
||||
});
|
||||
|
||||
test("returns all sessions when search is undefined", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: {},
|
||||
});
|
||||
expect(result.sessions.length).toBe(3);
|
||||
});
|
||||
test("filters sessions across display metadata and key fields", () => {
|
||||
const cases = [
|
||||
{ search: "WORK PROJECT", expectedKey: "agent:main:work-project" },
|
||||
{ search: "reunion", expectedKey: "agent:main:personal-chat" },
|
||||
{ search: "discord", expectedKey: "agent:main:discord:group:dev-team" },
|
||||
{ search: "sess-personal", expectedKey: "agent:main:personal-chat" },
|
||||
{ search: "dev-team", expectedKey: "agent:main:discord:group:dev-team" },
|
||||
{ search: "alpha", expectedKey: "agent:main:work-project" },
|
||||
{ search: " personal ", expectedKey: "agent:main:personal-chat" },
|
||||
{ search: "nonexistent-term", expectedKey: undefined },
|
||||
] as const;
|
||||
|
||||
test("filters by displayName case-insensitively", () => {
|
||||
const store = makeStore();
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
opts: { search: "WORK PROJECT" },
|
||||
});
|
||||
expect(result.sessions.length).toBe(1);
|
||||
expect(result.sessions[0].displayName).toBe("Work Project Alpha");
|
||||
});
|
||||
|
||||
test("filters by subject", () => {
|
||||
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);
|
||||
for (const testCase of cases) {
|
||||
const result = listSessionsFromStore({
|
||||
cfg: baseCfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store: makeStore(),
|
||||
opts: { search: testCase.search },
|
||||
});
|
||||
if (!testCase.expectedKey) {
|
||||
expect(result.sessions).toHaveLength(0);
|
||||
continue;
|
||||
}
|
||||
expect(result.sessions).toHaveLength(1);
|
||||
expect(result.sessions[0].key).toBe(testCase.expectedKey);
|
||||
}
|
||||
});
|
||||
|
||||
test("hides cron run alias session keys from sessions list", () => {
|
||||
|
||||
Reference in New Issue
Block a user