test: dedupe fixtures and test harness setup

This commit is contained in:
Peter Steinberger
2026-02-23 05:43:30 +00:00
parent 8af19ddc5b
commit 1c753ea786
75 changed files with 1886 additions and 2136 deletions

View File

@@ -46,28 +46,33 @@ function pollStatus(result: Awaited<ReturnType<ReturnType<typeof createProcessTo
return (result.details as { status?: string }).status;
}
test("process poll waits for completion when timeout is provided", async () => {
async function expectCompletedPollWithTimeout(params: {
sessionId: string;
callId: string;
timeout: number | string;
advanceMs: number;
assertUnresolvedAtMs?: number;
}) {
vi.useFakeTimers();
try {
const sessionId = "sess";
const { processTool, session } = createProcessSessionHarness(sessionId);
const { processTool, session } = createProcessSessionHarness(params.sessionId);
setTimeout(() => {
appendOutput(session, "stdout", "done\n");
markExited(session, 0, null, "completed");
}, 10);
const pollPromise = pollSession(processTool, "toolcall", sessionId, 2000);
const pollPromise = pollSession(processTool, params.callId, params.sessionId, params.timeout);
if (params.assertUnresolvedAtMs !== undefined) {
let resolved = false;
void pollPromise.finally(() => {
resolved = true;
});
await vi.advanceTimersByTimeAsync(params.assertUnresolvedAtMs);
expect(resolved).toBe(false);
}
let resolved = false;
void pollPromise.finally(() => {
resolved = true;
});
await vi.advanceTimersByTimeAsync(200);
expect(resolved).toBe(false);
await vi.advanceTimersByTimeAsync(100);
await vi.advanceTimersByTimeAsync(params.advanceMs);
const poll = await pollPromise;
const details = poll.details as { status?: string; aggregated?: string };
expect(details.status).toBe("completed");
@@ -75,27 +80,25 @@ test("process poll waits for completion when timeout is provided", async () => {
} finally {
vi.useRealTimers();
}
}
test("process poll waits for completion when timeout is provided", async () => {
await expectCompletedPollWithTimeout({
sessionId: "sess",
callId: "toolcall",
timeout: 2000,
assertUnresolvedAtMs: 200,
advanceMs: 100,
});
});
test("process poll accepts string timeout values", async () => {
vi.useFakeTimers();
try {
const sessionId = "sess-2";
const { processTool, session } = createProcessSessionHarness(sessionId);
setTimeout(() => {
appendOutput(session, "stdout", "done\n");
markExited(session, 0, null, "completed");
}, 10);
const pollPromise = pollSession(processTool, "toolcall", sessionId, "2000");
await vi.advanceTimersByTimeAsync(350);
const poll = await pollPromise;
const details = poll.details as { status?: string; aggregated?: string };
expect(details.status).toBe("completed");
expect(details.aggregated ?? "").toContain("done");
} finally {
vi.useRealTimers();
}
await expectCompletedPollWithTimeout({
sessionId: "sess-2",
callId: "toolcall",
timeout: "2000",
advanceMs: 350,
});
});
test("process poll exposes adaptive retryInMs for repeated no-output polls", async () => {

View File

@@ -35,6 +35,18 @@ async function resolveBedrockProvider() {
});
}
async function expectBedrockAuthSource(params: {
env: Record<string, string | undefined>;
expectedSource: string;
}) {
await withEnvAsync(params.env, async () => {
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain(params.expectedSource);
});
}
describe("getApiKeyForModel", () => {
it("migrates legacy oauth.json into auth-profiles.json", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-oauth-"));
@@ -226,57 +238,39 @@ describe("getApiKeyForModel", () => {
});
it("prefers Bedrock bearer token over access keys and profile", async () => {
await withEnvAsync(
{
await expectBedrockAuthSource({
env: {
AWS_BEARER_TOKEN_BEDROCK: "bedrock-token",
AWS_ACCESS_KEY_ID: "access-key",
AWS_SECRET_ACCESS_KEY: "secret-key",
AWS_PROFILE: "profile",
},
async () => {
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_BEARER_TOKEN_BEDROCK");
},
);
expectedSource: "AWS_BEARER_TOKEN_BEDROCK",
});
});
it("prefers Bedrock access keys over profile", async () => {
await withEnvAsync(
{
await expectBedrockAuthSource({
env: {
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_ACCESS_KEY_ID: "access-key",
AWS_SECRET_ACCESS_KEY: "secret-key",
AWS_PROFILE: "profile",
},
async () => {
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_ACCESS_KEY_ID");
},
);
expectedSource: "AWS_ACCESS_KEY_ID",
});
});
it("uses Bedrock profile when access keys are missing", async () => {
await withEnvAsync(
{
await expectBedrockAuthSource({
env: {
AWS_BEARER_TOKEN_BEDROCK: undefined,
AWS_ACCESS_KEY_ID: undefined,
AWS_SECRET_ACCESS_KEY: undefined,
AWS_PROFILE: "profile",
},
async () => {
const resolved = await resolveBedrockProvider();
expect(resolved.mode).toBe("aws-sdk");
expect(resolved.apiKey).toBeUndefined();
expect(resolved.source).toContain("AWS_PROFILE");
},
);
expectedSource: "AWS_PROFILE",
});
});
it("accepts VOYAGE_API_KEY for voyage", async () => {

View File

@@ -107,6 +107,60 @@ function createOverrideFailureRun(params: {
});
}
function makeSingleProviderStore(params: {
provider: string;
usageStat: NonNullable<AuthProfileStore["usageStats"]>[string];
}): AuthProfileStore {
const profileId = `${params.provider}:default`;
return {
version: AUTH_STORE_VERSION,
profiles: {
[profileId]: {
type: "api_key",
provider: params.provider,
key: "test-key",
},
},
usageStats: {
[profileId]: params.usageStat,
},
};
}
function createFallbackOnlyRun() {
return vi.fn().mockImplementation(async (providerId, modelId) => {
if (providerId === "fallback") {
return "ok";
}
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
});
}
async function expectSkippedUnavailableProvider(params: {
providerPrefix: string;
usageStat: NonNullable<AuthProfileStore["usageStats"]>[string];
expectedReason: string;
}) {
const provider = `${params.providerPrefix}-${crypto.randomUUID()}`;
const cfg = makeProviderFallbackCfg(provider);
const store = makeSingleProviderStore({
provider,
usageStat: params.usageStat,
});
const run = createFallbackOnlyRun();
const result = await runWithStoredAuth({
cfg,
store,
provider,
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
expect(result.attempts[0]?.reason).toBe(params.expectedReason);
}
describe("runWithModelFallback", () => {
it("normalizes openai gpt-5.3 codex to openai-codex before running", async () => {
const cfg = makeCfg();
@@ -310,86 +364,26 @@ describe("runWithModelFallback", () => {
});
it("skips providers when all profiles are in cooldown", async () => {
const provider = `cooldown-test-${crypto.randomUUID()}`;
const profileId = `${provider}:default`;
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[profileId]: {
type: "api_key",
provider,
key: "test-key",
},
await expectSkippedUnavailableProvider({
providerPrefix: "cooldown-test",
usageStat: {
cooldownUntil: Date.now() + 5 * 60_000,
},
usageStats: {
[profileId]: {
cooldownUntil: Date.now() + 5 * 60_000,
},
},
};
const cfg = makeProviderFallbackCfg(provider);
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
if (providerId === "fallback") {
return "ok";
}
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
expectedReason: "rate_limit",
});
const result = await runWithStoredAuth({
cfg,
store,
provider,
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
expect(result.attempts[0]?.reason).toBe("rate_limit");
});
it("propagates disabled reason when all profiles are unavailable", async () => {
const provider = `disabled-test-${crypto.randomUUID()}`;
const profileId = `${provider}:default`;
const now = Date.now();
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[profileId]: {
type: "api_key",
provider,
key: "test-key",
},
await expectSkippedUnavailableProvider({
providerPrefix: "disabled-test",
usageStat: {
disabledUntil: now + 5 * 60_000,
disabledReason: "billing",
failureCounts: { rate_limit: 4 },
},
usageStats: {
[profileId]: {
disabledUntil: now + 5 * 60_000,
disabledReason: "billing",
failureCounts: { rate_limit: 4 },
},
},
};
const cfg = makeProviderFallbackCfg(provider);
const run = vi.fn().mockImplementation(async (providerId, modelId) => {
if (providerId === "fallback") {
return "ok";
}
throw new Error(`unexpected provider: ${providerId}/${modelId}`);
expectedReason: "billing",
});
const result = await runWithStoredAuth({
cfg,
store,
provider,
run,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([["fallback", "ok-model"]]);
expect(result.attempts[0]?.reason).toBe("billing");
});
it("does not skip when any profile is available", async () => {

View File

@@ -10,6 +10,7 @@ import {
withModelsTempHome as withTempHome,
} from "./models-config.e2e-harness.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
installModelsConfigTestHooks();
@@ -34,11 +35,9 @@ describe("models-config", () => {
await ensureOpenClawModelsJson(validated.config);
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { api?: string; models?: Array<{ id: string; api?: string }> }>;
};
}>();
expect(parsed.providers.anthropic?.api).toBe("anthropic-messages");
expect(parsed.providers.anthropic?.models?.[0]?.api).toBe("anthropic-messages");
@@ -74,11 +73,9 @@ describe("models-config", () => {
await ensureOpenClawModelsJson(cfg);
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string; models?: Array<{ id: string }> }>;
};
}>();
expect(parsed.providers.minimax?.apiKey).toBe("MINIMAX_API_KEY");
const ids = parsed.providers.minimax?.models?.map((model) => model.id);
expect(ids).toContain("MiniMax-VL-01");

View File

@@ -1,10 +1,8 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
import { installModelsConfigTestHooks, withModelsTempHome } from "./models-config.e2e-harness.js";
import { ensureOpenClawModelsJson } from "./models-config.js";
import { readGeneratedModelsJson } from "./models-config.test-utils.js";
describe("models-config", () => {
installModelsConfigTestHooks();
@@ -47,11 +45,9 @@ describe("models-config", () => {
await ensureOpenClawModelsJson(cfg);
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
const parsed = JSON.parse(raw) as {
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { models: Array<{ id: string }> }>;
};
}>();
const ids = parsed.providers.google?.models?.map((model) => model.id);
expect(ids).toEqual(["gemini-3-pro-preview", "gemini-3-flash-preview"]);
});

View File

@@ -0,0 +1,9 @@
import fs from "node:fs/promises";
import path from "node:path";
import { resolveOpenClawAgentDir } from "./agent-paths.js";
export async function readGeneratedModelsJson<T>(): Promise<T> {
const modelPath = path.join(resolveOpenClawAgentDir(), "models.json");
const raw = await fs.readFile(modelPath, "utf8");
return JSON.parse(raw) as T;
}

View File

@@ -280,107 +280,105 @@ describe("parseNdjsonStream", () => {
});
});
async function withMockNdjsonFetch(
lines: string[],
run: (fetchMock: ReturnType<typeof vi.fn>) => Promise<void>,
): Promise<void> {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => {
const payload = lines.join("\n");
return new Response(`${payload}\n`, {
status: 200,
headers: { "Content-Type": "application/x-ndjson" },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
try {
await run(fetchMock);
} finally {
globalThis.fetch = originalFetch;
}
}
async function createOllamaTestStream(params: {
baseUrl: string;
options?: { maxTokens?: number; signal?: AbortSignal };
}) {
const streamFn = createOllamaStreamFn(params.baseUrl);
return streamFn(
{
id: "qwen3:32b",
api: "ollama",
provider: "custom-ollama",
contextWindow: 131072,
} as unknown as Parameters<typeof streamFn>[0],
{
messages: [{ role: "user", content: "hello" }],
} as unknown as Parameters<typeof streamFn>[1],
(params.options ?? {}) as unknown as Parameters<typeof streamFn>[2],
);
}
async function collectStreamEvents<T>(stream: AsyncIterable<T>): Promise<T[]> {
const events: T[] = [];
for await (const event of stream) {
events.push(event);
}
return events;
}
describe("createOllamaStreamFn", () => {
it("normalizes /v1 baseUrl and maps maxTokens + signal", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => {
const payload = [
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"ok"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":1}',
].join("\n");
return new Response(`${payload}\n`, {
status: 200,
headers: { "Content-Type": "application/x-ndjson" },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
],
async (fetchMock) => {
const signal = new AbortController().signal;
const stream = await createOllamaTestStream({
baseUrl: "http://ollama-host:11434/v1/",
options: { maxTokens: 123, signal },
});
try {
const streamFn = createOllamaStreamFn("http://ollama-host:11434/v1/");
const signal = new AbortController().signal;
const stream = await streamFn(
{
id: "qwen3:32b",
api: "ollama",
provider: "custom-ollama",
contextWindow: 131072,
} as unknown as Parameters<typeof streamFn>[0],
{
messages: [{ role: "user", content: "hello" }],
} as unknown as Parameters<typeof streamFn>[1],
{
maxTokens: 123,
signal,
} as unknown as Parameters<typeof streamFn>[2],
);
const events = await collectStreamEvents(stream);
expect(events.at(-1)?.type).toBe("done");
const events = [];
for await (const event of stream) {
events.push(event);
}
expect(events.at(-1)?.type).toBe("done");
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("http://ollama-host:11434/api/chat");
expect(requestInit.signal).toBe(signal);
if (typeof requestInit.body !== "string") {
throw new Error("Expected string request body");
}
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, requestInit] = fetchMock.mock.calls[0] as unknown as [string, RequestInit];
expect(url).toBe("http://ollama-host:11434/api/chat");
expect(requestInit.signal).toBe(signal);
if (typeof requestInit.body !== "string") {
throw new Error("Expected string request body");
}
const requestBody = JSON.parse(requestInit.body) as {
options: { num_ctx?: number; num_predict?: number };
};
expect(requestBody.options.num_ctx).toBe(131072);
expect(requestBody.options.num_predict).toBe(123);
} finally {
globalThis.fetch = originalFetch;
}
const requestBody = JSON.parse(requestInit.body) as {
options: { num_ctx?: number; num_predict?: number };
};
expect(requestBody.options.num_ctx).toBe(131072);
expect(requestBody.options.num_predict).toBe(123);
},
);
});
it("accumulates reasoning chunks when content is empty", async () => {
const originalFetch = globalThis.fetch;
const fetchMock = vi.fn(async () => {
const payload = [
await withMockNdjsonFetch(
[
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":"reasoned"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":"","reasoning":" output"},"done":false}',
'{"model":"m","created_at":"t","message":{"role":"assistant","content":""},"done":true,"prompt_eval_count":1,"eval_count":2}',
].join("\n");
return new Response(`${payload}\n`, {
status: 200,
headers: { "Content-Type": "application/x-ndjson" },
});
});
globalThis.fetch = fetchMock as unknown as typeof fetch;
],
async () => {
const stream = await createOllamaTestStream({ baseUrl: "http://ollama-host:11434" });
const events = await collectStreamEvents(stream);
try {
const streamFn = createOllamaStreamFn("http://ollama-host:11434");
const stream = await streamFn(
{
id: "qwen3:32b",
api: "ollama",
provider: "custom-ollama",
contextWindow: 131072,
} as unknown as Parameters<typeof streamFn>[0],
{
messages: [{ role: "user", content: "hello" }],
} as unknown as Parameters<typeof streamFn>[1],
{} as unknown as Parameters<typeof streamFn>[2],
);
const doneEvent = events.at(-1);
if (!doneEvent || doneEvent.type !== "done") {
throw new Error("Expected done event");
}
const events = [];
for await (const event of stream) {
events.push(event);
}
const doneEvent = events.at(-1);
if (!doneEvent || doneEvent.type !== "done") {
throw new Error("Expected done event");
}
expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]);
} finally {
globalThis.fetch = originalFetch;
}
expect(doneEvent.message.content).toEqual([{ type: "text", text: "reasoned output" }]);
},
);
});
});

View File

@@ -43,17 +43,13 @@ vi.mock("../usage.js", () => ({
normalizeUsage: vi.fn((usage?: unknown) =>
usage && typeof usage === "object" ? usage : undefined,
),
derivePromptTokens: vi.fn(
(usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) => {
if (!usage) {
return undefined;
}
const input = usage.input ?? 0;
const cacheRead = usage.cacheRead ?? 0;
const cacheWrite = usage.cacheWrite ?? 0;
const sum = input + cacheRead + cacheWrite;
return sum > 0 ? sum : undefined;
},
derivePromptTokens: vi.fn((usage?: { input?: number; cacheRead?: number; cacheWrite?: number }) =>
usage
? (() => {
const sum = (usage.input ?? 0) + (usage.cacheRead ?? 0) + (usage.cacheWrite ?? 0);
return sum > 0 ? sum : undefined;
})()
: undefined,
),
hasNonzeroUsage: vi.fn(() => false),
}));

View File

@@ -78,6 +78,21 @@ async function emitPngMediaToolResult(
});
}
async function emitUntrustedToolMediaResult(
ctx: EmbeddedPiSubscribeContext,
mediaPathOrUrl: string,
) {
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "plugin_tool",
toolCallId: "tc-1",
isError: false,
result: {
content: [{ type: "text", text: `MEDIA:${mediaPathOrUrl}` }],
},
});
}
describe("handleToolExecutionEnd media emission", () => {
it("does not warn for read tool when path is provided via file_path alias", async () => {
const ctx = createMockContext();
@@ -107,15 +122,7 @@ describe("handleToolExecutionEnd media emission", () => {
const onToolResult = vi.fn();
const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult });
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "plugin_tool",
toolCallId: "tc-1",
isError: false,
result: {
content: [{ type: "text", text: "MEDIA:/tmp/secret.png" }],
},
});
await emitUntrustedToolMediaResult(ctx, "/tmp/secret.png");
expect(onToolResult).not.toHaveBeenCalled();
});
@@ -124,15 +131,7 @@ describe("handleToolExecutionEnd media emission", () => {
const onToolResult = vi.fn();
const ctx = createMockContext({ shouldEmitToolOutput: false, onToolResult });
await handleToolExecutionEnd(ctx, {
type: "tool_execution_end",
toolName: "plugin_tool",
toolCallId: "tc-1",
isError: false,
result: {
content: [{ type: "text", text: "MEDIA:https://example.com/file.png" }],
},
});
await emitUntrustedToolMediaResult(ctx, "https://example.com/file.png");
expect(onToolResult).toHaveBeenCalledWith({
mediaUrls: ["https://example.com/file.png"],

View File

@@ -41,6 +41,24 @@ describe("subscribeEmbeddedPiSession", () => {
return { emit, subscription };
}
function createWriteFailureHarness(params: {
runId: string;
path: string;
content: string;
}): ReturnType<typeof createToolErrorHarness> {
const harness = createToolErrorHarness(params.runId);
emitToolRun({
emit: harness.emit,
toolName: "write",
toolCallId: "w1",
args: { path: params.path, content: params.content },
isError: true,
result: { error: "disk full" },
});
expect(harness.subscription.getLastToolError()?.toolName).toBe("write");
return harness;
}
function emitToolRun(params: {
emit: (evt: unknown) => void;
toolName: string;
@@ -389,17 +407,11 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("keeps unresolved mutating failure when an unrelated tool succeeds", () => {
const { emit, subscription } = createToolErrorHarness("run-tools-1");
emitToolRun({
emit,
toolName: "write",
toolCallId: "w1",
args: { path: "/tmp/demo.txt", content: "next" },
isError: true,
result: { error: "disk full" },
const { emit, subscription } = createWriteFailureHarness({
runId: "run-tools-1",
path: "/tmp/demo.txt",
content: "next",
});
expect(subscription.getLastToolError()?.toolName).toBe("write");
emitToolRun({
emit,
@@ -414,17 +426,11 @@ describe("subscribeEmbeddedPiSession", () => {
});
it("clears unresolved mutating failure when the same action succeeds", () => {
const { emit, subscription } = createToolErrorHarness("run-tools-2");
emitToolRun({
emit,
toolName: "write",
toolCallId: "w1",
args: { path: "/tmp/demo.txt", content: "next" },
isError: true,
result: { error: "disk full" },
const { emit, subscription } = createWriteFailureHarness({
runId: "run-tools-2",
path: "/tmp/demo.txt",
content: "next",
});
expect(subscription.getLastToolError()?.toolName).toBe("write");
emitToolRun({
emit,

View File

@@ -113,6 +113,34 @@ describe("Agent-specific tool filtering", () => {
};
}
function createExecHostDefaultsConfig(
agents: Array<{ id: string; execHost?: "gateway" | "sandbox" }>,
): OpenClawConfig {
return {
tools: {
exec: {
host: "sandbox",
security: "full",
ask: "off",
},
},
agents: {
list: agents.map((agent) => ({
id: agent.id,
...(agent.execHost
? {
tools: {
exec: {
host: agent.execHost,
},
},
}
: {}),
})),
},
};
}
it("should apply global tool policy when no agent-specific policy exists", () => {
const cfg = createMainAgentConfig({
tools: {
@@ -646,30 +674,10 @@ describe("Agent-specific tool filtering", () => {
});
it("should apply agent-specific exec host defaults over global defaults", async () => {
const cfg: OpenClawConfig = {
tools: {
exec: {
host: "sandbox",
security: "full",
ask: "off",
},
},
agents: {
list: [
{
id: "main",
tools: {
exec: {
host: "gateway",
},
},
},
{
id: "helper",
},
],
},
};
const cfg = createExecHostDefaultsConfig([
{ id: "main", execHost: "gateway" },
{ id: "helper" },
]);
const mainTools = createOpenClawCodingTools({
config: cfg,
@@ -716,27 +724,7 @@ describe("Agent-specific tool filtering", () => {
});
it("applies explicit agentId exec defaults when sessionKey is opaque", async () => {
const cfg: OpenClawConfig = {
tools: {
exec: {
host: "sandbox",
security: "full",
ask: "off",
},
},
agents: {
list: [
{
id: "main",
tools: {
exec: {
host: "gateway",
},
},
},
],
},
};
const cfg = createExecHostDefaultsConfig([{ id: "main", execHost: "gateway" }]);
const tools = createOpenClawCodingTools({
config: cfg,

View File

@@ -18,13 +18,10 @@ vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: vi.fn(),
}));
vi.mock("../security/skill-scanner.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../security/skill-scanner.js")>();
return {
...actual,
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
};
});
vi.mock("../security/skill-scanner.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../security/skill-scanner.js")>()),
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
}));
vi.mock("../shared/config-eval.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../shared/config-eval.js")>();

View File

@@ -5,10 +5,11 @@ import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vites
import { createTempHomeEnv } from "../test-utils/temp-home.js";
import { setTempStateDir, writeDownloadSkill } from "./skills-install.download-test-utils.js";
import { installSkill } from "./skills-install.js";
const runCommandWithTimeoutMock = vi.fn();
const scanDirectoryWithSummaryMock = vi.fn();
const fetchWithSsrFGuardMock = vi.fn();
import {
fetchWithSsrFGuardMock,
runCommandWithTimeoutMock,
scanDirectoryWithSummaryMock,
} from "./skills-install.test-mocks.js";
vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
@@ -18,13 +19,10 @@ vi.mock("../infra/net/fetch-guard.js", () => ({
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
}));
vi.mock("../security/skill-scanner.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../security/skill-scanner.js")>();
return {
...actual,
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
};
});
vi.mock("../security/skill-scanner.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../security/skill-scanner.js")>()),
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
}));
async function fileExists(filePath: string): Promise<boolean> {
try {
@@ -77,8 +75,8 @@ function mockTarExtractionFlow(params: {
verboseListOutput: string;
extract: "ok" | "reject";
}) {
runCommandWithTimeoutMock.mockImplementation(async (argv: unknown[]) => {
const cmd = argv as string[];
runCommandWithTimeoutMock.mockImplementation(async (...argv: unknown[]) => {
const cmd = (argv[0] ?? []) as string[];
if (cmd[0] === "tar" && cmd[1] === "tf") {
return runCommandResult({ stdout: params.listOutput });
}

View File

@@ -12,13 +12,10 @@ vi.mock("../process/exec.js", () => ({
runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args),
}));
vi.mock("../security/skill-scanner.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../security/skill-scanner.js")>();
return {
...actual,
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
};
});
vi.mock("../security/skill-scanner.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../security/skill-scanner.js")>()),
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
}));
async function writeInstallableSkill(workspaceDir: string, name: string): Promise<string> {
const skillDir = path.join(workspaceDir, "skills", name);

View File

@@ -1,31 +1,24 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { withEnv } from "../test-utils/env.js";
import { createFixtureSuite } from "../test-utils/fixture-suite.js";
import { writeSkill } from "./skills.e2e-test-helpers.js";
import { buildWorkspaceSkillsPrompt } from "./skills.js";
let fixtureRoot = "";
let fixtureCount = 0;
async function createCaseDir(prefix: string): Promise<string> {
const dir = path.join(fixtureRoot, `${prefix}-${fixtureCount++}`);
await fs.mkdir(dir, { recursive: true });
return dir;
}
const fixtureSuite = createFixtureSuite("openclaw-skills-prompt-suite-");
beforeAll(async () => {
fixtureRoot = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-skills-prompt-suite-"));
await fixtureSuite.setup();
});
afterAll(async () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
await fixtureSuite.cleanup();
});
describe("buildWorkspaceSkillsPrompt", () => {
it("prefers workspace skills over managed skills", async () => {
const workspaceDir = await createCaseDir("workspace");
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
const managedDir = path.join(workspaceDir, ".managed");
const bundledDir = path.join(workspaceDir, ".bundled");
const managedSkillDir = path.join(managedDir, "demo-skill");
@@ -62,7 +55,7 @@ describe("buildWorkspaceSkillsPrompt", () => {
expect(prompt).not.toContain(path.join(bundledSkillDir, "SKILL.md"));
});
it("gates by bins, config, and always", async () => {
const workspaceDir = await createCaseDir("workspace");
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
const skillsDir = path.join(workspaceDir, "skills");
const binDir = path.join(workspaceDir, "bin");
@@ -130,7 +123,7 @@ describe("buildWorkspaceSkillsPrompt", () => {
expect(gatedPrompt).not.toContain("config-skill");
});
it("uses skillKey for config lookups", async () => {
const workspaceDir = await createCaseDir("workspace");
const workspaceDir = await fixtureSuite.createCaseDir("workspace");
const skillDir = path.join(workspaceDir, "skills", "alias-skill");
await writeSkill({
dir: skillDir,

View File

@@ -59,106 +59,92 @@ vi.mock("./subagent-registry.js", () => ({
import { runSubagentAnnounceFlow } from "./subagent-announce.js";
type AnnounceFlowParams = Parameters<typeof runSubagentAnnounceFlow>[0];
const defaultSessionConfig = {
mainKey: "main",
scope: "per-sender",
} as const;
const baseAnnounceFlowParams = {
childSessionKey: "agent:main:subagent:worker",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1_000,
cleanup: "keep",
roundOneReply: "done",
waitForCompletion: false,
outcome: { status: "ok" as const },
} satisfies Omit<AnnounceFlowParams, "childRunId">;
function setConfiguredAnnounceTimeout(timeoutMs: number): void {
configOverride = {
session: defaultSessionConfig,
agents: {
defaults: {
subagents: {
announceTimeoutMs: timeoutMs,
},
},
},
};
}
async function runAnnounceFlowForTest(
childRunId: string,
overrides: Partial<AnnounceFlowParams> = {},
): Promise<void> {
await runSubagentAnnounceFlow({
...baseAnnounceFlowParams,
childRunId,
...overrides,
});
}
function findGatewayCall(predicate: (call: GatewayCall) => boolean): GatewayCall | undefined {
return gatewayCalls.find(predicate);
}
describe("subagent announce timeout config", () => {
beforeEach(() => {
gatewayCalls.length = 0;
sessionStore = {};
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
session: defaultSessionConfig,
};
});
it("uses 60s timeout by default for direct announce agent call", async () => {
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-default-timeout",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1_000,
cleanup: "keep",
roundOneReply: "done",
waitForCompletion: false,
outcome: { status: "ok" },
});
await runAnnounceFlowForTest("run-default-timeout");
const directAgentCall = gatewayCalls.find(
const directAgentCall = findGatewayCall(
(call) => call.method === "agent" && call.expectFinal === true,
);
expect(directAgentCall?.timeoutMs).toBe(60_000);
});
it("honors configured announce timeout for direct announce agent call", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
defaults: {
subagents: {
announceTimeoutMs: 90_000,
},
},
},
};
setConfiguredAnnounceTimeout(90_000);
await runAnnounceFlowForTest("run-config-timeout-agent");
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-config-timeout-agent",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "do thing",
timeoutMs: 1_000,
cleanup: "keep",
roundOneReply: "done",
waitForCompletion: false,
outcome: { status: "ok" },
});
const directAgentCall = gatewayCalls.find(
const directAgentCall = findGatewayCall(
(call) => call.method === "agent" && call.expectFinal === true,
);
expect(directAgentCall?.timeoutMs).toBe(90_000);
});
it("honors configured announce timeout for completion direct send call", async () => {
configOverride = {
session: {
mainKey: "main",
scope: "per-sender",
},
agents: {
defaults: {
subagents: {
announceTimeoutMs: 90_000,
},
},
},
};
await runSubagentAnnounceFlow({
childSessionKey: "agent:main:subagent:worker",
childRunId: "run-config-timeout-send",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
setConfiguredAnnounceTimeout(90_000);
await runAnnounceFlowForTest("run-config-timeout-send", {
requesterOrigin: {
channel: "discord",
to: "12345",
},
task: "do thing",
timeoutMs: 1_000,
cleanup: "keep",
roundOneReply: "done",
waitForCompletion: false,
outcome: { status: "ok" },
expectsCompletionMessage: true,
});
const sendCall = gatewayCalls.find((call) => call.method === "send");
const sendCall = findGatewayCall((call) => call.method === "send");
expect(sendCall?.timeoutMs).toBe(90_000);
});
});

View File

@@ -58,6 +58,7 @@ vi.mock("./subagent-registry.store.js", () => ({
describe("subagent registry steer restarts", () => {
let mod: typeof import("./subagent-registry.js");
type RegisterSubagentRunInput = Parameters<typeof mod.registerSubagentRun>[0];
beforeAll(async () => {
mod = await import("./subagent-registry.js");
@@ -90,6 +91,42 @@ describe("subagent registry steer restarts", () => {
}
};
const createDeferredAnnounceResolver = (): ((value: boolean) => void) => {
let resolveAnnounce!: (value: boolean) => void;
announceSpy.mockImplementationOnce(
() =>
new Promise<boolean>((resolve) => {
resolveAnnounce = resolve;
}),
);
return (value: boolean) => {
resolveAnnounce(value);
};
};
const registerCompletionModeRun = (
runId: string,
childSessionKey: string,
task: string,
options: Partial<Pick<RegisterSubagentRunInput, "spawnMode">> = {},
): void => {
mod.registerSubagentRun({
runId,
childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: {
channel: "discord",
to: "channel:123",
accountId: "work",
},
task,
cleanup: "keep",
expectsCompletionMessage: true,
...options,
});
};
afterEach(async () => {
announceSpy.mockClear();
announceSpy.mockResolvedValue(true);
@@ -159,29 +196,13 @@ describe("subagent registry steer restarts", () => {
it("defers subagent_ended hook for completion-mode runs until announce delivery resolves", async () => {
await withPendingAgentWait(async () => {
let resolveAnnounce!: (value: boolean) => void;
announceSpy.mockImplementationOnce(
() =>
new Promise<boolean>((resolve) => {
resolveAnnounce = resolve;
}),
const resolveAnnounce = createDeferredAnnounceResolver();
registerCompletionModeRun(
"run-completion-delayed",
"agent:main:subagent:completion-delayed",
"completion-mode task",
);
mod.registerSubagentRun({
runId: "run-completion-delayed",
childSessionKey: "agent:main:subagent:completion-delayed",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: {
channel: "discord",
to: "channel:123",
accountId: "work",
},
task: "completion-mode task",
cleanup: "keep",
expectsCompletionMessage: true,
});
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-completion-delayed",
@@ -211,30 +232,14 @@ describe("subagent registry steer restarts", () => {
it("does not emit subagent_ended on completion for persistent session-mode runs", async () => {
await withPendingAgentWait(async () => {
let resolveAnnounce!: (value: boolean) => void;
announceSpy.mockImplementationOnce(
() =>
new Promise<boolean>((resolve) => {
resolveAnnounce = resolve;
}),
const resolveAnnounce = createDeferredAnnounceResolver();
registerCompletionModeRun(
"run-persistent-session",
"agent:main:subagent:persistent-session",
"persistent session task",
{ spawnMode: "session" },
);
mod.registerSubagentRun({
runId: "run-persistent-session",
childSessionKey: "agent:main:subagent:persistent-session",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
requesterOrigin: {
channel: "discord",
to: "channel:123",
accountId: "work",
},
task: "persistent session task",
cleanup: "keep",
expectsCompletionMessage: true,
spawnMode: "session",
});
lifecycleHandler?.({
stream: "lifecycle",
runId: "run-persistent-session",