From 31f83c86b2cfe5552fda00786a088cb16df38c35 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 18 Feb 2026 04:48:40 +0000 Subject: [PATCH] refactor(test): dedupe agent harnesses and routing fixtures --- src/agents/bash-process-registry.e2e.test.ts | 117 +++------- .../bash-process-registry.test-helpers.ts | 42 ++++ .../bash-tools.process.poll-timeout.test.ts | 31 +-- .../bash-tools.process.supervisor.test.ts | 25 +- src/agents/openclaw-tools.camera.e2e.test.ts | 145 +++++------- .../pi-extensions/context-pruning.e2e.test.ts | 178 +++++--------- ....runs-compact-as-gated-command.e2e.test.ts | 44 ++-- .../server-context.remote-tab-ops.test.ts | 60 ++--- ...espects-ackmaxchars-heartbeat-acks.test.ts | 172 +++++--------- src/plugins/install.e2e.test.ts | 41 ++-- src/routing/resolve-route.test.ts | 221 ++++++++---------- src/tui/tui-input-history.test.ts | 119 ++-------- 12 files changed, 440 insertions(+), 755 deletions(-) create mode 100644 src/agents/bash-process-registry.test-helpers.ts diff --git a/src/agents/bash-process-registry.e2e.test.ts b/src/agents/bash-process-registry.e2e.test.ts index 07e816e4454..29f47a3f6c3 100644 --- a/src/agents/bash-process-registry.e2e.test.ts +++ b/src/agents/bash-process-registry.e2e.test.ts @@ -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"); diff --git a/src/agents/bash-process-registry.test-helpers.ts b/src/agents/bash-process-registry.test-helpers.ts new file mode 100644 index 00000000000..8ed57635c1d --- /dev/null +++ b/src/agents/bash-process-registry.test-helpers.ts @@ -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; +} diff --git a/src/agents/bash-tools.process.poll-timeout.test.ts b/src/agents/bash-tools.process.poll-timeout.test.ts index 3e0aa2b802c..00482a9c1e0 100644 --- a/src/agents/bash-tools.process.poll-timeout.test.ts +++ b/src/agents/bash-tools.process.poll-timeout.test.ts @@ -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 }; } diff --git a/src/agents/bash-tools.process.supervisor.test.ts b/src/agents/bash-tools.process.supervisor.test.ts index e6d026595f4..b7892100001 100644 --- a/src/agents/bash-tools.process.supervisor.test.ts +++ b/src/agents/bash-tools.process.supervisor.test.ts @@ -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", () => { diff --git a/src/agents/openclaw-tools.camera.e2e.test.ts b/src/agents/openclaw-tools.camera.e2e.test.ts index f9860109b86..7524b4f7ab0 100644 --- a/src/agents/openclaw-tools.camera.e2e.test.ts +++ b/src/agents/openclaw-tools.camera.e2e.test.ts @@ -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) { + 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", + ); }); }); diff --git a/src/agents/pi-extensions/context-pruning.e2e.test.ts b/src/agents/pi-extensions/context-pruning.e2e.test.ts index 09bc3d2f8aa..c71591d7ece 100644 --- a/src/agents/pi-extensions/context-pruning.e2e.test.ts +++ b/src/agents/pi-extensions/context-pruning.e2e.test.ts @@ -85,6 +85,42 @@ function makeUser(text: string): AgentMessage { return { role: "user", content: text, timestamp: Date.now() }; } +type ContextPruningSettings = NonNullable>; +type PruneArgs = Parameters[0]; +type PruneOverrides = Omit; + +const CONTEXT_WINDOW_1000 = { + model: { contextWindow: 1000 }, +} as unknown as ExtensionContext; + +function makeAggressiveSettings( + overrides: Partial = {}, +): 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 = {}, + 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); diff --git a/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts b/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts index 6ca5eeb9059..6251192afce 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.runs-compact-as-gated-command.e2e.test.ts @@ -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>) { + 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(); diff --git a/src/browser/server-context.remote-tab-ops.test.ts b/src/browser/server-context.remote-tab-ops.test.ts index 42cd53c1ebf..6e06937774c 100644 --- a/src/browser/server-context.remote-tab-ops.test.ts +++ b/src/browser/server-context.remote-tab-ops.test.ts @@ -48,6 +48,20 @@ function makeState( }; } +function makeUnexpectedFetchMock() { + return vi.fn(async () => { + throw new Error("unexpected fetch"); + }); +} + +function createRemoteRouteHarness(fetchMock?: ReturnType) { + 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>); - 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>); - 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>); - 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>); - 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"]); diff --git a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts index c4c5b10919b..b2bdaf79beb 100644 --- a/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts +++ b/src/infra/heartbeat-runner.respects-ackmaxchars-heartbeat-acks.test.ts @@ -94,6 +94,52 @@ describe("resolveHeartbeatIntervalMs", () => { return withTempHeartbeatSandbox(fn, { unsetEnvVars: ["TELEGRAM_BOT_TOKEN"] }); } + function createMessageSendSpy(extra: Record = {}) { + return vi.fn().mockResolvedValue({ + messageId: "m1", + toJid: "jid", + ...extra, + }); + } + + async function runTelegramHeartbeatWithDefaults(params: { + tmpDir: string; + storePath: string; + replySpy: ReturnType; + replyText: string; + messages?: Record; + telegramOverrides?: Record; + }) { + 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: "HEARTBEAT_OK" }); - 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, diff --git a/src/plugins/install.e2e.test.ts b/src/plugins/install.e2e.test.ts index a7f036788a0..e0b66221e91 100644 --- a/src/plugins/install.e2e.test.ts +++ b/src/plugins/install.e2e.test.ts @@ -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 () => { diff --git a/src/routing/resolve-route.test.ts b/src/routing/resolve-route.test.ts index 9c36656deab..79bd0fe2496 100644 --- a/src/routing/resolve-route.test.ts +++ b/src/routing/resolve-route.test.ts @@ -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[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"); }); }); diff --git a/src/tui/tui-input-history.test.ts b/src/tui/tui-input-history.test.ts index 5bcdbe5479d..dfe9148b937 100644 --- a/src/tui/tui-input-history.test.ts +++ b/src/tui/tui-input-history.test.ts @@ -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("!");