mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 16:18:26 +00:00
test: dedupe fixtures and test harness setup
This commit is contained in:
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
9
src/agents/models-config.test-utils.ts
Normal file
9
src/agents/models-config.test-utils.ts
Normal 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;
|
||||
}
|
||||
@@ -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" }]);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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),
|
||||
}));
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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")>();
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user