test: group remaining suite cleanups

This commit is contained in:
Peter Steinberger
2026-02-21 21:43:24 +00:00
parent 5c8f0b5a77
commit 861718e4dc
32 changed files with 870 additions and 922 deletions

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import {
DEFAULT_APPROVAL_REQUEST_TIMEOUT_MS,
DEFAULT_APPROVAL_TIMEOUT_MS,
@@ -8,15 +8,20 @@ vi.mock("./tools/gateway.js", () => ({
callGatewayTool: vi.fn(),
}));
let callGatewayTool: typeof import("./tools/gateway.js").callGatewayTool;
let requestExecApprovalDecision: typeof import("./bash-tools.exec-approval-request.js").requestExecApprovalDecision;
describe("requestExecApprovalDecision", () => {
beforeEach(async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
beforeAll(async () => {
({ callGatewayTool } = await import("./tools/gateway.js"));
({ requestExecApprovalDecision } = await import("./bash-tools.exec-approval-request.js"));
});
beforeEach(() => {
vi.mocked(callGatewayTool).mockReset();
});
it("returns string decisions", async () => {
const { requestExecApprovalDecision } = await import("./bash-tools.exec-approval-request.js");
const { callGatewayTool } = await import("./tools/gateway.js");
vi.mocked(callGatewayTool).mockResolvedValue({ decision: "allow-once" });
const result = await requestExecApprovalDecision({
@@ -51,9 +56,6 @@ describe("requestExecApprovalDecision", () => {
});
it("returns null for missing or non-string decisions", async () => {
const { requestExecApprovalDecision } = await import("./bash-tools.exec-approval-request.js");
const { callGatewayTool } = await import("./tools/gateway.js");
vi.mocked(callGatewayTool).mockResolvedValueOnce({});
await expect(
requestExecApprovalDecision({

View File

@@ -1,7 +1,7 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("./tools/gateway.js", () => ({
callGatewayTool: vi.fn(),
@@ -15,10 +15,18 @@ vi.mock("./tools/nodes-utils.js", () => ({
resolveNodeIdFromList: vi.fn((nodes: Array<{ nodeId: string }>) => nodes[0]?.nodeId),
}));
let callGatewayTool: typeof import("./tools/gateway.js").callGatewayTool;
let createExecTool: typeof import("./bash-tools.exec.js").createExecTool;
describe("exec approvals", () => {
let previousHome: string | undefined;
let previousUserProfile: string | undefined;
beforeAll(async () => {
({ callGatewayTool } = await import("./tools/gateway.js"));
({ createExecTool } = await import("./bash-tools.exec.js"));
});
beforeEach(async () => {
previousHome = process.env.HOME;
previousUserProfile = process.env.USERPROFILE;
@@ -43,7 +51,6 @@ describe("exec approvals", () => {
});
it("reuses approval id as the node runId", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
let invokeParams: unknown;
vi.mocked(callGatewayTool).mockImplementation(async (method, _opts, params) => {
@@ -58,7 +65,6 @@ describe("exec approvals", () => {
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
host: "node",
ask: "always",
@@ -78,7 +84,6 @@ describe("exec approvals", () => {
});
it("skips approval when node allowlist is satisfied", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-test-bin-"));
const binDir = path.join(tempDir, "bin");
await fs.mkdir(binDir, { recursive: true });
@@ -111,7 +116,6 @@ describe("exec approvals", () => {
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
host: "node",
ask: "on-miss",
@@ -128,14 +132,12 @@ describe("exec approvals", () => {
});
it("honors ask=off for elevated gateway exec without prompting", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const calls: string[] = [];
vi.mocked(callGatewayTool).mockImplementation(async (method) => {
calls.push(method);
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
ask: "off",
security: "full",
@@ -149,7 +151,6 @@ describe("exec approvals", () => {
});
it("requires approval for elevated ask when allowlist misses", async () => {
const { callGatewayTool } = await import("./tools/gateway.js");
const calls: string[] = [];
let resolveApproval: (() => void) | undefined;
const approvalSeen = new Promise<void>((resolve) => {
@@ -169,7 +170,6 @@ describe("exec approvals", () => {
return { ok: true };
});
const { createExecTool } = await import("./bash-tools.exec.js");
const tool = createExecTool({
ask: "on-miss",
security: "allowlist",

View File

@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from "vitest";
import { beforeAll, describe, expect, it, vi } from "vitest";
import {
addSubagentRunForTests,
listSubagentRunsForRequester,
@@ -41,7 +41,13 @@ const waitForCalls = async (getCount: () => number, count: number, timeoutMs = 2
);
};
let sessionsModule: typeof import("../config/sessions.js");
describe("sessions tools", () => {
beforeAll(async () => {
sessionsModule = await import("../config/sessions.js");
});
it("uses number (not integer) in tool schemas for Gemini compatibility", () => {
const tools = createOpenClawTools();
const byName = (name: string) => {
@@ -767,7 +773,6 @@ describe("sessions tools", () => {
startedAt: now - 2 * 60_000,
});
const sessionsModule = await import("../config/sessions.js");
const loadSessionStoreSpy = vi
.spyOn(sessionsModule, "loadSessionStore")
.mockImplementation(() => ({
@@ -827,7 +832,6 @@ describe("sessions tools", () => {
startedAt: Date.now() - 60_000,
});
const sessionsModule = await import("../config/sessions.js");
const loadSessionStoreSpy = vi
.spyOn(sessionsModule, "loadSessionStore")
.mockImplementation(() => ({

View File

@@ -118,38 +118,47 @@ describe("buildBootstrapContextFiles", () => {
});
});
describe("resolveBootstrapMaxChars", () => {
it("returns default when unset", () => {
expect(resolveBootstrapMaxChars()).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
});
it("uses configured value when valid", () => {
const cfg = {
agents: { defaults: { bootstrapMaxChars: 12345 } },
} as OpenClawConfig;
expect(resolveBootstrapMaxChars(cfg)).toBe(12345);
});
it("falls back when invalid", () => {
const cfg = {
agents: { defaults: { bootstrapMaxChars: -1 } },
} as OpenClawConfig;
expect(resolveBootstrapMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_MAX_CHARS);
});
});
type BootstrapLimitResolverCase = {
name: "bootstrapMaxChars" | "bootstrapTotalMaxChars";
resolve: (cfg?: OpenClawConfig) => number;
defaultValue: number;
};
describe("resolveBootstrapTotalMaxChars", () => {
it("returns default when unset", () => {
expect(resolveBootstrapTotalMaxChars()).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS);
const BOOTSTRAP_LIMIT_RESOLVERS: BootstrapLimitResolverCase[] = [
{
name: "bootstrapMaxChars",
resolve: resolveBootstrapMaxChars,
defaultValue: DEFAULT_BOOTSTRAP_MAX_CHARS,
},
{
name: "bootstrapTotalMaxChars",
resolve: resolveBootstrapTotalMaxChars,
defaultValue: DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS,
},
];
describe("bootstrap limit resolvers", () => {
it("return defaults when unset", () => {
for (const resolver of BOOTSTRAP_LIMIT_RESOLVERS) {
expect(resolver.resolve()).toBe(resolver.defaultValue);
}
});
it("uses configured value when valid", () => {
const cfg = {
agents: { defaults: { bootstrapTotalMaxChars: 12345 } },
} as OpenClawConfig;
expect(resolveBootstrapTotalMaxChars(cfg)).toBe(12345);
it("use configured values when valid", () => {
for (const resolver of BOOTSTRAP_LIMIT_RESOLVERS) {
const cfg = {
agents: { defaults: { [resolver.name]: 12345 } },
} as OpenClawConfig;
expect(resolver.resolve(cfg)).toBe(12345);
}
});
it("falls back when invalid", () => {
const cfg = {
agents: { defaults: { bootstrapTotalMaxChars: -1 } },
} as OpenClawConfig;
expect(resolveBootstrapTotalMaxChars(cfg)).toBe(DEFAULT_BOOTSTRAP_TOTAL_MAX_CHARS);
it("fall back when values are invalid", () => {
for (const resolver of BOOTSTRAP_LIMIT_RESOLVERS) {
const cfg = {
agents: { defaults: { [resolver.name]: -1 } },
} as OpenClawConfig;
expect(resolver.resolve(cfg)).toBe(resolver.defaultValue);
}
});
});

View File

@@ -35,10 +35,6 @@ describe("isAuthErrorMessage", () => {
expect(isAuthErrorMessage(sample)).toBe(true);
}
});
it("ignores unrelated errors", () => {
expect(isAuthErrorMessage("rate limit exceeded")).toBe(false);
expect(isAuthErrorMessage("billing issue detected")).toBe(false);
});
});
describe("isBillingErrorMessage", () => {
@@ -54,11 +50,6 @@ describe("isBillingErrorMessage", () => {
expect(isBillingErrorMessage(sample)).toBe(true);
}
});
it("ignores unrelated errors", () => {
expect(isBillingErrorMessage("rate limit exceeded")).toBe(false);
expect(isBillingErrorMessage("invalid api key")).toBe(false);
expect(isBillingErrorMessage("context length exceeded")).toBe(false);
});
it("does not false-positive on issue IDs or text containing 402", () => {
const falsePositives = [
"Fixed issue CHE-402 in the latest release",
@@ -110,14 +101,6 @@ describe("isCloudCodeAssistFormatError", () => {
expect(isCloudCodeAssistFormatError(sample)).toBe(true);
}
});
it("ignores unrelated errors", () => {
expect(isCloudCodeAssistFormatError("rate limit exceeded")).toBe(false);
expect(
isCloudCodeAssistFormatError(
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}',
),
).toBe(false);
});
});
describe("isCloudflareOrHtmlErrorPage", () => {
@@ -195,13 +178,6 @@ describe("isContextOverflowError", () => {
}
});
it("ignores unrelated errors", () => {
expect(isContextOverflowError("rate limit exceeded")).toBe(false);
expect(isContextOverflowError("request size exceeds upload limit")).toBe(false);
expect(isContextOverflowError("model not found")).toBe(false);
expect(isContextOverflowError("authentication failed")).toBe(false);
});
it("ignores normal conversation text mentioning context overflow", () => {
// These are legitimate conversation snippets, not error messages
expect(isContextOverflowError("Let's investigate the context overflow bug")).toBe(false);
@@ -211,6 +187,46 @@ describe("isContextOverflowError", () => {
});
});
describe("error classifiers", () => {
it("ignore unrelated errors", () => {
const checks: Array<{
matcher: (message: string) => boolean;
samples: string[];
}> = [
{
matcher: isAuthErrorMessage,
samples: ["rate limit exceeded", "billing issue detected"],
},
{
matcher: isBillingErrorMessage,
samples: ["rate limit exceeded", "invalid api key", "context length exceeded"],
},
{
matcher: isCloudCodeAssistFormatError,
samples: [
"rate limit exceeded",
'400 {"type":"error","error":{"type":"invalid_request_error","message":"messages.84.content.1.image.source.base64.data: At least one of the image dimensions exceed max allowed size for many-image requests: 2000 pixels"}}',
],
},
{
matcher: isContextOverflowError,
samples: [
"rate limit exceeded",
"request size exceeds upload limit",
"model not found",
"authentication failed",
],
},
];
for (const check of checks) {
for (const sample of check.samples) {
expect(check.matcher(sample)).toBe(false);
}
}
});
});
describe("isLikelyContextOverflowError", () => {
it("matches context overflow hints", () => {
const samples = [

View File

@@ -14,10 +14,12 @@ describe("sanitizeUserFacingText", () => {
expect(sanitizeUserFacingText("Hi <final>there</final>!")).toBe("Hi there!");
});
it("does not clobber normal numeric prefixes", () => {
expect(sanitizeUserFacingText("202 results found")).toBe("202 results found");
expect(sanitizeUserFacingText("400 days left")).toBe("400 days left");
});
it.each(["202 results found", "400 days left"])(
"does not clobber normal numeric prefix: %s",
(text) => {
expect(sanitizeUserFacingText(text)).toBe(text);
},
);
it("sanitizes role ordering errors", () => {
const result = sanitizeUserFacingText("400 Incorrect role information", { errorContext: true });
@@ -30,45 +32,27 @@ describe("sanitizeUserFacingText", () => {
);
});
it("sanitizes direct context-overflow errors", () => {
expect(
sanitizeUserFacingText(
"Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.",
{ errorContext: true },
),
).toContain("Context overflow: prompt too large for the model.");
expect(
sanitizeUserFacingText("Request size exceeds model context window", { errorContext: true }),
).toContain("Context overflow: prompt too large for the model.");
it.each([
"Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.",
"Request size exceeds model context window",
])("sanitizes direct context-overflow error: %s", (text) => {
expect(sanitizeUserFacingText(text, { errorContext: true })).toContain(
"Context overflow: prompt too large for the model.",
);
});
it("does not swallow assistant text that quotes the canonical context-overflow string", () => {
const text =
"Changelog note: we fixed false positives for `Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.` in 2026.2.9";
it.each([
"Changelog note: we fixed false positives for `Context overflow: prompt too large for the model. Try /reset (or /new) to start a fresh session, or use a larger-context model.` in 2026.2.9",
"nah it failed, hit a context overflow. the prompt was too large for the model. want me to retry it with a different approach?",
"Problem: When a subagent reads a very large file, it can exceed the model context window. Auto-compaction cannot help in that case.",
])("does not rewrite regular context-overflow mentions: %s", (text) => {
expect(sanitizeUserFacingText(text)).toBe(text);
});
it("does not rewrite conversational mentions of context overflow", () => {
const text =
"nah it failed, hit a context overflow. the prompt was too large for the model. want me to retry it with a different approach?";
expect(sanitizeUserFacingText(text)).toBe(text);
});
it("does not rewrite technical summaries that mention context overflow", () => {
const text =
"Problem: When a subagent reads a very large file, it can exceed the model context window. Auto-compaction cannot help in that case.";
expect(sanitizeUserFacingText(text)).toBe(text);
});
it("does not rewrite conversational billing/help text without errorContext", () => {
const text =
"If your API billing is low, top up credits in your provider dashboard and retry payment verification.";
expect(sanitizeUserFacingText(text)).toBe(text);
});
it("does not rewrite normal text that mentions billing and plan", () => {
const text =
"Firebase downgraded us to the free Spark plan; check whether we need to re-enable billing.";
it.each([
"If your API billing is low, top up credits in your provider dashboard and retry payment verification.",
"Firebase downgraded us to the free Spark plan; check whether we need to re-enable billing.",
])("does not rewrite regular billing mentions: %s", (text) => {
expect(sanitizeUserFacingText(text)).toBe(text);
});
@@ -95,25 +79,27 @@ describe("sanitizeUserFacingText", () => {
);
});
it("collapses consecutive duplicate paragraphs", () => {
const text = "Hello there!\n\nHello there!";
expect(sanitizeUserFacingText(text)).toBe("Hello there!");
it.each([
{
input: "Hello there!\n\nHello there!",
expected: "Hello there!",
},
{
input: "Hello there!\n\nDifferent line.",
expected: "Hello there!\n\nDifferent line.",
},
])("normalizes paragraph blocks", ({ input, expected }) => {
expect(sanitizeUserFacingText(input)).toBe(expected);
});
it("does not collapse distinct paragraphs", () => {
const text = "Hello there!\n\nDifferent line.";
expect(sanitizeUserFacingText(text)).toBe(text);
});
it("strips leading newlines from LLM output", () => {
expect(sanitizeUserFacingText("\n\nHello there!")).toBe("Hello there!");
expect(sanitizeUserFacingText("\nHello there!")).toBe("Hello there!");
expect(sanitizeUserFacingText("\n\n\nMultiple newlines")).toBe("Multiple newlines");
});
it("strips leading whitespace and newlines combined", () => {
expect(sanitizeUserFacingText("\n \nHello")).toBe("Hello");
expect(sanitizeUserFacingText(" \n\nHello")).toBe("Hello");
it.each([
{ input: "\n\nHello there!", expected: "Hello there!" },
{ input: "\nHello there!", expected: "Hello there!" },
{ input: "\n\n\nMultiple newlines", expected: "Multiple newlines" },
{ input: "\n \nHello", expected: "Hello" },
{ input: " \n\nHello", expected: "Hello" },
])("strips leading empty lines: %j", ({ input, expected }) => {
expect(sanitizeUserFacingText(input)).toBe(expected);
});
it("preserves trailing whitespace and internal newlines", () => {
@@ -121,9 +107,8 @@ describe("sanitizeUserFacingText", () => {
expect(sanitizeUserFacingText("Line 1\nLine 2")).toBe("Line 1\nLine 2");
});
it("returns empty for whitespace-only input", () => {
expect(sanitizeUserFacingText("\n\n")).toBe("");
expect(sanitizeUserFacingText(" \n ")).toBe("");
it.each(["\n\n", " \n "])("returns empty for whitespace-only input: %j", (input) => {
expect(sanitizeUserFacingText(input)).toBe("");
});
});
@@ -334,81 +319,60 @@ describe("downgradeOpenAIReasoningBlocks", () => {
});
describe("normalizeTextForComparison", () => {
it("lowercases text", () => {
expect(normalizeTextForComparison("Hello World")).toBe("hello world");
});
it("trims whitespace", () => {
expect(normalizeTextForComparison(" hello ")).toBe("hello");
});
it("collapses multiple spaces", () => {
expect(normalizeTextForComparison("hello world")).toBe("hello world");
});
it("strips emoji", () => {
expect(normalizeTextForComparison("Hello 👋 World 🌍")).toBe("hello world");
});
it("handles mixed normalization", () => {
expect(normalizeTextForComparison(" Hello 👋 WORLD 🌍 ")).toBe("hello world");
it.each([
{ input: "Hello World", expected: "hello world" },
{ input: " hello ", expected: "hello" },
{ input: "hello world", expected: "hello world" },
{ input: "Hello 👋 World 🌍", expected: "hello world" },
{ input: " Hello 👋 WORLD 🌍 ", expected: "hello world" },
])("normalizes comparison text", ({ input, expected }) => {
expect(normalizeTextForComparison(input)).toBe(expected);
});
});
describe("isMessagingToolDuplicate", () => {
it("returns false for empty sentTexts", () => {
expect(isMessagingToolDuplicate("hello world", [])).toBe(false);
});
it("returns false for short texts", () => {
expect(isMessagingToolDuplicate("short", ["short"])).toBe(false);
});
it("detects exact duplicates", () => {
expect(
isMessagingToolDuplicate("Hello, this is a test message!", [
"Hello, this is a test message!",
]),
).toBe(true);
});
it("detects duplicates with different casing", () => {
expect(
isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [
"hello, this is a test message!",
]),
).toBe(true);
});
it("detects duplicates with emoji variations", () => {
expect(
isMessagingToolDuplicate("Hello! 👋 This is a test message!", [
"Hello! This is a test message!",
]),
).toBe(true);
});
it("detects substring duplicates (LLM elaboration)", () => {
expect(
isMessagingToolDuplicate('I sent the message: "Hello, this is a test message!"', [
"Hello, this is a test message!",
]),
).toBe(true);
});
it("detects when sent text contains block reply (reverse substring)", () => {
expect(
isMessagingToolDuplicate("Hello, this is a test message!", [
'I sent the message: "Hello, this is a test message!"',
]),
).toBe(true);
});
it("returns false for non-matching texts", () => {
expect(
isMessagingToolDuplicate("This is completely different content.", [
"Hello, this is a test message!",
]),
).toBe(false);
it.each([
{
input: "hello world",
sentTexts: [],
expected: false,
},
{
input: "short",
sentTexts: ["short"],
expected: false,
},
{
input: "Hello, this is a test message!",
sentTexts: ["Hello, this is a test message!"],
expected: true,
},
{
input: "HELLO, THIS IS A TEST MESSAGE!",
sentTexts: ["hello, this is a test message!"],
expected: true,
},
{
input: "Hello! 👋 This is a test message!",
sentTexts: ["Hello! This is a test message!"],
expected: true,
},
{
input: 'I sent the message: "Hello, this is a test message!"',
sentTexts: ["Hello, this is a test message!"],
expected: true,
},
{
input: "Hello, this is a test message!",
sentTexts: ['I sent the message: "Hello, this is a test message!"'],
expected: true,
},
{
input: "This is completely different content.",
sentTexts: ["Hello, this is a test message!"],
expected: false,
},
])("returns $expected for duplicate check", ({ input, sentTexts, expected }) => {
expect(isMessagingToolDuplicate(input, sentTexts)).toBe(expected);
});
});

View File

@@ -278,40 +278,49 @@ describe("applyExtraParamsToAgent", () => {
expect(payload.store).toBe(false);
});
it("does not force store=true for Codex responses (Codex requires store=false)", () => {
const payload = runStoreMutationCase({
applyProvider: "openai-codex",
applyModelId: "codex-mini-latest",
model: {
api: "openai-codex-responses",
provider: "openai-codex",
id: "codex-mini-latest",
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
} as Model<"openai-codex-responses">,
});
expect(payload.store).toBe(false);
});
it.each([
{
name: "with openai-codex provider config",
run: () =>
runStoreMutationCase({
applyProvider: "openai-codex",
applyModelId: "codex-mini-latest",
model: {
api: "openai-codex-responses",
provider: "openai-codex",
id: "codex-mini-latest",
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
} as Model<"openai-codex-responses">,
}),
},
{
name: "without config via provider/model hints",
run: () => {
const payload = { store: false };
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
it("does not force store=true for Codex responses (Codex requires store=false)", () => {
const payload = { store: false };
const baseStreamFn: StreamFn = (_model, _context, options) => {
options?.onPayload?.(payload);
return {} as ReturnType<StreamFn>;
};
const agent = { streamFn: baseStreamFn };
applyExtraParamsToAgent(agent, undefined, "openai-codex", "codex-mini-latest");
applyExtraParamsToAgent(agent, undefined, "openai-codex", "codex-mini-latest");
const model = {
api: "openai-codex-responses",
provider: "openai-codex",
id: "codex-mini-latest",
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
} as Model<"openai-codex-responses">;
const context: Context = { messages: [] };
const model = {
api: "openai-codex-responses",
provider: "openai-codex",
id: "codex-mini-latest",
baseUrl: "https://chatgpt.com/backend-api/codex/responses",
} as Model<"openai-codex-responses">;
const context: Context = { messages: [] };
void agent.streamFn?.(model, context, {});
expect(payload.store).toBe(false);
});
void agent.streamFn?.(model, context, {});
return payload;
},
},
])(
"does not force store=true for Codex responses (Codex requires store=false) ($name)",
({ run }) => {
expect(run().store).toBe(false);
},
);
});

View File

@@ -28,23 +28,25 @@ function makeAssistantMessage(
}
describe("extractAssistantText", () => {
it("strips Minimax tool invocation XML from text", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: `<invoke name="Bash">
it("strips tool-only Minimax invocation XML from text", () => {
const cases = [
`<invoke name="Bash">
<parameter name="command">netstat -tlnp | grep 18789</parameter>
</invoke>
</minimax:tool_call>`,
},
],
timestamp: Date.now(),
});
const result = extractAssistantText(msg);
expect(result).toBe("");
`<invoke name="Bash">
<parameter name="command">test</parameter>
</invoke>
</minimax:tool_call>`,
];
for (const text of cases) {
const msg = makeAssistantMessage({
role: "assistant",
content: [{ type: "text", text }],
timestamp: Date.now(),
});
expect(extractAssistantText(msg)).toBe("");
}
});
it("strips multiple tool invocations", () => {
@@ -268,25 +270,6 @@ describe("extractAssistantText", () => {
expect(result).toBe("Some text here.More text.");
});
it("returns empty string when message is only tool invocations", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: `<invoke name="Bash">
<parameter name="command">test</parameter>
</invoke>
</minimax:tool_call>`,
},
],
timestamp: Date.now(),
});
const result = extractAssistantText(msg);
expect(result).toBe("");
});
it("handles multiple text blocks", () => {
const msg = makeAssistantMessage({
role: "assistant",
@@ -436,140 +419,62 @@ File contents here`,
expect(result).toBe("Here's what I found:\nDone checking.");
});
it("strips thinking tags from text content", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: "<think>El usuario quiere retomar una tarea...</think>Aquí está tu respuesta.",
},
],
timestamp: Date.now(),
});
it("strips reasoning/thinking tag variants", () => {
const cases = [
{
name: "think tag",
text: "<think>El usuario quiere retomar una tarea...</think>Aquí está tu respuesta.",
expected: "Aquí está tu respuesta.",
},
{
name: "think tag with attributes",
text: `<think reason="deliberate">Hidden</think>Visible`,
expected: "Visible",
},
{
name: "unclosed think tag",
text: "<think>Pensando sobre el problema...",
expected: "",
},
{
name: "thinking tag",
text: "Before<thinking>internal reasoning</thinking>After",
expected: "BeforeAfter",
},
{
name: "antthinking tag",
text: "<antthinking>Some reasoning</antthinking>The actual answer.",
expected: "The actual answer.",
},
{
name: "final wrapper",
text: "<final>\nAnswer\n</final>",
expected: "Answer",
},
{
name: "thought tag",
text: "<thought>Internal deliberation</thought>Final response.",
expected: "Final response.",
},
{
name: "multiple think blocks",
text: "Start<think>first thought</think>Middle<think>second thought</think>End",
expected: "StartMiddleEnd",
},
] as const;
const result = extractAssistantText(msg);
expect(result).toBe("Aquí está tu respuesta.");
});
it("strips thinking tags with attributes", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: `<think reason="deliberate">Hidden</think>Visible`,
},
],
timestamp: Date.now(),
});
const result = extractAssistantText(msg);
expect(result).toBe("Visible");
});
it("strips thinking tags without closing tag", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: "<think>Pensando sobre el problema...",
},
],
timestamp: Date.now(),
});
const result = extractAssistantText(msg);
expect(result).toBe("");
});
it("strips thinking tags with various formats", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: "Before<thinking>internal reasoning</thinking>After",
},
],
timestamp: Date.now(),
});
const result = extractAssistantText(msg);
expect(result).toBe("BeforeAfter");
});
it("strips antthinking tags", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: "<antthinking>Some reasoning</antthinking>The actual answer.",
},
],
timestamp: Date.now(),
});
const result = extractAssistantText(msg);
expect(result).toBe("The actual answer.");
});
it("strips final tags while keeping content", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: "<final>\nAnswer\n</final>",
},
],
timestamp: Date.now(),
});
const result = extractAssistantText(msg);
expect(result).toBe("Answer");
});
it("strips thought tags", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: "<thought>Internal deliberation</thought>Final response.",
},
],
timestamp: Date.now(),
});
const result = extractAssistantText(msg);
expect(result).toBe("Final response.");
});
it("handles nested or multiple thinking blocks", () => {
const msg = makeAssistantMessage({
role: "assistant",
content: [
{
type: "text",
text: "Start<think>first thought</think>Middle<think>second thought</think>End",
},
],
timestamp: Date.now(),
});
const result = extractAssistantText(msg);
expect(result).toBe("StartMiddleEnd");
for (const testCase of cases) {
const msg = makeAssistantMessage({
role: "assistant",
content: [{ type: "text", text: testCase.text }],
timestamp: Date.now(),
});
expect(extractAssistantText(msg), testCase.name).toBe(testCase.expected);
}
});
});
describe("formatReasoningMessage", () => {
it("returns empty string for empty input", () => {
expect(formatReasoningMessage("")).toBe("");
});
it("returns empty string for whitespace-only input", () => {
expect(formatReasoningMessage(" \n \t ")).toBe("");
});
@@ -604,37 +509,51 @@ describe("formatReasoningMessage", () => {
});
describe("stripDowngradedToolCallText", () => {
it("strips [Historical context: ...] blocks", () => {
const text = `[Historical context: a different model called tool "exec" with arguments {"command":"git status"}]`;
expect(stripDowngradedToolCallText(text)).toBe("");
});
it("strips downgraded marker blocks while preserving surrounding user-facing text", () => {
const cases = [
{
name: "historical context only",
text: `[Historical context: a different model called tool "exec" with arguments {"command":"git status"}]`,
expected: "",
},
{
name: "text before historical context",
text: `Here is the answer.\n[Historical context: a different model called tool "read"]`,
expected: "Here is the answer.",
},
{
name: "text around historical context",
text: `Before.\n[Historical context: tool call info]\nAfter.`,
expected: "Before.\nAfter.",
},
{
name: "multiple historical context blocks",
text: `[Historical context: first tool call]\n[Historical context: second tool call]`,
expected: "",
},
{
name: "mixed tool call and historical context",
text: `Intro.\n[Tool Call: exec (ID: toolu_1)]\nArguments: { "command": "ls" }\n[Historical context: a different model called tool "read"]`,
expected: "Intro.",
},
{
name: "no markers",
text: "Just a normal response with no markers.",
expected: "Just a normal response with no markers.",
},
] as const;
it("preserves text before [Historical context: ...] blocks", () => {
const text = `Here is the answer.\n[Historical context: a different model called tool "read"]`;
expect(stripDowngradedToolCallText(text)).toBe("Here is the answer.");
});
it("preserves text around [Historical context: ...] blocks", () => {
const text = `Before.\n[Historical context: tool call info]\nAfter.`;
expect(stripDowngradedToolCallText(text)).toBe("Before.\nAfter.");
});
it("strips multiple [Historical context: ...] blocks", () => {
const text = `[Historical context: first tool call]\n[Historical context: second tool call]`;
expect(stripDowngradedToolCallText(text)).toBe("");
});
it("strips mixed [Tool Call: ...] and [Historical context: ...] blocks", () => {
const text = `Intro.\n[Tool Call: exec (ID: toolu_1)]\nArguments: { "command": "ls" }\n[Historical context: a different model called tool "read"]`;
expect(stripDowngradedToolCallText(text)).toBe("Intro.");
});
it("returns text unchanged when no markers are present", () => {
const text = "Just a normal response with no markers.";
expect(stripDowngradedToolCallText(text)).toBe("Just a normal response with no markers.");
});
it("returns empty string for empty input", () => {
expect(stripDowngradedToolCallText("")).toBe("");
for (const testCase of cases) {
expect(stripDowngradedToolCallText(testCase.text), testCase.name).toBe(testCase.expected);
}
});
});
describe("empty input handling", () => {
it("returns empty string", () => {
const helpers = [formatReasoningMessage, stripDowngradedToolCallText];
for (const helper of helpers) {
expect(helper("")).toBe("");
}
});
});

View File

@@ -1,9 +1,21 @@
import { describe, expect, it } from "vitest";
import { beforeAll, describe, expect, it } from "vitest";
let resolveSandboxScope: typeof import("./sandbox.js").resolveSandboxScope;
let resolveSandboxDockerConfig: typeof import("./sandbox.js").resolveSandboxDockerConfig;
let resolveSandboxBrowserConfig: typeof import("./sandbox.js").resolveSandboxBrowserConfig;
let resolveSandboxPruneConfig: typeof import("./sandbox.js").resolveSandboxPruneConfig;
describe("sandbox config merges", () => {
it("resolves sandbox scope deterministically", { timeout: 60_000 }, async () => {
const { resolveSandboxScope } = await import("./sandbox.js");
beforeAll(async () => {
({
resolveSandboxScope,
resolveSandboxDockerConfig,
resolveSandboxBrowserConfig,
resolveSandboxPruneConfig,
} = await import("./sandbox.js"));
});
it("resolves sandbox scope deterministically", { timeout: 60_000 }, async () => {
expect(resolveSandboxScope({})).toBe("agent");
expect(resolveSandboxScope({ perSession: true })).toBe("session");
expect(resolveSandboxScope({ perSession: false })).toBe("shared");
@@ -11,8 +23,6 @@ describe("sandbox config merges", () => {
});
it("merges sandbox docker env and ulimits (agent wins)", async () => {
const { resolveSandboxDockerConfig } = await import("./sandbox.js");
const resolved = resolveSandboxDockerConfig({
scope: "agent",
globalDocker: {
@@ -33,8 +43,6 @@ describe("sandbox config merges", () => {
});
it("merges sandbox docker binds (global + agent combined)", async () => {
const { resolveSandboxDockerConfig } = await import("./sandbox.js");
const resolved = resolveSandboxDockerConfig({
scope: "agent",
globalDocker: {
@@ -52,8 +60,6 @@ describe("sandbox config merges", () => {
});
it("returns undefined binds when neither global nor agent has binds", async () => {
const { resolveSandboxDockerConfig } = await import("./sandbox.js");
const resolved = resolveSandboxDockerConfig({
scope: "agent",
globalDocker: {},
@@ -64,8 +70,6 @@ describe("sandbox config merges", () => {
});
it("ignores agent binds under shared scope", async () => {
const { resolveSandboxDockerConfig } = await import("./sandbox.js");
const resolved = resolveSandboxDockerConfig({
scope: "shared",
globalDocker: {
@@ -80,8 +84,6 @@ describe("sandbox config merges", () => {
});
it("ignores agent docker overrides under shared scope", async () => {
const { resolveSandboxDockerConfig } = await import("./sandbox.js");
const resolved = resolveSandboxDockerConfig({
scope: "shared",
globalDocker: { image: "global" },
@@ -92,8 +94,6 @@ describe("sandbox config merges", () => {
});
it("applies per-agent browser and prune overrides (ignored under shared scope)", async () => {
const { resolveSandboxBrowserConfig, resolveSandboxPruneConfig } = await import("./sandbox.js");
const browser = resolveSandboxBrowserConfig({
scope: "agent",
globalBrowser: { enabled: false, headless: false, enableNoVnc: true },

View File

@@ -115,15 +115,6 @@ describe("validateSeccompProfile", () => {
expect(() => validateSeccompProfile("/tmp/seccomp.json")).not.toThrow();
expect(() => validateSeccompProfile(undefined)).not.toThrow();
});
it("blocks unconfined (case-insensitive)", () => {
expect(() => validateSeccompProfile("unconfined")).toThrow(
/seccomp profile "unconfined" is blocked/,
);
expect(() => validateSeccompProfile("Unconfined")).toThrow(
/seccomp profile "Unconfined" is blocked/,
);
});
});
describe("validateApparmorProfile", () => {
@@ -131,11 +122,23 @@ describe("validateApparmorProfile", () => {
expect(() => validateApparmorProfile("openclaw-sandbox")).not.toThrow();
expect(() => validateApparmorProfile(undefined)).not.toThrow();
});
});
it("blocks unconfined (case-insensitive)", () => {
expect(() => validateApparmorProfile("unconfined")).toThrow(
/apparmor profile "unconfined" is blocked/,
);
describe("profile hardening", () => {
it.each([
{
name: "seccomp",
run: (value: string) => validateSeccompProfile(value),
expected: /seccomp profile ".+" is blocked/,
},
{
name: "apparmor",
run: (value: string) => validateApparmorProfile(value),
expected: /apparmor profile ".+" is blocked/,
},
])("blocks unconfined profiles (case-insensitive): $name", ({ run, expected }) => {
expect(() => run("unconfined")).toThrow(expected);
expect(() => run("Unconfined")).toThrow(expected);
});
});

View File

@@ -90,19 +90,15 @@ describe("resolveShellFromPath", () => {
}
});
if (isWin) {
it("returns undefined on Windows for missing PATH entries in this test harness", () => {
process.env.PATH = "";
expect(resolveShellFromPath("bash")).toBeUndefined();
});
return;
}
it("returns undefined when PATH is empty", () => {
process.env.PATH = "";
expect(resolveShellFromPath("bash")).toBeUndefined();
});
if (isWin) {
return;
}
it("returns the first executable match from PATH", () => {
const notExecutable = createTempCommandDir(tempDirs, [{ name: "bash", executable: false }]);
const executable = createTempCommandDir(tempDirs, [{ name: "bash", executable: true }]);

View File

@@ -1261,7 +1261,7 @@ describe("subagent announce formatting", () => {
threadId: 99,
},
},
] as const)("$testName", async (testCase) => {
] as const)("thread routing: $testName", async (testCase) => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(true);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);
@@ -1348,7 +1348,7 @@ describe("subagent announce formatting", () => {
expectedChannel: "whatsapp",
expectedAccountId: "acct-987",
},
] as const)("$testName", async (testCase) => {
] as const)("direct announce: $testName", async (testCase) => {
const { runSubagentAnnounceFlow } = await import("./subagent-announce.js");
embeddedRunMock.isEmbeddedPiRunActive.mockReturnValue(false);
embeddedRunMock.isEmbeddedPiRunStreaming.mockReturnValue(false);

View File

@@ -535,7 +535,7 @@ describe("buildAgentSystemPrompt", () => {
});
describe("buildSubagentSystemPrompt", () => {
it("includes sub-agent spawning guidance for depth-1 orchestrator when maxSpawnDepth >= 2", () => {
it("renders depth-1 orchestrator guidance, labels, and recovery notes", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "research task",
@@ -549,21 +549,15 @@ describe("buildSubagentSystemPrompt", () => {
expect(prompt).toContain("`subagents` tool");
expect(prompt).toContain("announce their results back to you automatically");
expect(prompt).toContain("Do NOT repeatedly poll `subagents list`");
expect(prompt).toContain("spawned by the main agent");
expect(prompt).toContain("reported to the main agent");
expect(prompt).toContain("[compacted: tool output removed to free context]");
expect(prompt).toContain("[truncated: output exceeded context limit]");
expect(prompt).toContain("offset/limit");
expect(prompt).toContain("instead of full-file `cat`");
});
it("does not include spawning guidance for depth-1 leaf when maxSpawnDepth == 1", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "research task",
childDepth: 1,
maxSpawnDepth: 1,
});
expect(prompt).not.toContain("## Sub-Agent Spawning");
expect(prompt).not.toContain("You CAN spawn");
});
it("includes leaf worker note for depth-2 sub-sub-agents", () => {
it("renders depth-2 leaf guidance with parent orchestrator labels", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc:subagent:def",
task: "leaf task",
@@ -574,54 +568,39 @@ describe("buildSubagentSystemPrompt", () => {
expect(prompt).toContain("## Sub-Agent Spawning");
expect(prompt).toContain("leaf worker");
expect(prompt).toContain("CANNOT spawn further sub-agents");
});
it("uses 'parent orchestrator' label for depth-2 agents", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc:subagent:def",
task: "leaf task",
childDepth: 2,
maxSpawnDepth: 2,
});
expect(prompt).toContain("spawned by the parent orchestrator");
expect(prompt).toContain("reported to the parent orchestrator");
});
it("uses 'main agent' label for depth-1 agents", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "orchestrator task",
childDepth: 1,
maxSpawnDepth: 2,
});
it("omits spawning guidance for depth-1 leaf agents", () => {
const leafCases = [
{
name: "explicit maxSpawnDepth 1",
input: {
childSessionKey: "agent:main:subagent:abc",
task: "research task",
childDepth: 1,
maxSpawnDepth: 1,
},
expectMainAgentLabel: false,
},
{
name: "implicit default depth/maxSpawnDepth",
input: {
childSessionKey: "agent:main:subagent:abc",
task: "basic task",
},
expectMainAgentLabel: true,
},
] as const;
expect(prompt).toContain("spawned by the main agent");
expect(prompt).toContain("reported to the main agent");
});
it("includes recovery guidance for compacted/truncated tool output", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "investigate logs",
childDepth: 1,
maxSpawnDepth: 2,
});
expect(prompt).toContain("[compacted: tool output removed to free context]");
expect(prompt).toContain("[truncated: output exceeded context limit]");
expect(prompt).toContain("offset/limit");
expect(prompt).toContain("instead of full-file `cat`");
});
it("defaults to depth 1 and maxSpawnDepth 1 when not provided", () => {
const prompt = buildSubagentSystemPrompt({
childSessionKey: "agent:main:subagent:abc",
task: "basic task",
});
// Should not include spawning guidance (default maxSpawnDepth is 1, depth 1 is leaf)
expect(prompt).not.toContain("## Sub-Agent Spawning");
expect(prompt).toContain("spawned by the main agent");
for (const testCase of leafCases) {
const prompt = buildSubagentSystemPrompt(testCase.input);
expect(prompt, testCase.name).not.toContain("## Sub-Agent Spawning");
expect(prompt, testCase.name).not.toContain("You CAN spawn");
if (testCase.expectMainAgentLabel) {
expect(prompt, testCase.name).toContain("spawned by the main agent");
}
}
});
});

View File

@@ -35,12 +35,6 @@ describe("readStringOrNumberParam", () => {
const params = { chatId: " abc " };
expect(readStringOrNumberParam(params, "chatId")).toBe("abc");
});
it("throws when required and missing", () => {
expect(() => readStringOrNumberParam({}, "chatId", { required: true })).toThrow(
/chatId required/,
);
});
});
describe("readNumberParam", () => {
@@ -53,8 +47,13 @@ describe("readNumberParam", () => {
const params = { messageId: "42.9" };
expect(readNumberParam(params, "messageId", { integer: true })).toBe(42);
});
});
it("throws when required and missing", () => {
describe("required parameter validation", () => {
it("throws when required values are missing", () => {
expect(() => readStringOrNumberParam({}, "chatId", { required: true })).toThrow(
/chatId required/,
);
expect(() => readNumberParam({}, "messageId", { required: true })).toThrow(
/messageId required/,
);

View File

@@ -1,4 +1,4 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { createTestRegistry } from "../../test-utils/channel-plugins.js";
import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js";
@@ -22,10 +22,10 @@ vi.mock("../../config/config.js", async (importOriginal) => {
import { createSessionsListTool } from "./sessions-list-tool.js";
import { createSessionsSendTool } from "./sessions-send-tool.js";
const loadResolveAnnounceTarget = async () => await import("./sessions-announce-target.js");
let resolveAnnounceTarget: (typeof import("./sessions-announce-target.js"))["resolveAnnounceTarget"];
let setActivePluginRegistry: (typeof import("../../plugins/runtime.js"))["setActivePluginRegistry"];
const installRegistry = async () => {
const { setActivePluginRegistry } = await import("../../plugins/runtime.js");
setActivePluginRegistry(
createTestRegistry([
{
@@ -89,6 +89,11 @@ describe("sanitizeTextContent", () => {
});
});
beforeAll(async () => {
({ resolveAnnounceTarget } = await import("./sessions-announce-target.js"));
({ setActivePluginRegistry } = await import("../../plugins/runtime.js"));
});
describe("extractAssistantText", () => {
it("sanitizes blocks without injecting newlines", () => {
const message = {
@@ -134,7 +139,6 @@ describe("resolveAnnounceTarget", () => {
});
it("derives non-WhatsApp announce targets from the session key", async () => {
const { resolveAnnounceTarget } = await loadResolveAnnounceTarget();
const target = await resolveAnnounceTarget({
sessionKey: "agent:main:discord:group:dev",
displayKey: "agent:main:discord:group:dev",
@@ -144,7 +148,6 @@ describe("resolveAnnounceTarget", () => {
});
it("hydrates WhatsApp accountId from sessions.list when available", async () => {
const { resolveAnnounceTarget } = await loadResolveAnnounceTarget();
callGatewayMock.mockResolvedValueOnce({
sessions: [
{