refactor(test): dedupe agent harnesses and routing fixtures

This commit is contained in:
Peter Steinberger
2026-02-18 04:48:40 +00:00
parent 8a9fddedc9
commit 31f83c86b2
12 changed files with 440 additions and 755 deletions

View File

@@ -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");

View 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;
}

View File

@@ -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 };
}

View File

@@ -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", () => {

View File

@@ -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",
);
});
});

View File

@@ -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);

View File

@@ -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();

View File

@@ -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"]);

View File

@@ -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,

View File

@@ -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 () => {

View File

@@ -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");
});
});

View File

@@ -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("!");