mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
refactor(test): dedupe agent harnesses and routing fixtures
This commit is contained in:
@@ -10,34 +10,35 @@ import {
|
||||
markExited,
|
||||
resetProcessRegistryForTests,
|
||||
} from "./bash-process-registry.js";
|
||||
import { createProcessSessionFixture } from "./bash-process-registry.test-helpers.js";
|
||||
|
||||
describe("bash process registry", () => {
|
||||
function createRegistrySession(params: {
|
||||
id?: string;
|
||||
maxOutputChars: number;
|
||||
pendingMaxOutputChars: number;
|
||||
backgrounded: boolean;
|
||||
}): ProcessSession {
|
||||
return createProcessSessionFixture({
|
||||
id: params.id ?? "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123, removeAllListeners: vi.fn() } as unknown as ChildProcessWithoutNullStreams,
|
||||
maxOutputChars: params.maxOutputChars,
|
||||
pendingMaxOutputChars: params.pendingMaxOutputChars,
|
||||
backgrounded: params.backgrounded,
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
resetProcessRegistryForTests();
|
||||
});
|
||||
|
||||
it("captures output and truncates", () => {
|
||||
const session: ProcessSession = {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123, removeAllListeners: vi.fn() } as unknown as ChildProcessWithoutNullStreams,
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
const session = createRegistrySession({
|
||||
maxOutputChars: 10,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: false,
|
||||
};
|
||||
});
|
||||
|
||||
addSession(session);
|
||||
appendOutput(session, "stdout", "0123456789");
|
||||
@@ -48,27 +49,11 @@ describe("bash process registry", () => {
|
||||
});
|
||||
|
||||
it("caps pending output to avoid runaway polls", () => {
|
||||
const session: ProcessSession = {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123, removeAllListeners: vi.fn() } as unknown as ChildProcessWithoutNullStreams,
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
const session = createRegistrySession({
|
||||
maxOutputChars: 100_000,
|
||||
pendingMaxOutputChars: 20_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
});
|
||||
|
||||
addSession(session);
|
||||
const payload = `${"a".repeat(70_000)}${"b".repeat(20_000)}`;
|
||||
@@ -82,27 +67,11 @@ describe("bash process registry", () => {
|
||||
});
|
||||
|
||||
it("respects max output cap when pending cap is larger", () => {
|
||||
const session: ProcessSession = {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123, removeAllListeners: vi.fn() } as unknown as ChildProcessWithoutNullStreams,
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
const session = createRegistrySession({
|
||||
maxOutputChars: 5_000,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
});
|
||||
|
||||
addSession(session);
|
||||
appendOutput(session, "stdout", "x".repeat(10_000));
|
||||
@@ -113,27 +82,11 @@ describe("bash process registry", () => {
|
||||
});
|
||||
|
||||
it("caps stdout and stderr independently", () => {
|
||||
const session: ProcessSession = {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123, removeAllListeners: vi.fn() } as unknown as ChildProcessWithoutNullStreams,
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
const session = createRegistrySession({
|
||||
maxOutputChars: 100,
|
||||
pendingMaxOutputChars: 10,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
});
|
||||
|
||||
addSession(session);
|
||||
appendOutput(session, "stdout", "a".repeat(6));
|
||||
@@ -147,27 +100,11 @@ describe("bash process registry", () => {
|
||||
});
|
||||
|
||||
it("only persists finished sessions when backgrounded", () => {
|
||||
const session: ProcessSession = {
|
||||
id: "sess",
|
||||
command: "echo test",
|
||||
child: { pid: 123, removeAllListeners: vi.fn() } as unknown as ChildProcessWithoutNullStreams,
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
const session = createRegistrySession({
|
||||
maxOutputChars: 100,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: false,
|
||||
};
|
||||
});
|
||||
|
||||
addSession(session);
|
||||
markExited(session, 0, null, "completed");
|
||||
|
||||
42
src/agents/bash-process-registry.test-helpers.ts
Normal file
42
src/agents/bash-process-registry.test-helpers.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import type { ChildProcessWithoutNullStreams } from "node:child_process";
|
||||
import type { ProcessSession } from "./bash-process-registry.js";
|
||||
|
||||
export function createProcessSessionFixture(params: {
|
||||
id: string;
|
||||
command?: string;
|
||||
startedAt?: number;
|
||||
cwd?: string;
|
||||
maxOutputChars?: number;
|
||||
pendingMaxOutputChars?: number;
|
||||
backgrounded?: boolean;
|
||||
pid?: number;
|
||||
child?: ChildProcessWithoutNullStreams;
|
||||
}): ProcessSession {
|
||||
const session: ProcessSession = {
|
||||
id: params.id,
|
||||
command: params.command ?? "test",
|
||||
startedAt: params.startedAt ?? Date.now(),
|
||||
cwd: params.cwd ?? "/tmp",
|
||||
maxOutputChars: params.maxOutputChars ?? 10_000,
|
||||
pendingMaxOutputChars: params.pendingMaxOutputChars ?? 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: params.backgrounded ?? false,
|
||||
};
|
||||
if (params.pid !== undefined) {
|
||||
session.pid = params.pid;
|
||||
}
|
||||
if (params.child) {
|
||||
session.child = params.child;
|
||||
}
|
||||
return session;
|
||||
}
|
||||
@@ -1,12 +1,12 @@
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { resetDiagnosticSessionStateForTest } from "../logging/diagnostic-session-state.js";
|
||||
import type { ProcessSession } from "./bash-process-registry.js";
|
||||
import {
|
||||
addSession,
|
||||
appendOutput,
|
||||
markExited,
|
||||
resetProcessRegistryForTests,
|
||||
} from "./bash-process-registry.js";
|
||||
import { createProcessSessionFixture } from "./bash-process-registry.test-helpers.js";
|
||||
import { createProcessTool } from "./bash-tools.process.js";
|
||||
|
||||
afterEach(() => {
|
||||
@@ -14,32 +14,13 @@ afterEach(() => {
|
||||
resetDiagnosticSessionStateForTest();
|
||||
});
|
||||
|
||||
function createBackgroundSession(id: string): ProcessSession {
|
||||
return {
|
||||
id,
|
||||
command: "test",
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 10_000,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
}
|
||||
|
||||
function createProcessSessionHarness(sessionId: string) {
|
||||
const processTool = createProcessTool();
|
||||
const session = createBackgroundSession(sessionId);
|
||||
const session = createProcessSessionFixture({
|
||||
id: sessionId,
|
||||
command: "test",
|
||||
backgrounded: true,
|
||||
});
|
||||
addSession(session);
|
||||
return { processTool, session };
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ProcessSession } from "./bash-process-registry.js";
|
||||
import {
|
||||
addSession,
|
||||
getFinishedSession,
|
||||
getSession,
|
||||
resetProcessRegistryForTests,
|
||||
} from "./bash-process-registry.js";
|
||||
import { createProcessSessionFixture } from "./bash-process-registry.test-helpers.js";
|
||||
import { createProcessTool } from "./bash-tools.process.js";
|
||||
|
||||
const { supervisorMock } = vi.hoisted(() => ({
|
||||
@@ -30,28 +30,13 @@ vi.mock("../process/kill-tree.js", () => ({
|
||||
killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args),
|
||||
}));
|
||||
|
||||
function createBackgroundSession(id: string, pid?: number): ProcessSession {
|
||||
return {
|
||||
function createBackgroundSession(id: string, pid?: number) {
|
||||
return createProcessSessionFixture({
|
||||
id,
|
||||
command: "sleep 999",
|
||||
startedAt: Date.now(),
|
||||
cwd: "/tmp",
|
||||
maxOutputChars: 10_000,
|
||||
pendingMaxOutputChars: 30_000,
|
||||
totalOutputChars: 0,
|
||||
pendingStdout: [],
|
||||
pendingStderr: [],
|
||||
pendingStdoutChars: 0,
|
||||
pendingStderrChars: 0,
|
||||
aggregated: "",
|
||||
tail: "",
|
||||
pid,
|
||||
exited: false,
|
||||
exitCode: undefined,
|
||||
exitSignal: undefined,
|
||||
truncated: false,
|
||||
backgrounded: true,
|
||||
};
|
||||
...(pid === undefined ? {} : { pid }),
|
||||
});
|
||||
}
|
||||
|
||||
describe("process tool supervisor cancellation", () => {
|
||||
|
||||
@@ -13,15 +13,40 @@ vi.mock("../media/image-ops.js", () => ({
|
||||
import "./test-helpers/fast-core-tools.js";
|
||||
import { createOpenClawTools } from "./openclaw-tools.js";
|
||||
|
||||
describe("nodes camera_snap", () => {
|
||||
beforeEach(() => {
|
||||
callGateway.mockReset();
|
||||
});
|
||||
const NODE_ID = "mac-1";
|
||||
const BASE_RUN_INPUT = { action: "run", node: NODE_ID, command: ["echo", "hi"] } as const;
|
||||
|
||||
function unexpectedGatewayMethod(method: unknown): never {
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
}
|
||||
|
||||
function getNodesTool() {
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) {
|
||||
throw new Error("missing nodes tool");
|
||||
}
|
||||
return tool;
|
||||
}
|
||||
|
||||
async function executeNodes(input: Record<string, unknown>) {
|
||||
return getNodesTool().execute("call1", input as never);
|
||||
}
|
||||
|
||||
function mockNodeList(commands?: string[]) {
|
||||
return {
|
||||
nodes: [{ nodeId: NODE_ID, ...(commands ? { commands } : {}) }],
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
callGateway.mockReset();
|
||||
});
|
||||
|
||||
describe("nodes camera_snap", () => {
|
||||
it("maps jpg payloads to image/jpeg", async () => {
|
||||
callGateway.mockImplementation(async ({ method }) => {
|
||||
if (method === "node.list") {
|
||||
return { nodes: [{ nodeId: "mac-1" }] };
|
||||
return mockNodeList();
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
return {
|
||||
@@ -33,17 +58,12 @@ describe("nodes camera_snap", () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) {
|
||||
throw new Error("missing nodes tool");
|
||||
}
|
||||
|
||||
const result = await tool.execute("call1", {
|
||||
const result = await executeNodes({
|
||||
action: "camera_snap",
|
||||
node: "mac-1",
|
||||
node: NODE_ID,
|
||||
facing: "front",
|
||||
});
|
||||
|
||||
@@ -55,7 +75,7 @@ describe("nodes camera_snap", () => {
|
||||
it("passes deviceId when provided", async () => {
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return { nodes: [{ nodeId: "mac-1" }] };
|
||||
return mockNodeList();
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
@@ -71,17 +91,12 @@ describe("nodes camera_snap", () => {
|
||||
},
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) {
|
||||
throw new Error("missing nodes tool");
|
||||
}
|
||||
|
||||
await tool.execute("call1", {
|
||||
await executeNodes({
|
||||
action: "camera_snap",
|
||||
node: "mac-1",
|
||||
node: NODE_ID,
|
||||
facing: "front",
|
||||
deviceId: "cam-123",
|
||||
});
|
||||
@@ -89,18 +104,14 @@ describe("nodes camera_snap", () => {
|
||||
});
|
||||
|
||||
describe("nodes run", () => {
|
||||
beforeEach(() => {
|
||||
callGateway.mockReset();
|
||||
});
|
||||
|
||||
it("passes invoke and command timeouts", async () => {
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return { nodes: [{ nodeId: "mac-1", commands: ["system.run"] }] };
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
expect(params).toMatchObject({
|
||||
nodeId: "mac-1",
|
||||
nodeId: NODE_ID,
|
||||
command: "system.run",
|
||||
timeoutMs: 45_000,
|
||||
params: {
|
||||
@@ -114,18 +125,11 @@ describe("nodes run", () => {
|
||||
payload: { stdout: "", stderr: "", exitCode: 0, success: true },
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) {
|
||||
throw new Error("missing nodes tool");
|
||||
}
|
||||
|
||||
await tool.execute("call1", {
|
||||
action: "run",
|
||||
node: "mac-1",
|
||||
command: ["echo", "hi"],
|
||||
await executeNodes({
|
||||
...BASE_RUN_INPUT,
|
||||
cwd: "/tmp",
|
||||
env: ["FOO=bar"],
|
||||
commandTimeoutMs: 12_000,
|
||||
@@ -138,7 +142,7 @@ describe("nodes run", () => {
|
||||
let approvalId: string | null = null;
|
||||
callGateway.mockImplementation(async ({ method, params }) => {
|
||||
if (method === "node.list") {
|
||||
return { nodes: [{ nodeId: "mac-1", commands: ["system.run"] }] };
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
invokeCalls += 1;
|
||||
@@ -146,7 +150,7 @@ describe("nodes run", () => {
|
||||
throw new Error("SYSTEM_RUN_DENIED: approval required");
|
||||
}
|
||||
expect(params).toMatchObject({
|
||||
nodeId: "mac-1",
|
||||
nodeId: NODE_ID,
|
||||
command: "system.run",
|
||||
params: {
|
||||
command: ["echo", "hi"],
|
||||
@@ -170,26 +174,17 @@ describe("nodes run", () => {
|
||||
: null;
|
||||
return { decision: "allow-once" };
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) {
|
||||
throw new Error("missing nodes tool");
|
||||
}
|
||||
|
||||
await tool.execute("call1", {
|
||||
action: "run",
|
||||
node: "mac-1",
|
||||
command: ["echo", "hi"],
|
||||
});
|
||||
await executeNodes(BASE_RUN_INPUT);
|
||||
expect(invokeCalls).toBe(2);
|
||||
});
|
||||
|
||||
it("fails with user denied when approval decision is deny", async () => {
|
||||
callGateway.mockImplementation(async ({ method }) => {
|
||||
if (method === "node.list") {
|
||||
return { nodes: [{ nodeId: "mac-1", commands: ["system.run"] }] };
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
throw new Error("SYSTEM_RUN_DENIED: approval required");
|
||||
@@ -197,32 +192,16 @@ describe("nodes run", () => {
|
||||
if (method === "exec.approval.request") {
|
||||
return { decision: "deny" };
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) {
|
||||
throw new Error("missing nodes tool");
|
||||
}
|
||||
|
||||
await expect(
|
||||
tool.execute("call1", {
|
||||
action: "run",
|
||||
node: "mac-1",
|
||||
command: ["echo", "hi"],
|
||||
}),
|
||||
).rejects.toThrow("exec denied: user denied");
|
||||
await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow("exec denied: user denied");
|
||||
});
|
||||
|
||||
it("fails closed for timeout and invalid approval decisions", async () => {
|
||||
const tool = createOpenClawTools().find((candidate) => candidate.name === "nodes");
|
||||
if (!tool) {
|
||||
throw new Error("missing nodes tool");
|
||||
}
|
||||
|
||||
callGateway.mockImplementation(async ({ method }) => {
|
||||
if (method === "node.list") {
|
||||
return { nodes: [{ nodeId: "mac-1", commands: ["system.run"] }] };
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
throw new Error("SYSTEM_RUN_DENIED: approval required");
|
||||
@@ -230,19 +209,13 @@ describe("nodes run", () => {
|
||||
if (method === "exec.approval.request") {
|
||||
return {};
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
await expect(
|
||||
tool.execute("call1", {
|
||||
action: "run",
|
||||
node: "mac-1",
|
||||
command: ["echo", "hi"],
|
||||
}),
|
||||
).rejects.toThrow("exec denied: approval timed out");
|
||||
await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow("exec denied: approval timed out");
|
||||
|
||||
callGateway.mockImplementation(async ({ method }) => {
|
||||
if (method === "node.list") {
|
||||
return { nodes: [{ nodeId: "mac-1", commands: ["system.run"] }] };
|
||||
return mockNodeList(["system.run"]);
|
||||
}
|
||||
if (method === "node.invoke") {
|
||||
throw new Error("SYSTEM_RUN_DENIED: approval required");
|
||||
@@ -250,14 +223,10 @@ describe("nodes run", () => {
|
||||
if (method === "exec.approval.request") {
|
||||
return { decision: "allow-never" };
|
||||
}
|
||||
throw new Error(`unexpected method: ${String(method)}`);
|
||||
return unexpectedGatewayMethod(method);
|
||||
});
|
||||
await expect(
|
||||
tool.execute("call1", {
|
||||
action: "run",
|
||||
node: "mac-1",
|
||||
command: ["echo", "hi"],
|
||||
}),
|
||||
).rejects.toThrow("exec denied: invalid approval decision");
|
||||
await expect(executeNodes(BASE_RUN_INPUT)).rejects.toThrow(
|
||||
"exec denied: invalid approval decision",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -85,6 +85,42 @@ function makeUser(text: string): AgentMessage {
|
||||
return { role: "user", content: text, timestamp: Date.now() };
|
||||
}
|
||||
|
||||
type ContextPruningSettings = NonNullable<ReturnType<typeof computeEffectiveSettings>>;
|
||||
type PruneArgs = Parameters<typeof pruneContextMessages>[0];
|
||||
type PruneOverrides = Omit<PruneArgs, "messages" | "settings" | "ctx">;
|
||||
|
||||
const CONTEXT_WINDOW_1000 = {
|
||||
model: { contextWindow: 1000 },
|
||||
} as unknown as ExtensionContext;
|
||||
|
||||
function makeAggressiveSettings(
|
||||
overrides: Partial<ContextPruningSettings> = {},
|
||||
): ContextPruningSettings {
|
||||
return {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
keepLastAssistants: 0,
|
||||
softTrimRatio: 0,
|
||||
hardClearRatio: 0,
|
||||
minPrunableToolChars: 0,
|
||||
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function pruneWithAggressiveDefaults(
|
||||
messages: AgentMessage[],
|
||||
settingsOverrides: Partial<ContextPruningSettings> = {},
|
||||
extra: PruneOverrides = {},
|
||||
): AgentMessage[] {
|
||||
return pruneContextMessages({
|
||||
messages,
|
||||
settings: makeAggressiveSettings(settingsOverrides),
|
||||
ctx: CONTEXT_WINDOW_1000,
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
type ContextHandler = (
|
||||
event: { messages: AgentMessage[] },
|
||||
ctx: ExtensionContext,
|
||||
@@ -157,21 +193,7 @@ describe("context-pruning", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const settings = {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
keepLastAssistants: 3,
|
||||
softTrimRatio: 0.0,
|
||||
hardClearRatio: 0.0,
|
||||
minPrunableToolChars: 0,
|
||||
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
model: { contextWindow: 1000 },
|
||||
} as unknown as ExtensionContext;
|
||||
|
||||
const next = pruneContextMessages({ messages, settings, ctx });
|
||||
const next = pruneWithAggressiveDefaults(messages, { keepLastAssistants: 3 });
|
||||
|
||||
expect(toolText(findToolResult(next, "t2"))).toContain("y".repeat(20_000));
|
||||
expect(toolText(findToolResult(next, "t3"))).toContain("z".repeat(20_000));
|
||||
@@ -180,16 +202,6 @@ describe("context-pruning", () => {
|
||||
});
|
||||
|
||||
it("never prunes tool results before the first user message", () => {
|
||||
const settings = {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
keepLastAssistants: 0,
|
||||
softTrimRatio: 0.0,
|
||||
hardClearRatio: 0.0,
|
||||
minPrunableToolChars: 0,
|
||||
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
|
||||
};
|
||||
|
||||
const messages: AgentMessage[] = [
|
||||
makeAssistant("bootstrap tool calls"),
|
||||
makeToolResult({
|
||||
@@ -206,13 +218,14 @@ describe("context-pruning", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const next = pruneContextMessages({
|
||||
const next = pruneWithAggressiveDefaults(
|
||||
messages,
|
||||
settings,
|
||||
ctx: { model: { contextWindow: 1000 } } as unknown as ExtensionContext,
|
||||
isToolPrunable: () => true,
|
||||
contextWindowTokensOverride: 1000,
|
||||
});
|
||||
{},
|
||||
{
|
||||
isToolPrunable: () => true,
|
||||
contextWindowTokensOverride: 1000,
|
||||
},
|
||||
);
|
||||
|
||||
expect(toolText(findToolResult(next, "t0"))).toBe("x".repeat(20_000));
|
||||
expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]");
|
||||
@@ -241,19 +254,11 @@ describe("context-pruning", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const settings = {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
const next = pruneWithAggressiveDefaults(messages, {
|
||||
keepLastAssistants: 1,
|
||||
softTrimRatio: 10.0,
|
||||
hardClearRatio: 0.0,
|
||||
minPrunableToolChars: 0,
|
||||
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
model: { contextWindow: 1000 },
|
||||
} as unknown as ExtensionContext;
|
||||
const next = pruneContextMessages({ messages, settings, ctx });
|
||||
softTrim: DEFAULT_CONTEXT_PRUNING_SETTINGS.softTrim,
|
||||
});
|
||||
|
||||
expect(toolText(findToolResult(next, "t1"))).toBe("[cleared]");
|
||||
expect(toolText(findToolResult(next, "t2"))).toBe("[cleared]");
|
||||
@@ -273,19 +278,9 @@ describe("context-pruning", () => {
|
||||
makeAssistant("a2"),
|
||||
];
|
||||
|
||||
const settings = {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
keepLastAssistants: 0,
|
||||
softTrimRatio: 0,
|
||||
hardClearRatio: 0,
|
||||
minPrunableToolChars: 0,
|
||||
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
|
||||
};
|
||||
|
||||
const next = pruneContextMessages({
|
||||
messages,
|
||||
settings,
|
||||
settings: makeAggressiveSettings(),
|
||||
ctx: { model: undefined } as unknown as ExtensionContext,
|
||||
contextWindowTokensOverride: 1000,
|
||||
});
|
||||
@@ -297,15 +292,7 @@ describe("context-pruning", () => {
|
||||
const sessionManager = {};
|
||||
|
||||
setContextPruningRuntime(sessionManager, {
|
||||
settings: {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
keepLastAssistants: 0,
|
||||
softTrimRatio: 0,
|
||||
hardClearRatio: 0,
|
||||
minPrunableToolChars: 0,
|
||||
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
|
||||
},
|
||||
settings: makeAggressiveSettings(),
|
||||
contextWindowTokens: 1000,
|
||||
isToolPrunable: () => true,
|
||||
lastCacheTouchAt: Date.now() - DEFAULT_CONTEXT_PRUNING_SETTINGS.ttlMs - 1000,
|
||||
@@ -336,15 +323,7 @@ describe("context-pruning", () => {
|
||||
const lastTouch = Date.now() - DEFAULT_CONTEXT_PRUNING_SETTINGS.ttlMs - 1000;
|
||||
|
||||
setContextPruningRuntime(sessionManager, {
|
||||
settings: {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
keepLastAssistants: 0,
|
||||
softTrimRatio: 0,
|
||||
hardClearRatio: 0,
|
||||
minPrunableToolChars: 0,
|
||||
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
|
||||
},
|
||||
settings: makeAggressiveSettings(),
|
||||
contextWindowTokens: 1000,
|
||||
isToolPrunable: () => true,
|
||||
lastCacheTouchAt: lastTouch,
|
||||
@@ -392,21 +371,9 @@ describe("context-pruning", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const settings = {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
keepLastAssistants: 0,
|
||||
softTrimRatio: 0.0,
|
||||
hardClearRatio: 0.0,
|
||||
minPrunableToolChars: 0,
|
||||
const next = pruneWithAggressiveDefaults(messages, {
|
||||
tools: { allow: ["ex*"], deny: ["exec"] },
|
||||
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
model: { contextWindow: 1000 },
|
||||
} as unknown as ExtensionContext;
|
||||
const next = pruneContextMessages({ messages, settings, ctx });
|
||||
});
|
||||
|
||||
// Deny wins => exec is not pruned, even though allow matches.
|
||||
expect(toolText(findToolResult(next, "t1"))).toContain("x".repeat(20_000));
|
||||
@@ -424,20 +391,7 @@ describe("context-pruning", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const settings = {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
keepLastAssistants: 0,
|
||||
softTrimRatio: 0.0,
|
||||
hardClearRatio: 0.0,
|
||||
minPrunableToolChars: 0,
|
||||
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||
softTrim: { maxChars: 10, headChars: 3, tailChars: 3 },
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
model: { contextWindow: 1000 },
|
||||
} as unknown as ExtensionContext;
|
||||
const next = pruneContextMessages({ messages, settings, ctx });
|
||||
const next = pruneWithAggressiveDefaults(messages);
|
||||
|
||||
const tool = findToolResult(next, "t1");
|
||||
if (!tool || tool.role !== "toolResult") {
|
||||
@@ -463,18 +417,10 @@ describe("context-pruning", () => {
|
||||
} as unknown as AgentMessage,
|
||||
];
|
||||
|
||||
const settings = {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
keepLastAssistants: 0,
|
||||
softTrimRatio: 0.0,
|
||||
const next = pruneWithAggressiveDefaults(messages, {
|
||||
hardClearRatio: 10.0,
|
||||
softTrim: { maxChars: 5, headChars: 7, tailChars: 3 },
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
model: { contextWindow: 1000 },
|
||||
} as unknown as ExtensionContext;
|
||||
const next = pruneContextMessages({ messages, settings, ctx });
|
||||
});
|
||||
|
||||
const text = toolText(findToolResult(next, "t1"));
|
||||
expect(text).toContain("AAAAA\nB");
|
||||
@@ -492,20 +438,10 @@ describe("context-pruning", () => {
|
||||
}),
|
||||
];
|
||||
|
||||
const settings = {
|
||||
...DEFAULT_CONTEXT_PRUNING_SETTINGS,
|
||||
keepLastAssistants: 0,
|
||||
softTrimRatio: 0.0,
|
||||
const next = pruneWithAggressiveDefaults(messages, {
|
||||
hardClearRatio: 10.0,
|
||||
minPrunableToolChars: 0,
|
||||
hardClear: { enabled: true, placeholder: "[cleared]" },
|
||||
softTrim: { maxChars: 10, headChars: 6, tailChars: 6 },
|
||||
};
|
||||
|
||||
const ctx = {
|
||||
model: { contextWindow: 1000 },
|
||||
} as unknown as ExtensionContext;
|
||||
const next = pruneContextMessages({ messages, settings, ctx });
|
||||
});
|
||||
|
||||
const tool = findToolResult(next, "t1");
|
||||
const text = toolText(tool);
|
||||
|
||||
@@ -17,19 +17,27 @@ beforeAll(async () => {
|
||||
|
||||
installTriggerHandlingE2eTestHooks();
|
||||
|
||||
function mockSuccessfulCompaction() {
|
||||
getCompactEmbeddedPiSessionMock().mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "summary",
|
||||
firstKeptEntryId: "x",
|
||||
tokensBefore: 12000,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function replyText(res: Awaited<ReturnType<typeof getReplyFromConfig>>) {
|
||||
return Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
}
|
||||
|
||||
describe("trigger handling", () => {
|
||||
it("runs /compact as a gated command", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
const storePath = join(tmpdir(), `openclaw-session-test-${Date.now()}.json`);
|
||||
getCompactEmbeddedPiSessionMock().mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "summary",
|
||||
firstKeptEntryId: "x",
|
||||
tokensBefore: 12000,
|
||||
},
|
||||
});
|
||||
mockSuccessfulCompaction();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
@@ -56,7 +64,7 @@ describe("trigger handling", () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const text = replyText(res);
|
||||
expect(text?.startsWith("⚙️ Compacted")).toBe(true);
|
||||
expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce();
|
||||
expect(getRunEmbeddedPiAgentMock()).not.toHaveBeenCalled();
|
||||
@@ -72,15 +80,7 @@ describe("trigger handling", () => {
|
||||
it("runs /compact for non-default agents without transcript path validation failures", async () => {
|
||||
await withTempHome(async (home) => {
|
||||
getCompactEmbeddedPiSessionMock().mockClear();
|
||||
getCompactEmbeddedPiSessionMock().mockResolvedValue({
|
||||
ok: true,
|
||||
compacted: true,
|
||||
result: {
|
||||
summary: "summary",
|
||||
firstKeptEntryId: "x",
|
||||
tokensBefore: 12000,
|
||||
},
|
||||
});
|
||||
mockSuccessfulCompaction();
|
||||
|
||||
const res = await getReplyFromConfig(
|
||||
{
|
||||
@@ -94,7 +94,7 @@ describe("trigger handling", () => {
|
||||
makeCfg(home),
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const text = replyText(res);
|
||||
expect(text?.startsWith("⚙️ Compacted")).toBe(true);
|
||||
expect(getCompactEmbeddedPiSessionMock()).toHaveBeenCalledOnce();
|
||||
expect(getCompactEmbeddedPiSessionMock().mock.calls[0]?.[0]?.sessionFile).toContain(
|
||||
@@ -129,7 +129,7 @@ describe("trigger handling", () => {
|
||||
makeCfg(home),
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const text = replyText(res);
|
||||
expect(text).toBe("ok");
|
||||
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
|
||||
const prompt = getRunEmbeddedPiAgentMock().mock.calls[0]?.[0]?.prompt ?? "";
|
||||
@@ -158,7 +158,7 @@ describe("trigger handling", () => {
|
||||
makeCfg(home),
|
||||
);
|
||||
|
||||
const text = Array.isArray(res) ? res[0]?.text : res?.text;
|
||||
const text = replyText(res);
|
||||
expect(text).toBe("ok");
|
||||
expect(text).not.toMatch(/Thinking level set/i);
|
||||
expect(getRunEmbeddedPiAgentMock()).toHaveBeenCalledOnce();
|
||||
|
||||
@@ -48,6 +48,20 @@ function makeState(
|
||||
};
|
||||
}
|
||||
|
||||
function makeUnexpectedFetchMock() {
|
||||
return vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
}
|
||||
|
||||
function createRemoteRouteHarness(fetchMock?: ReturnType<typeof vi.fn>) {
|
||||
const activeFetchMock = fetchMock ?? makeUnexpectedFetchMock();
|
||||
global.fetch = withFetchPreconnect(activeFetchMock);
|
||||
const state = makeState("remote");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
return { state, remote: ctx.forProfile("remote"), fetchMock: activeFetchMock };
|
||||
}
|
||||
|
||||
describe("browser server-context remote profile tab operations", () => {
|
||||
it("uses Playwright tab operations when available", async () => {
|
||||
const listPagesViaPlaywright = vi.fn(async () => [
|
||||
@@ -67,15 +81,7 @@ describe("browser server-context remote profile tab operations", () => {
|
||||
closePageByTargetIdViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
|
||||
const state = makeState("remote");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const remote = ctx.forProfile("remote");
|
||||
const { state, remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
const tabs = await remote.listTabs();
|
||||
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
|
||||
@@ -132,15 +138,7 @@ describe("browser server-context remote profile tab operations", () => {
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
|
||||
const state = makeState("remote");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const remote = ctx.forProfile("remote");
|
||||
const { remote } = createRemoteRouteHarness();
|
||||
|
||||
const first = await remote.ensureTabAvailable();
|
||||
expect(first.targetId).toBe("A");
|
||||
@@ -159,15 +157,7 @@ describe("browser server-context remote profile tab operations", () => {
|
||||
focusPageByTargetIdViaPlaywright,
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
|
||||
const state = makeState("remote");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const remote = ctx.forProfile("remote");
|
||||
const { state, remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
await remote.focusTab("T1");
|
||||
expect(focusPageByTargetIdViaPlaywright).toHaveBeenCalledWith({
|
||||
@@ -185,15 +175,7 @@ describe("browser server-context remote profile tab operations", () => {
|
||||
}),
|
||||
} as unknown as Awaited<ReturnType<typeof pwAiModule.getPwAiModule>>);
|
||||
|
||||
const fetchMock = vi.fn(async () => {
|
||||
throw new Error("unexpected fetch");
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
|
||||
const state = makeState("remote");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const remote = ctx.forProfile("remote");
|
||||
const { remote, fetchMock } = createRemoteRouteHarness();
|
||||
|
||||
await expect(remote.listTabs()).rejects.toThrow(/boom/);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
@@ -221,11 +203,7 @@ describe("browser server-context remote profile tab operations", () => {
|
||||
} as unknown as Response;
|
||||
});
|
||||
|
||||
global.fetch = withFetchPreconnect(fetchMock);
|
||||
|
||||
const state = makeState("remote");
|
||||
const ctx = createBrowserRouteContext({ getState: () => state });
|
||||
const remote = ctx.forProfile("remote");
|
||||
const { remote } = createRemoteRouteHarness(fetchMock);
|
||||
|
||||
const tabs = await remote.listTabs();
|
||||
expect(tabs.map((t) => t.targetId)).toEqual(["T1"]);
|
||||
|
||||
@@ -94,6 +94,52 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
return withTempHeartbeatSandbox(fn, { unsetEnvVars: ["TELEGRAM_BOT_TOKEN"] });
|
||||
}
|
||||
|
||||
function createMessageSendSpy(extra: Record<string, unknown> = {}) {
|
||||
return vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
...extra,
|
||||
});
|
||||
}
|
||||
|
||||
async function runTelegramHeartbeatWithDefaults(params: {
|
||||
tmpDir: string;
|
||||
storePath: string;
|
||||
replySpy: ReturnType<typeof vi.spyOn>;
|
||||
replyText: string;
|
||||
messages?: Record<string, unknown>;
|
||||
telegramOverrides?: Record<string, unknown>;
|
||||
}) {
|
||||
const cfg = createHeartbeatConfig({
|
||||
tmpDir: params.tmpDir,
|
||||
storePath: params.storePath,
|
||||
heartbeat: { every: "5m", target: "telegram" },
|
||||
channels: {
|
||||
telegram: {
|
||||
token: "test-token",
|
||||
allowFrom: ["*"],
|
||||
heartbeat: { showOk: false },
|
||||
...params.telegramOverrides,
|
||||
},
|
||||
},
|
||||
...(params.messages ? { messages: params.messages } : {}),
|
||||
});
|
||||
|
||||
await seedMainSession(params.storePath, cfg, {
|
||||
lastChannel: "telegram",
|
||||
lastProvider: "telegram",
|
||||
lastTo: "12345",
|
||||
});
|
||||
|
||||
params.replySpy.mockResolvedValue({ text: params.replyText });
|
||||
const sendTelegram = createMessageSendSpy();
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: makeTelegramDeps({ sendTelegram }),
|
||||
});
|
||||
return sendTelegram;
|
||||
}
|
||||
|
||||
it("respects ackMaxChars for heartbeat acks", async () => {
|
||||
await withTempHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const cfg = createHeartbeatConfig({
|
||||
@@ -114,10 +160,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
});
|
||||
|
||||
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK 🦞" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
const sendWhatsApp = createMessageSendSpy();
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
@@ -147,10 +190,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
});
|
||||
|
||||
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
const sendWhatsApp = createMessageSendSpy();
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
@@ -164,37 +204,11 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
|
||||
it("does not deliver HEARTBEAT_OK to telegram when showOk is false", async () => {
|
||||
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const cfg = createHeartbeatConfig({
|
||||
const sendTelegram = await runTelegramHeartbeatWithDefaults({
|
||||
tmpDir,
|
||||
storePath,
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "telegram",
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
token: "test-token",
|
||||
allowFrom: ["*"],
|
||||
heartbeat: { showOk: false },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await seedMainSession(storePath, cfg, {
|
||||
lastChannel: "telegram",
|
||||
lastProvider: "telegram",
|
||||
lastTo: "12345",
|
||||
});
|
||||
|
||||
replySpy.mockResolvedValue({ text: "HEARTBEAT_OK" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: makeTelegramDeps({ sendTelegram }),
|
||||
replySpy,
|
||||
replyText: "HEARTBEAT_OK",
|
||||
});
|
||||
|
||||
expect(sendTelegram).not.toHaveBeenCalled();
|
||||
@@ -203,80 +217,28 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
|
||||
it("strips responsePrefix before HEARTBEAT_OK detection and suppresses short ack text", async () => {
|
||||
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const cfg = createHeartbeatConfig({
|
||||
const sendTelegram = await runTelegramHeartbeatWithDefaults({
|
||||
tmpDir,
|
||||
storePath,
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "telegram",
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
token: "test-token",
|
||||
allowFrom: ["*"],
|
||||
heartbeat: { showOk: false },
|
||||
},
|
||||
},
|
||||
replySpy,
|
||||
replyText: "[openclaw] HEARTBEAT_OK all good",
|
||||
messages: { responsePrefix: "[openclaw]" },
|
||||
});
|
||||
|
||||
await seedMainSession(storePath, cfg, {
|
||||
lastChannel: "telegram",
|
||||
lastProvider: "telegram",
|
||||
lastTo: "12345",
|
||||
});
|
||||
|
||||
replySpy.mockResolvedValue({ text: "[openclaw] HEARTBEAT_OK all good" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: makeTelegramDeps({ sendTelegram }),
|
||||
});
|
||||
|
||||
expect(sendTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it("does not strip alphanumeric responsePrefix from larger words", async () => {
|
||||
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
|
||||
const cfg = createHeartbeatConfig({
|
||||
const sendTelegram = await runTelegramHeartbeatWithDefaults({
|
||||
tmpDir,
|
||||
storePath,
|
||||
heartbeat: {
|
||||
every: "5m",
|
||||
target: "telegram",
|
||||
},
|
||||
channels: {
|
||||
telegram: {
|
||||
token: "test-token",
|
||||
allowFrom: ["*"],
|
||||
heartbeat: { showOk: false },
|
||||
},
|
||||
},
|
||||
replySpy,
|
||||
replyText: "History check complete",
|
||||
messages: { responsePrefix: "Hi" },
|
||||
});
|
||||
|
||||
await seedMainSession(storePath, cfg, {
|
||||
lastChannel: "telegram",
|
||||
lastProvider: "telegram",
|
||||
lastTo: "12345",
|
||||
});
|
||||
|
||||
replySpy.mockResolvedValue({ text: "History check complete" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
deps: makeTelegramDeps({ sendTelegram }),
|
||||
});
|
||||
|
||||
expect(sendTelegram).toHaveBeenCalledTimes(1);
|
||||
expect(sendTelegram).toHaveBeenCalledWith(
|
||||
"12345",
|
||||
@@ -309,10 +271,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
lastTo: "+1555",
|
||||
});
|
||||
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
const sendWhatsApp = createMessageSendSpy();
|
||||
|
||||
const result = await runHeartbeatOnce({
|
||||
cfg,
|
||||
@@ -344,10 +303,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
});
|
||||
|
||||
replySpy.mockResolvedValue({ text: "<b>HEARTBEAT_OK</b>" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
const sendWhatsApp = createMessageSendSpy();
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
@@ -420,10 +376,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
});
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Heartbeat alert" });
|
||||
const sendWhatsApp = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
toJid: "jid",
|
||||
});
|
||||
const sendWhatsApp = createMessageSendSpy();
|
||||
|
||||
const res = await runHeartbeatOnce({
|
||||
cfg,
|
||||
@@ -459,10 +412,7 @@ describe("resolveHeartbeatIntervalMs", () => {
|
||||
});
|
||||
|
||||
replySpy.mockResolvedValue({ text: "Hello from heartbeat" });
|
||||
const sendTelegram = vi.fn().mockResolvedValue({
|
||||
messageId: "m1",
|
||||
chatId: "123456",
|
||||
});
|
||||
const sendTelegram = createMessageSendSpy({ chatId: "123456" });
|
||||
|
||||
await runHeartbeatOnce({
|
||||
cfg,
|
||||
|
||||
@@ -86,6 +86,27 @@ async function createVoiceCallArchive(params: {
|
||||
return { pkgDir, archivePath };
|
||||
}
|
||||
|
||||
async function setupVoiceCallArchiveInstall(params: { outName: string; version: string }) {
|
||||
const stateDir = makeTempDir();
|
||||
const workDir = makeTempDir();
|
||||
const { archivePath } = await createVoiceCallArchive({
|
||||
workDir,
|
||||
outName: params.outName,
|
||||
version: params.version,
|
||||
});
|
||||
return {
|
||||
stateDir,
|
||||
archivePath,
|
||||
extensionsDir: path.join(stateDir, "extensions"),
|
||||
};
|
||||
}
|
||||
|
||||
function expectPluginFiles(result: { targetDir: string }, stateDir: string, pluginId: string) {
|
||||
expect(result.targetDir).toBe(path.join(stateDir, "extensions", pluginId));
|
||||
expect(fs.existsSync(path.join(result.targetDir, "package.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(result.targetDir, "dist", "index.js"))).toBe(true);
|
||||
}
|
||||
|
||||
function setupPluginInstallDirs() {
|
||||
const tmpDir = makeTempDir();
|
||||
const pluginDir = path.join(tmpDir, "plugin-src");
|
||||
@@ -164,15 +185,11 @@ beforeEach(() => {
|
||||
|
||||
describe("installPluginFromArchive", () => {
|
||||
it("installs into ~/.openclaw/extensions and uses unscoped id", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const workDir = makeTempDir();
|
||||
const { archivePath } = await createVoiceCallArchive({
|
||||
workDir,
|
||||
const { stateDir, archivePath, extensionsDir } = await setupVoiceCallArchiveInstall({
|
||||
outName: "plugin.tgz",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
const { installPluginFromArchive } = await import("./install.js");
|
||||
const result = await installPluginFromArchive({
|
||||
archivePath,
|
||||
@@ -183,21 +200,15 @@ describe("installPluginFromArchive", () => {
|
||||
return;
|
||||
}
|
||||
expect(result.pluginId).toBe("voice-call");
|
||||
expect(result.targetDir).toBe(path.join(stateDir, "extensions", "voice-call"));
|
||||
expect(fs.existsSync(path.join(result.targetDir, "package.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(result.targetDir, "dist", "index.js"))).toBe(true);
|
||||
expectPluginFiles(result, stateDir, "voice-call");
|
||||
});
|
||||
|
||||
it("rejects installing when plugin already exists", async () => {
|
||||
const stateDir = makeTempDir();
|
||||
const workDir = makeTempDir();
|
||||
const { archivePath } = await createVoiceCallArchive({
|
||||
workDir,
|
||||
const { archivePath, extensionsDir } = await setupVoiceCallArchiveInstall({
|
||||
outName: "plugin.tgz",
|
||||
version: "0.0.1",
|
||||
});
|
||||
|
||||
const extensionsDir = path.join(stateDir, "extensions");
|
||||
const { installPluginFromArchive } = await import("./install.js");
|
||||
const first = await installPluginFromArchive({
|
||||
archivePath,
|
||||
@@ -246,9 +257,7 @@ describe("installPluginFromArchive", () => {
|
||||
return;
|
||||
}
|
||||
expect(result.pluginId).toBe("zipper");
|
||||
expect(result.targetDir).toBe(path.join(stateDir, "extensions", "zipper"));
|
||||
expect(fs.existsSync(path.join(result.targetDir, "package.json"))).toBe(true);
|
||||
expect(fs.existsSync(path.join(result.targetDir, "dist", "index.js"))).toBe(true);
|
||||
expectPluginFiles(result, stateDir, "zipper");
|
||||
});
|
||||
|
||||
it("allows updates when mode is update", async () => {
|
||||
|
||||
@@ -515,191 +515,158 @@ describe("backward compatibility: peer.kind dm → direct", () => {
|
||||
});
|
||||
|
||||
describe("role-based agent routing", () => {
|
||||
test("guild+roles binding matches when member has matching role", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }],
|
||||
type DiscordBinding = NonNullable<OpenClawConfig["bindings"]>[number];
|
||||
|
||||
function makeDiscordRoleBinding(
|
||||
agentId: string,
|
||||
params: {
|
||||
roles?: string[];
|
||||
peerId?: string;
|
||||
includeGuildId?: boolean;
|
||||
} = {},
|
||||
): DiscordBinding {
|
||||
return {
|
||||
agentId,
|
||||
match: {
|
||||
channel: "discord",
|
||||
...(params.includeGuildId === false ? {} : { guildId: "g1" }),
|
||||
...(params.roles !== undefined ? { roles: params.roles } : {}),
|
||||
...(params.peerId ? { peer: { kind: "channel", id: params.peerId } } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function expectDiscordRoleRoute(params: {
|
||||
bindings: DiscordBinding[];
|
||||
memberRoleIds?: string[];
|
||||
peerId?: string;
|
||||
parentPeerId?: string;
|
||||
expectedAgentId: string;
|
||||
expectedMatchedBy: string;
|
||||
}) {
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
cfg: { bindings: params.bindings },
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
memberRoleIds: ["r1"],
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
...(params.memberRoleIds ? { memberRoleIds: params.memberRoleIds } : {}),
|
||||
peer: { kind: "channel", id: params.peerId ?? "c1" },
|
||||
...(params.parentPeerId
|
||||
? {
|
||||
parentPeer: { kind: "channel", id: params.parentPeerId },
|
||||
}
|
||||
: {}),
|
||||
});
|
||||
expect(route.agentId).toBe(params.expectedAgentId);
|
||||
expect(route.matchedBy).toBe(params.expectedMatchedBy);
|
||||
}
|
||||
|
||||
test("guild+roles binding matches when member has matching role", () => {
|
||||
expectDiscordRoleRoute({
|
||||
bindings: [makeDiscordRoleBinding("opus", { roles: ["r1"] })],
|
||||
memberRoleIds: ["r1"],
|
||||
expectedAgentId: "opus",
|
||||
expectedMatchedBy: "binding.guild+roles",
|
||||
});
|
||||
expect(route.agentId).toBe("opus");
|
||||
expect(route.matchedBy).toBe("binding.guild+roles");
|
||||
});
|
||||
|
||||
test("guild+roles binding skipped when no matching role", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
expectDiscordRoleRoute({
|
||||
bindings: [makeDiscordRoleBinding("opus", { roles: ["r1"] })],
|
||||
memberRoleIds: ["r2"],
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
expectedAgentId: "main",
|
||||
expectedMatchedBy: "default",
|
||||
});
|
||||
expect(route.agentId).toBe("main");
|
||||
expect(route.matchedBy).toBe("default");
|
||||
});
|
||||
|
||||
test("guild+roles is more specific than guild-only", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
expectDiscordRoleRoute({
|
||||
bindings: [
|
||||
{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } },
|
||||
{ agentId: "sonnet", match: { channel: "discord", guildId: "g1" } },
|
||||
makeDiscordRoleBinding("opus", { roles: ["r1"] }),
|
||||
makeDiscordRoleBinding("sonnet"),
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
memberRoleIds: ["r1"],
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
expectedAgentId: "opus",
|
||||
expectedMatchedBy: "binding.guild+roles",
|
||||
});
|
||||
expect(route.agentId).toBe("opus");
|
||||
expect(route.matchedBy).toBe("binding.guild+roles");
|
||||
});
|
||||
|
||||
test("peer binding still beats guild+roles", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
expectDiscordRoleRoute({
|
||||
bindings: [
|
||||
{
|
||||
agentId: "peer-agent",
|
||||
match: { channel: "discord", peer: { kind: "channel", id: "c1" } },
|
||||
},
|
||||
{ agentId: "roles-agent", match: { channel: "discord", guildId: "g1", roles: ["r1"] } },
|
||||
makeDiscordRoleBinding("peer-agent", { peerId: "c1", includeGuildId: false }),
|
||||
makeDiscordRoleBinding("roles-agent", { roles: ["r1"] }),
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
memberRoleIds: ["r1"],
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
expectedAgentId: "peer-agent",
|
||||
expectedMatchedBy: "binding.peer",
|
||||
});
|
||||
expect(route.agentId).toBe("peer-agent");
|
||||
expect(route.matchedBy).toBe("binding.peer");
|
||||
});
|
||||
|
||||
test("parent peer binding still beats guild+roles", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
expectDiscordRoleRoute({
|
||||
bindings: [
|
||||
{
|
||||
agentId: "parent-agent",
|
||||
match: { channel: "discord", peer: { kind: "channel", id: "parent-1" } },
|
||||
},
|
||||
{ agentId: "roles-agent", match: { channel: "discord", guildId: "g1", roles: ["r1"] } },
|
||||
makeDiscordRoleBinding("parent-agent", {
|
||||
peerId: "parent-1",
|
||||
includeGuildId: false,
|
||||
}),
|
||||
makeDiscordRoleBinding("roles-agent", { roles: ["r1"] }),
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
memberRoleIds: ["r1"],
|
||||
peer: { kind: "channel", id: "thread-1" },
|
||||
parentPeer: { kind: "channel", id: "parent-1" },
|
||||
peerId: "thread-1",
|
||||
parentPeerId: "parent-1",
|
||||
expectedAgentId: "parent-agent",
|
||||
expectedMatchedBy: "binding.peer.parent",
|
||||
});
|
||||
expect(route.agentId).toBe("parent-agent");
|
||||
expect(route.matchedBy).toBe("binding.peer.parent");
|
||||
});
|
||||
|
||||
test("no memberRoleIds means guild+roles doesn't match", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } }],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
expectDiscordRoleRoute({
|
||||
bindings: [makeDiscordRoleBinding("opus", { roles: ["r1"] })],
|
||||
expectedAgentId: "main",
|
||||
expectedMatchedBy: "default",
|
||||
});
|
||||
expect(route.agentId).toBe("main");
|
||||
expect(route.matchedBy).toBe("default");
|
||||
});
|
||||
|
||||
test("first matching binding wins with multiple role bindings", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
expectDiscordRoleRoute({
|
||||
bindings: [
|
||||
{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["r1"] } },
|
||||
{ agentId: "sonnet", match: { channel: "discord", guildId: "g1", roles: ["r2"] } },
|
||||
makeDiscordRoleBinding("opus", { roles: ["r1"] }),
|
||||
makeDiscordRoleBinding("sonnet", { roles: ["r2"] }),
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
memberRoleIds: ["r1", "r2"],
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
expectedAgentId: "opus",
|
||||
expectedMatchedBy: "binding.guild+roles",
|
||||
});
|
||||
expect(route.agentId).toBe("opus");
|
||||
expect(route.matchedBy).toBe("binding.guild+roles");
|
||||
});
|
||||
|
||||
test("empty roles array treated as no role restriction", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: [] } }],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
expectDiscordRoleRoute({
|
||||
bindings: [makeDiscordRoleBinding("opus", { roles: [] })],
|
||||
memberRoleIds: ["r1"],
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
expectedAgentId: "opus",
|
||||
expectedMatchedBy: "binding.guild",
|
||||
});
|
||||
expect(route.agentId).toBe("opus");
|
||||
expect(route.matchedBy).toBe("binding.guild");
|
||||
});
|
||||
|
||||
test("guild+roles binding does not match as guild-only when roles do not match", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
bindings: [
|
||||
{ agentId: "opus", match: { channel: "discord", guildId: "g1", roles: ["admin"] } },
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
expectDiscordRoleRoute({
|
||||
bindings: [makeDiscordRoleBinding("opus", { roles: ["admin"] })],
|
||||
memberRoleIds: ["regular"],
|
||||
peer: { kind: "channel", id: "c1" },
|
||||
expectedAgentId: "main",
|
||||
expectedMatchedBy: "default",
|
||||
});
|
||||
expect(route.agentId).toBe("main");
|
||||
expect(route.matchedBy).toBe("default");
|
||||
});
|
||||
|
||||
test("peer+guild+roles binding does not act as guild+roles fallback when peer mismatches", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
expectDiscordRoleRoute({
|
||||
bindings: [
|
||||
{
|
||||
agentId: "peer-roles",
|
||||
match: {
|
||||
channel: "discord",
|
||||
peer: { kind: "channel", id: "c-target" },
|
||||
guildId: "g1",
|
||||
roles: ["r1"],
|
||||
},
|
||||
},
|
||||
{
|
||||
agentId: "guild-roles",
|
||||
match: {
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
roles: ["r1"],
|
||||
},
|
||||
},
|
||||
makeDiscordRoleBinding("peer-roles", { peerId: "c-target", roles: ["r1"] }),
|
||||
makeDiscordRoleBinding("guild-roles", { roles: ["r1"] }),
|
||||
],
|
||||
};
|
||||
const route = resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "discord",
|
||||
guildId: "g1",
|
||||
memberRoleIds: ["r1"],
|
||||
peer: { kind: "channel", id: "c-other" },
|
||||
peerId: "c-other",
|
||||
expectedAgentId: "guild-roles",
|
||||
expectedMatchedBy: "binding.guild+roles",
|
||||
});
|
||||
expect(route.agentId).toBe("guild-roles");
|
||||
expect(route.matchedBy).toBe("binding.guild+roles");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,19 +1,26 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createEditorSubmitHandler } from "./tui.js";
|
||||
|
||||
function createSubmitHarness() {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
const handleCommand = vi.fn();
|
||||
const sendMessage = vi.fn();
|
||||
const handleBangLine = vi.fn();
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand,
|
||||
sendMessage,
|
||||
handleBangLine,
|
||||
});
|
||||
return { editor, handleCommand, sendMessage, handleBangLine, handler };
|
||||
}
|
||||
|
||||
describe("createEditorSubmitHandler", () => {
|
||||
it("adds submitted messages to editor history", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
handleBangLine: vi.fn(),
|
||||
});
|
||||
const { editor, handler } = createSubmitHarness();
|
||||
|
||||
handler("hello world");
|
||||
|
||||
@@ -22,17 +29,7 @@ describe("createEditorSubmitHandler", () => {
|
||||
});
|
||||
|
||||
it("trims input before adding to history", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
handleBangLine: vi.fn(),
|
||||
});
|
||||
const { editor, handler } = createSubmitHarness();
|
||||
|
||||
handler(" hi ");
|
||||
|
||||
@@ -40,17 +37,7 @@ describe("createEditorSubmitHandler", () => {
|
||||
});
|
||||
|
||||
it("does not add empty-string submissions to history", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
handleBangLine: vi.fn(),
|
||||
});
|
||||
const { editor, handler } = createSubmitHarness();
|
||||
|
||||
handler("");
|
||||
|
||||
@@ -58,17 +45,7 @@ describe("createEditorSubmitHandler", () => {
|
||||
});
|
||||
|
||||
it("does not add whitespace-only submissions to history", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
handleBangLine: vi.fn(),
|
||||
});
|
||||
const { editor, handler } = createSubmitHarness();
|
||||
|
||||
handler(" ");
|
||||
|
||||
@@ -76,19 +53,7 @@ describe("createEditorSubmitHandler", () => {
|
||||
});
|
||||
|
||||
it("routes slash commands to handleCommand", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
const handleCommand = vi.fn();
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand,
|
||||
sendMessage,
|
||||
handleBangLine: vi.fn(),
|
||||
});
|
||||
const { editor, handleCommand, sendMessage, handler } = createSubmitHarness();
|
||||
|
||||
handler("/models");
|
||||
|
||||
@@ -98,19 +63,7 @@ describe("createEditorSubmitHandler", () => {
|
||||
});
|
||||
|
||||
it("routes normal messages to sendMessage", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
const handleCommand = vi.fn();
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand,
|
||||
sendMessage,
|
||||
handleBangLine: vi.fn(),
|
||||
});
|
||||
const { editor, handleCommand, sendMessage, handler } = createSubmitHarness();
|
||||
|
||||
handler("hello");
|
||||
|
||||
@@ -120,18 +73,7 @@ describe("createEditorSubmitHandler", () => {
|
||||
});
|
||||
|
||||
it("routes bang-prefixed lines to handleBangLine", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
const handleBangLine = vi.fn();
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand: vi.fn(),
|
||||
sendMessage: vi.fn(),
|
||||
handleBangLine,
|
||||
});
|
||||
const { handleBangLine, handler } = createSubmitHarness();
|
||||
|
||||
handler("!ls");
|
||||
|
||||
@@ -139,18 +81,7 @@ describe("createEditorSubmitHandler", () => {
|
||||
});
|
||||
|
||||
it("treats a lone ! as a normal message", () => {
|
||||
const editor = {
|
||||
setText: vi.fn(),
|
||||
addToHistory: vi.fn(),
|
||||
};
|
||||
const sendMessage = vi.fn();
|
||||
|
||||
const handler = createEditorSubmitHandler({
|
||||
editor,
|
||||
handleCommand: vi.fn(),
|
||||
sendMessage,
|
||||
handleBangLine: vi.fn(),
|
||||
});
|
||||
const { sendMessage, handler } = createSubmitHarness();
|
||||
|
||||
handler("!");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user