perf: trim agent test runtime imports

This commit is contained in:
Peter Steinberger
2026-04-11 13:16:56 +01:00
parent 5915d7cb6b
commit bb0bfabec8
29 changed files with 527 additions and 458 deletions

View File

@@ -14,6 +14,42 @@ vi.mock("../plugins/provider-runtime.js", () => ({
resolveExternalAuthProfilesWithPlugins: () => [],
}));
vi.mock("./cli-credentials.js", () => ({
readCodexCliCredentialsCached: () => {
const codexHome = process.env.CODEX_HOME;
if (!codexHome) {
return null;
}
try {
const raw = JSON.parse(fs.readFileSync(path.join(codexHome, "auth.json"), "utf8")) as {
tokens?: {
access_token?: unknown;
refresh_token?: unknown;
account_id?: unknown;
};
};
const access = raw.tokens?.access_token;
const refresh = raw.tokens?.refresh_token;
if (typeof access !== "string" || typeof refresh !== "string") {
return null;
}
return {
type: "oauth",
provider: "openai-codex",
access,
refresh,
expires: Date.now() + 60 * 60 * 1000,
accountId: typeof raw.tokens?.account_id === "string" ? raw.tokens.account_id : undefined,
};
} catch {
return null;
}
},
readMiniMaxCliCredentialsCached: () => null,
resetCliCredentialCachesForTest: vi.fn(),
writeCodexCliCredentials: vi.fn(() => false),
}));
describe("ensureAuthProfileStore", () => {
function withTempAgentDir<T>(prefix: string, run: (agentDir: string) => T): T {
const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix));

View File

@@ -54,16 +54,21 @@ const loadPluginManifestRegistry = vi.hoisted(() =>
})),
);
const resolveManifestContractOwnerPluginId = vi.hoisted(() => vi.fn<() => undefined>());
const resolveProviderSyntheticAuthWithPlugin = vi.hoisted(() => vi.fn(() => undefined));
vi.mock("../plugins/manifest-registry.js", () => ({
loadPluginManifestRegistry,
resolveManifestContractOwnerPluginId,
}));
vi.mock("../plugins/provider-runtime.js", () => ({
resolveProviderSyntheticAuthWithPlugin,
}));
describe("provider auth aliases", () => {
beforeEach(() => {
loadPluginManifestRegistry.mockReset();
loadPluginManifestRegistry.mockReturnValue(createFixtureProviderRegistry());
resolveProviderSyntheticAuthWithPlugin.mockReset();
});
it("shares manifest env vars across aliased providers", () => {

View File

@@ -1,166 +0,0 @@
import { mkdtempSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
normalizePluginDiscoveryResult,
resolvePluginDiscoveryProviders,
runProviderCatalog,
} from "../plugins/provider-discovery.js";
import type { ProviderPlugin } from "../plugins/types.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import type { ProviderConfig } from "./models-config.providers.js";
describe("Ollama auto-discovery", () => {
let originalFetch: typeof globalThis.fetch;
let ollamaCatalogProviderPromise: Promise<ProviderPlugin | undefined> | undefined;
afterEach(() => {
vi.unstubAllEnvs();
globalThis.fetch = originalFetch;
delete process.env.OLLAMA_API_KEY;
});
function createCleanProviderDiscoveryEnv(): NodeJS.ProcessEnv {
const env = { ...process.env };
delete env.OPENCLAW_BUNDLED_PLUGINS_DIR;
delete env.OPENCLAW_DISABLE_BUNDLED_PLUGINS;
delete env.OPENCLAW_SKIP_PROVIDERS;
delete env.OPENCLAW_SKIP_CHANNELS;
delete env.OPENCLAW_SKIP_CRON;
delete env.OPENCLAW_TEST_MINIMAL_GATEWAY;
return env;
}
function createCatalogLoadEnv(): NodeJS.ProcessEnv {
originalFetch = globalThis.fetch;
return {
...createCleanProviderDiscoveryEnv(),
OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS: "ollama",
VITEST: "1",
NODE_ENV: "test",
};
}
function createDiscoveryRunEnv(): NodeJS.ProcessEnv {
return {
...createCleanProviderDiscoveryEnv(),
OPENCLAW_TEST_ONLY_PROVIDER_PLUGIN_IDS: "ollama",
VITEST: "",
NODE_ENV: "development",
};
}
async function loadOllamaCatalogProvider(): Promise<ProviderPlugin | undefined> {
ollamaCatalogProviderPromise ??= resolvePluginDiscoveryProviders({
env: createCatalogLoadEnv(),
onlyPluginIds: ["ollama"],
}).then((providers) => providers.find((provider) => provider.id === "ollama"));
return ollamaCatalogProviderPromise;
}
async function runOllamaCatalog(params?: {
explicitProviders?: Record<string, ProviderConfig>;
}): Promise<ProviderConfig | undefined> {
const provider = await loadOllamaCatalogProvider();
if (!provider) {
return undefined;
}
const env = createDiscoveryRunEnv();
const config: OpenClawConfig | undefined = params?.explicitProviders
? { models: { providers: params.explicitProviders } }
: undefined;
const result = await runProviderCatalog({
provider,
config: config ?? {},
agentDir: mkdtempSync(join(tmpdir(), "openclaw-test-")),
env,
resolveProviderApiKey: () => ({
apiKey: env.OLLAMA_API_KEY?.trim() ? env.OLLAMA_API_KEY : undefined,
}),
resolveProviderAuth: () => ({
apiKey: env.OLLAMA_API_KEY?.trim() ? env.OLLAMA_API_KEY : undefined,
mode: env.OLLAMA_API_KEY?.trim() ? "api_key" : "none",
source: env.OLLAMA_API_KEY?.trim() ? "env" : "none",
}),
});
return normalizePluginDiscoveryResult({ provider, result }).ollama as
| ProviderConfig
| undefined;
}
function mockOllamaUnreachable() {
globalThis.fetch = withFetchPreconnect(
vi.fn().mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:11434")),
) as typeof fetch;
}
it("auto-registers ollama provider when models are discovered locally", async () => {
globalThis.fetch = withFetchPreconnect(
vi.fn().mockImplementation(async (url: string | URL) => {
if (String(url).includes("/api/tags")) {
return {
ok: true,
json: async () => ({
models: [{ name: "deepseek-r1:latest" }, { name: "llama3.3:latest" }],
}),
};
}
if (String(url).includes("/api/show")) {
return {
ok: true,
json: async () => ({ model_info: {} }),
};
}
throw new Error(`Unexpected fetch: ${url}`);
}),
) as typeof fetch;
const provider = await runOllamaCatalog();
expect(provider).toBeDefined();
expect(provider?.apiKey).toBe("ollama-local");
expect(provider?.api).toBe("ollama");
expect(provider?.baseUrl).toBe("http://127.0.0.1:11434");
expect(provider?.models).toHaveLength(2);
expect(provider?.models?.[0]?.id).toBe("deepseek-r1:latest");
expect(provider?.models?.[0]?.reasoning).toBe(true);
expect(provider?.models?.[1]?.reasoning).toBe(false);
});
it("does not warn when Ollama is unreachable and not explicitly configured", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockOllamaUnreachable();
const provider = await runOllamaCatalog();
expect(provider).toBeUndefined();
const ollamaWarnings = warnSpy.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("Ollama"),
);
expect(ollamaWarnings).toHaveLength(0);
warnSpy.mockRestore();
});
it("warns when Ollama is unreachable and explicitly configured", async () => {
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
mockOllamaUnreachable();
await runOllamaCatalog({
explicitProviders: {
ollama: {
baseUrl: "http://127.0.0.1:11435/v1",
api: "openai-completions",
models: [],
},
},
});
const ollamaWarnings = warnSpy.mock.calls.filter(
(args) => typeof args[0] === "string" && args[0].includes("Ollama"),
);
expect(ollamaWarnings.length).toBeGreaterThan(0);
warnSpy.mockRestore();
});
});

View File

@@ -239,6 +239,91 @@ describe("Ollama provider", () => {
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 2 });
});
it("auto-registers ollama provider when models are discovered locally", async () => {
await withoutAmbientOllamaEnv(async () => {
enableDiscoveryEnv();
const fetchMock = vi.fn(async (input: unknown) => {
const url = String(input);
if (url.endsWith("/api/tags")) {
return tagsResponse(["deepseek-r1:latest", "llama3.3:latest"]);
}
if (url.endsWith("/api/show")) {
return {
ok: true,
json: async () => ({ model_info: {} }),
};
}
return notFoundJsonResponse();
});
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
env: { VITEST: "", NODE_ENV: "development" },
});
expect(provider?.apiKey).toBe(OLLAMA_LOCAL_AUTH_MARKER);
expect(provider?.api).toBe("ollama");
expect(provider?.baseUrl).toBe("http://127.0.0.1:11434");
expect(provider?.models).toHaveLength(2);
expect(provider?.models?.[0]?.id).toBe("deepseek-r1:latest");
expect(provider?.models?.[0]?.reasoning).toBe(true);
expect(provider?.models?.[1]?.reasoning).toBe(false);
expectDiscoveryCallCounts(fetchMock, { tags: 1, show: 2 });
});
});
it("does not warn when Ollama is unreachable and not explicitly configured", async () => {
await withoutAmbientOllamaEnv(async () => {
enableDiscoveryEnv();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = vi
.fn()
.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:11434"));
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
const provider = await runOllamaCatalog({
env: { VITEST: "", NODE_ENV: "development" },
});
expect(provider).toBeUndefined();
expect(
warnSpy.mock.calls.filter(([message]) => String(message).includes("Ollama")),
).toHaveLength(0);
warnSpy.mockRestore();
});
});
it("warns when Ollama is unreachable and explicitly configured", async () => {
await withoutAmbientOllamaEnv(async () => {
enableDiscoveryEnv();
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
const fetchMock = vi
.fn()
.mockRejectedValue(new Error("connect ECONNREFUSED 127.0.0.1:11434"));
vi.stubGlobal("fetch", withFetchPreconnect(fetchMock));
await runOllamaCatalog({
config: {
models: {
providers: {
ollama: {
baseUrl: "http://127.0.0.1:11435/v1",
api: "openai-completions",
models: [],
},
},
},
},
env: { VITEST: "", NODE_ENV: "development" },
});
expect(
warnSpy.mock.calls.filter(([message]) => String(message).includes("Ollama")).length,
).toBeGreaterThan(0);
warnSpy.mockRestore();
});
});
it("falls back to default context window when /api/show fails", async () => {
const agentDir = createAgentDir();
enableDiscoveryEnv();

View File

@@ -210,6 +210,15 @@ export async function loadCompactHooksHarness(): Promise<{
maybeCompactAgentHarnessSession: vi.fn(async () => undefined),
}));
vi.doMock("../../plugins/provider-runtime.js", () => ({
prepareProviderRuntimeAuth: vi.fn(async () => ({ resolvedApiKey: undefined })),
resolveProviderSystemPromptContribution: vi.fn(() => undefined),
resolveProviderTextTransforms: vi.fn(() => undefined),
transformProviderSystemPrompt: vi.fn(
(params: { systemPrompt?: string }) => params.systemPrompt,
),
}));
vi.doMock("../provider-stream.js", () => ({
registerProviderStreamForModel: registerProviderStreamForModelMock,
}));

View File

@@ -1,7 +1,12 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
drainSessionStoreLockQueuesForTest,
resetSessionStoreLockRuntimeForTests,
setSessionWriteLockAcquirerForTests,
} from "../config/sessions.js";
import {
readCompactionCount,
seedSessionStore,
@@ -50,6 +55,17 @@ function createCompactionContext(params: {
} as unknown as EmbeddedPiSubscribeContext;
}
beforeEach(() => {
setSessionWriteLockAcquirerForTests(async () => ({
release: async () => {},
}));
});
afterEach(async () => {
resetSessionStoreLockRuntimeForTests();
await drainSessionStoreLockQueuesForTest();
});
describe("reconcileSessionStoreCompactionCountAfterSuccess", () => {
it("raises the stored compaction count to the observed value", async () => {
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compaction-reconcile-"));

View File

@@ -1,8 +1,8 @@
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { normalizePluginsConfig, resolveEffectiveEnableState } from "../plugins/config-state.js";
import { loadPluginManifestRegistry } from "../plugins/manifest-registry.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import type { PluginOrigin } from "../plugins/plugin-origin.types.js";
import { normalizeOptionalLowercaseString } from "../shared/string-coerce.js";
import { normalizeProviderId } from "./provider-id.js";
export type ProviderAuthAliasLookupParams = {
@@ -17,6 +17,8 @@ type ProviderAuthAliasCandidate = {
target: string;
};
type PluginEntriesConfig = NonNullable<NonNullable<OpenClawConfig["plugins"]>["entries"]>;
const PROVIDER_AUTH_ALIAS_ORIGIN_PRIORITY: Readonly<Record<PluginOrigin, number>> = {
config: 0,
bundled: 1,
@@ -31,6 +33,56 @@ function resolveProviderAuthAliasOriginPriority(origin: PluginOrigin | undefined
return PROVIDER_AUTH_ALIAS_ORIGIN_PRIORITY[origin] ?? Number.MAX_SAFE_INTEGER;
}
function normalizePluginConfigId(id: unknown): string {
return normalizeOptionalLowercaseString(id) ?? "";
}
function hasPluginId(list: unknown, pluginId: string): boolean {
return Array.isArray(list) && list.some((entry) => normalizePluginConfigId(entry) === pluginId);
}
function findPluginEntry(
entries: PluginEntriesConfig | undefined,
pluginId: string,
): { enabled?: boolean } | undefined {
if (!entries || typeof entries !== "object" || Array.isArray(entries)) {
return undefined;
}
for (const [key, value] of Object.entries(entries)) {
if (normalizePluginConfigId(key) !== pluginId) {
continue;
}
return value && typeof value === "object" && !Array.isArray(value)
? (value as { enabled?: boolean })
: {};
}
return undefined;
}
function isWorkspacePluginTrustedForAuthAliases(
plugin: PluginManifestRecord,
config: OpenClawConfig | undefined,
): boolean {
const pluginsConfig = config?.plugins;
if (pluginsConfig?.enabled === false) {
return false;
}
const pluginId = normalizePluginConfigId(plugin.id);
if (!pluginId || hasPluginId(pluginsConfig?.deny, pluginId)) {
return false;
}
const entry = findPluginEntry(pluginsConfig?.entries, pluginId);
if (entry?.enabled === false) {
return false;
}
if (entry?.enabled === true || hasPluginId(pluginsConfig?.allow, pluginId)) {
return true;
}
return normalizePluginConfigId(pluginsConfig?.slots?.contextEngine) === pluginId;
}
function shouldUsePluginAuthAliases(
plugin: PluginManifestRecord,
params: ProviderAuthAliasLookupParams | undefined,
@@ -38,13 +90,7 @@ function shouldUsePluginAuthAliases(
if (plugin.origin !== "workspace" || params?.includeUntrustedWorkspacePlugins === true) {
return true;
}
const normalizedConfig = normalizePluginsConfig(params?.config?.plugins);
return resolveEffectiveEnableState({
id: plugin.id,
origin: plugin.origin,
config: normalizedConfig,
rootConfig: params?.config,
}).enabled;
return isWorkspacePluginTrustedForAuthAliases(plugin, params?.config);
}
export function resolveProviderAuthAliasMap(

View File

@@ -11,6 +11,7 @@ import { captureEnv } from "../test-utils/env.js";
const hoisted = vi.hoisted(() => ({
announceSpy: vi.fn(async () => true),
allowedRunIds: undefined as Set<string> | undefined,
registryPath: undefined as string | undefined,
}));
const { announceSpy } = hoisted;
@@ -46,10 +47,16 @@ vi.mock("./subagent-registry.store.js", async () => {
runs: Map<string, import("./subagent-registry.types.js").SubagentRunRecord>,
) => {
const pathname = resolvePath();
const persistedRuns = hoisted.allowedRunIds
? new Map([...runs].filter(([runId]) => hoisted.allowedRunIds?.has(runId)))
: runs;
if (hoisted.allowedRunIds && persistedRuns.size === 0 && runs.size > 0) {
return;
}
fsSync.mkdirSync(pathSync.dirname(pathname), { recursive: true });
fsSync.writeFileSync(
pathname,
`${JSON.stringify({ version: 2, runs: Object.fromEntries(runs) }, null, 2)}\n`,
`${JSON.stringify({ version: 2, runs: Object.fromEntries(persistedRuns) }, null, 2)}\n`,
"utf8",
);
},
@@ -115,8 +122,16 @@ describe("subagent registry persistence resume", () => {
beforeEach(async () => {
announceSpy.mockClear();
vi.mocked(callGatewayModule.callGateway).mockReset();
vi.mocked(callGatewayModule.callGateway).mockResolvedValue({
status: "ok",
startedAt: 111,
endedAt: 222,
});
mod.__testing.setDepsForTest({
callGateway: vi.mocked(callGatewayModule.callGateway),
cleanupBrowserSessionsForLifecycleEnd: vi.fn(async () => {}),
captureSubagentCompletionReply: vi.fn(async () => undefined),
ensureContextEnginesInitialized: vi.fn(),
ensureRuntimePluginsLoaded: vi.fn(),
loadConfig: vi.fn(() => ({})),
@@ -129,12 +144,6 @@ describe("subagent registry persistence resume", () => {
})),
});
mod.resetSubagentRegistryForTests({ persist: false });
vi.mocked(callGatewayModule.callGateway).mockReset();
vi.mocked(callGatewayModule.callGateway).mockResolvedValue({
status: "ok",
startedAt: 111,
endedAt: 222,
});
vi.mocked(agentEventsModule.onAgentEvent).mockReset();
vi.mocked(agentEventsModule.onAgentEvent).mockReturnValue(() => undefined);
});
@@ -150,6 +159,7 @@ describe("subagent registry persistence resume", () => {
tempStateDir = null;
}
hoisted.registryPath = undefined;
hoisted.allowedRunIds = undefined;
envSnapshot.restore();
});
@@ -158,6 +168,7 @@ describe("subagent registry persistence resume", () => {
process.env.OPENCLAW_STATE_DIR = tempStateDir;
const registryPath = path.join(tempStateDir, "subagents", "runs.json");
hoisted.registryPath = registryPath;
hoisted.allowedRunIds = new Set(["run-1"]);
let releaseInitialWait:
| ((value: { status: "ok"; startedAt: number; endedAt: number }) => void)

View File

@@ -181,6 +181,17 @@ describe("subagent registry persistence", () => {
beforeEach(() => {
__testing.setDepsForTest({
cleanupBrowserSessionsForLifecycleEnd: vi.fn(async () => {}),
ensureContextEnginesInitialized: vi.fn(),
ensureRuntimePluginsLoaded: vi.fn(),
loadConfig: vi.fn(() => ({})),
resolveAgentTimeoutMs: vi.fn(() => 100),
resolveContextEngine: vi.fn(async () => ({
info: { id: "test", name: "Test", version: "0.0.1" },
ingest: vi.fn(async () => ({ ingested: false })),
assemble: vi.fn(async ({ messages }) => ({ messages, estimatedTokens: 0 })),
compact: vi.fn(async () => ({ ok: false, compacted: false })),
})),
runSubagentAnnounceFlow: announceSpy,
});
vi.mocked(callGateway).mockReset();
@@ -362,11 +373,7 @@ describe("subagent registry persistence", () => {
tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-subagent-"));
process.env.OPENCLAW_STATE_DIR = tempStateDir;
vi.mocked(callGateway).mockResolvedValue({
status: "ok",
startedAt: 111,
endedAt: 222,
});
vi.mocked(callGateway).mockImplementationOnce(async () => await new Promise(() => {}));
registerSubagentRun({
runId: " run-live ",

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
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";
import {
createSubagentSpawnTestConfig,
loadSubagentSpawnModuleForTest,
@@ -14,7 +14,15 @@ let configOverride: Record<string, unknown> = {
...createSubagentSpawnTestConfig(),
};
let workspaceDirOverride = "";
let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests;
let subagentSpawnModule: Awaited<ReturnType<typeof loadSubagentSpawnModuleForTest>>;
beforeAll(async () => {
subagentSpawnModule = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride || os.tmpdir(),
});
});
// --- decodeStrictBase64 ---
@@ -22,11 +30,7 @@ describe("decodeStrictBase64", () => {
const maxBytes = 1024;
it("valid base64 returns buffer with correct bytes", async () => {
const { decodeStrictBase64 } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride || os.tmpdir(),
});
const { decodeStrictBase64 } = subagentSpawnModule;
const input = "hello world";
const encoded = Buffer.from(input).toString("base64");
const result = decodeStrictBase64(encoded, maxBytes);
@@ -35,69 +39,41 @@ describe("decodeStrictBase64", () => {
});
it("empty string returns null", async () => {
const { decodeStrictBase64 } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride || os.tmpdir(),
});
const { decodeStrictBase64 } = subagentSpawnModule;
expect(decodeStrictBase64("", maxBytes)).toBeNull();
});
it("bad padding (length % 4 !== 0) returns null", async () => {
const { decodeStrictBase64 } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride || os.tmpdir(),
});
const { decodeStrictBase64 } = subagentSpawnModule;
expect(decodeStrictBase64("abc", maxBytes)).toBeNull();
});
it("non-base64 chars returns null", async () => {
const { decodeStrictBase64 } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride || os.tmpdir(),
});
const { decodeStrictBase64 } = subagentSpawnModule;
expect(decodeStrictBase64("!@#$", maxBytes)).toBeNull();
});
it("whitespace-only returns null (empty after strip)", async () => {
const { decodeStrictBase64 } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride || os.tmpdir(),
});
const { decodeStrictBase64 } = subagentSpawnModule;
expect(decodeStrictBase64(" ", maxBytes)).toBeNull();
});
it("pre-decode oversize guard: encoded string > maxEncodedBytes * 2 returns null", async () => {
const { decodeStrictBase64 } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride || os.tmpdir(),
});
const { decodeStrictBase64 } = subagentSpawnModule;
// maxEncodedBytes = ceil(1024/3)*4 = 1368; *2 = 2736
const oversized = "A".repeat(2737);
expect(decodeStrictBase64(oversized, maxBytes)).toBeNull();
});
it("decoded byteLength exceeds maxDecodedBytes returns null", async () => {
const { decodeStrictBase64 } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride || os.tmpdir(),
});
const { decodeStrictBase64 } = subagentSpawnModule;
const bigBuf = Buffer.alloc(1025, 0x42);
const encoded = bigBuf.toString("base64");
expect(decodeStrictBase64(encoded, maxBytes)).toBeNull();
});
it("valid base64 at exact boundary returns Buffer", async () => {
const { decodeStrictBase64 } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride || os.tmpdir(),
});
const { decodeStrictBase64 } = subagentSpawnModule;
const exactBuf = Buffer.alloc(1024, 0x41);
const encoded = exactBuf.toString("base64");
const result = decodeStrictBase64(encoded, maxBytes);
@@ -114,12 +90,7 @@ describe("spawnSubagentDirect filename validation", () => {
path.join(os.tmpdir(), `openclaw-subagent-attachments-${process.pid}-${Date.now()}-`),
);
configOverride = createSubagentSpawnTestConfig(workspaceDirOverride);
({ resetSubagentRegistryForTests } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride,
}));
resetSubagentRegistryForTests();
subagentSpawnModule.resetSubagentRegistryForTests();
callGatewayMock.mockClear();
setupAcceptedSubagentGatewayMock(callGatewayMock);
});
@@ -141,11 +112,7 @@ describe("spawnSubagentDirect filename validation", () => {
const validContent = Buffer.from("hello").toString("base64");
async function spawnWithName(name: string) {
const { spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride,
});
const { spawnSubagentDirect } = subagentSpawnModule;
return spawnSubagentDirect(
{
task: "test",
@@ -180,11 +147,7 @@ describe("spawnSubagentDirect filename validation", () => {
});
it("duplicate name returns attachments_duplicate_name", async () => {
const { spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride,
});
const { spawnSubagentDirect } = subagentSpawnModule;
const result = await spawnSubagentDirect(
{
task: "test",
@@ -219,11 +182,7 @@ describe("spawnSubagentDirect filename validation", () => {
return {};
});
const { spawnSubagentDirect } = await loadSubagentSpawnModuleForTest({
callGatewayMock,
loadConfig: () => configOverride,
workspaceDir: workspaceDirOverride,
});
const { spawnSubagentDirect } = subagentSpawnModule;
const result = await spawnSubagentDirect(
{
task: "test",

View File

@@ -1,5 +1,5 @@
import sharp from "sharp";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const { infoMock, warnMock } = vi.hoisted(() => ({
infoMock: vi.fn(),
@@ -25,8 +25,8 @@ vi.mock("../logging/subsystem.js", () => {
import { sanitizeContentBlocksImages } from "./tool-images.js";
async function createLargePng(): Promise<Buffer> {
const width = 2400;
const height = 680;
const width = 2001;
const height = 8;
const raw = Buffer.alloc(width * height * 3, 0x7f);
return await sharp(raw, {
raw: { width, height, channels: 3 },
@@ -36,13 +36,18 @@ async function createLargePng(): Promise<Buffer> {
}
describe("tool-images log context", () => {
let png: Buffer;
beforeAll(async () => {
png = await createLargePng();
});
beforeEach(() => {
infoMock.mockClear();
warnMock.mockClear();
});
it("includes filename from MEDIA text", async () => {
const png = await createLargePng();
const blocks = [
{ type: "text" as const, text: "MEDIA:/tmp/snapshots/camera-front.png" },
{ type: "image" as const, data: png.toString("base64"), mimeType: "image/png" },
@@ -53,7 +58,6 @@ describe("tool-images log context", () => {
});
it("includes filename from read label", async () => {
const png = await createLargePng();
const blocks = [
{ type: "image" as const, data: png.toString("base64"), mimeType: "image/png" },
];

View File

@@ -172,7 +172,23 @@ function createFalEditProvider(params?: {
describe("createImageGenerateTool", () => {
beforeAll(async () => {
vi.doUnmock("../../secrets/provider-env-vars.js");
vi.doMock("../../secrets/provider-env-vars.js", async () => {
const actual = await vi.importActual<typeof import("../../secrets/provider-env-vars.js")>(
"../../secrets/provider-env-vars.js",
);
return {
...actual,
getProviderEnvVars: (providerId: string) => {
if (providerId === "google") {
return ["GEMINI_API_KEY", "GOOGLE_API_KEY"];
}
if (providerId === "openai") {
return ["OPENAI_API_KEY"];
}
return [];
},
};
});
imageGenerationRuntime = await import("../../image-generation/runtime.js");
imageOps = await import("../../media/image-ops.js");
mediaStore = await import("../../media/store.js");

View File

@@ -287,6 +287,7 @@ describe("message tool secret scoping", () => {
currentChannelProvider: "discord",
agentAccountId: "ops",
loadConfig: mocks.loadConfig as never,
getScopedChannelsCommandSecretTargets: mocks.getScopedChannelsCommandSecretTargets as never,
resolveCommandSecretRefsViaGateway: mocks.resolveCommandSecretRefsViaGateway as never,
runMessageAction: mocks.runMessageAction as never,
});

View File

@@ -398,6 +398,7 @@ type MessageToolOptions = {
sessionId?: string;
config?: OpenClawConfig;
loadConfig?: () => OpenClawConfig;
getScopedChannelsCommandSecretTargets?: typeof getScopedChannelsCommandSecretTargets;
resolveCommandSecretRefsViaGateway?: typeof resolveCommandSecretRefsViaGateway;
runMessageAction?: typeof runMessageAction;
currentChannelId?: string;
@@ -616,6 +617,8 @@ function appendMessageToolReadHint(
export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
const loadConfigForTool = options?.loadConfig ?? loadConfig;
const getScopedSecretTargetsForTool =
options?.getScopedChannelsCommandSecretTargets ?? getScopedChannelsCommandSecretTargets;
const resolveSecretRefsForTool =
options?.resolveCommandSecretRefsViaGateway ?? resolveCommandSecretRefsViaGateway;
const runMessageActionForTool = options?.runMessageAction ?? runMessageAction;
@@ -693,7 +696,7 @@ export function createMessageTool(options?: MessageToolOptions): AnyAgentTool {
accountId: params.accountId,
fallbackAccountId: agentAccountId,
});
const scopedTargets = getScopedChannelsCommandSecretTargets({
const scopedTargets = getScopedSecretTargetsForTool({
config: loadedRaw,
channel: scope.channel,
accountId: scope.accountId,

View File

@@ -1,6 +1,6 @@
import { getAcpSessionManager } from "../../acp/control-plane/manager.js";
import { resolveSessionAgentId } from "../../agents/agent-scope.js";
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import { abortEmbeddedPiRun } from "../../agents/pi-embedded-runner/runs.js";
import {
getLatestSubagentRunByChildSessionKey,
listSubagentRunsForController,

View File

@@ -5,7 +5,6 @@ import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-bu
import { estimateMessagesTokens } from "../../agents/compaction.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { compactEmbeddedPiSession, runEmbeddedPiAgent } from "../../agents/pi-embedded.js";
import { resolveSandboxConfigForAgent, resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import {
derivePromptTokens,
@@ -45,10 +44,26 @@ import { refreshQueuedFollowupSession, type FollowupRun } from "./queue.js";
import type { ReplyOperation } from "./reply-run-registry.js";
import { incrementCompactionCount } from "./session-updates.js";
async function compactEmbeddedPiSessionDefault(
...args: Parameters<typeof import("../../agents/pi-embedded.js").compactEmbeddedPiSession>
): Promise<
Awaited<ReturnType<typeof import("../../agents/pi-embedded.js").compactEmbeddedPiSession>>
> {
const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js");
return await compactEmbeddedPiSession(...args);
}
async function runEmbeddedPiAgentDefault(
...args: Parameters<typeof import("../../agents/pi-embedded.js").runEmbeddedPiAgent>
): Promise<Awaited<ReturnType<typeof import("../../agents/pi-embedded.js").runEmbeddedPiAgent>>> {
const { runEmbeddedPiAgent } = await import("../../agents/pi-embedded.js");
return await runEmbeddedPiAgent(...args);
}
const memoryDeps = {
compactEmbeddedPiSession,
compactEmbeddedPiSession: compactEmbeddedPiSessionDefault,
runWithModelFallback,
runEmbeddedPiAgent,
runEmbeddedPiAgent: runEmbeddedPiAgentDefault,
registerAgentRunContext,
refreshQueuedFollowupSession,
incrementCompactionCount,
@@ -60,8 +75,8 @@ const memoryDeps = {
export function setAgentRunnerMemoryTestDeps(overrides?: Partial<typeof memoryDeps>): void {
Object.assign(memoryDeps, {
runWithModelFallback,
compactEmbeddedPiSession,
runEmbeddedPiAgent,
compactEmbeddedPiSession: compactEmbeddedPiSessionDefault,
runEmbeddedPiAgent: runEmbeddedPiAgentDefault,
registerAgentRunContext,
refreshQueuedFollowupSession,
incrementCompactionCount,

View File

@@ -2,7 +2,7 @@ import { resolveContextTokensForModel } from "../../agents/context.js";
import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { resolveModelAuthMode } from "../../agents/model-auth.js";
import { isCliProvider } from "../../agents/model-selection.js";
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded.js";
import { queueEmbeddedPiMessage } from "../../agents/pi-embedded-runner/runs.js";
import { hasNonzeroUsage } from "../../agents/usage.js";
import {
loadSessionStore,

View File

@@ -90,6 +90,13 @@ vi.mock("../../acp/runtime/session-meta.js", () => ({
resolveSessionStorePathForAcp: (args: unknown) => hoisted.resolveSessionStorePathForAcpMock(args),
}));
vi.mock("../../agents/acp-spawn.js", () => ({
resolveAcpSpawnRuntimePolicyError: (params: { cfg?: OpenClawConfig }) =>
params.cfg?.agents?.defaults?.sandbox?.mode === "all"
? 'Sandboxed sessions cannot spawn ACP sessions because runtime="acp" runs on the host. Use runtime="subagent" from sandboxed sessions.'
: undefined,
}));
vi.mock("../../config/sessions.js", async () => {
const actual = await vi.importActual<typeof import("../../config/sessions.js")>(
"../../config/sessions.js",

View File

@@ -1,26 +1,5 @@
import { logVerbose } from "../../globals.js";
import { requireGatewayClientScopeForInternalChannel } from "./command-gates.js";
import {
handleAcpDoctorAction,
handleAcpInstallAction,
handleAcpSessionsAction,
} from "./commands-acp/diagnostics.js";
import {
handleAcpCancelAction,
handleAcpCloseAction,
handleAcpSpawnAction,
handleAcpSteerAction,
} from "./commands-acp/lifecycle.js";
import {
handleAcpCwdAction,
handleAcpModelAction,
handleAcpPermissionsAction,
handleAcpResetOptionsAction,
handleAcpSetAction,
handleAcpSetModeAction,
handleAcpStatusAction,
handleAcpTimeoutAction,
} from "./commands-acp/runtime-options.js";
import {
COMMAND,
type AcpAction,
@@ -39,23 +18,57 @@ type AcpActionHandler = (
tokens: string[],
) => Promise<CommandHandlerResult>;
const ACP_ACTION_HANDLERS: Record<Exclude<AcpAction, "help">, AcpActionHandler> = {
spawn: handleAcpSpawnAction,
cancel: handleAcpCancelAction,
steer: handleAcpSteerAction,
close: handleAcpCloseAction,
status: handleAcpStatusAction,
"set-mode": handleAcpSetModeAction,
set: handleAcpSetAction,
cwd: handleAcpCwdAction,
permissions: handleAcpPermissionsAction,
timeout: handleAcpTimeoutAction,
model: handleAcpModelAction,
"reset-options": handleAcpResetOptionsAction,
doctor: handleAcpDoctorAction,
install: async (params, tokens) => handleAcpInstallAction(params, tokens),
sessions: async (params, tokens) => handleAcpSessionsAction(params, tokens),
};
let lifecycleHandlersPromise: Promise<typeof import("./commands-acp/lifecycle.js")> | undefined;
let runtimeOptionHandlersPromise:
| Promise<typeof import("./commands-acp/runtime-options.js")>
| undefined;
let diagnosticHandlersPromise: Promise<typeof import("./commands-acp/diagnostics.js")> | undefined;
async function loadAcpActionHandler(action: Exclude<AcpAction, "help">): Promise<AcpActionHandler> {
if (action === "spawn" || action === "cancel" || action === "steer" || action === "close") {
lifecycleHandlersPromise ??= import("./commands-acp/lifecycle.js");
const handlers = await lifecycleHandlersPromise;
return {
spawn: handlers.handleAcpSpawnAction,
cancel: handlers.handleAcpCancelAction,
steer: handlers.handleAcpSteerAction,
close: handlers.handleAcpCloseAction,
}[action];
}
if (
action === "status" ||
action === "set-mode" ||
action === "set" ||
action === "cwd" ||
action === "permissions" ||
action === "timeout" ||
action === "model" ||
action === "reset-options"
) {
runtimeOptionHandlersPromise ??= import("./commands-acp/runtime-options.js");
const handlers = await runtimeOptionHandlersPromise;
return {
status: handlers.handleAcpStatusAction,
"set-mode": handlers.handleAcpSetModeAction,
set: handlers.handleAcpSetAction,
cwd: handlers.handleAcpCwdAction,
permissions: handlers.handleAcpPermissionsAction,
timeout: handlers.handleAcpTimeoutAction,
model: handlers.handleAcpModelAction,
"reset-options": handlers.handleAcpResetOptionsAction,
}[action];
}
diagnosticHandlersPromise ??= import("./commands-acp/diagnostics.js");
const handlers = await diagnosticHandlersPromise;
const diagnosticHandlers: Record<"doctor" | "install" | "sessions", AcpActionHandler> = {
doctor: handlers.handleAcpDoctorAction,
install: async (params, tokens) => handlers.handleAcpInstallAction(params, tokens),
sessions: async (params, tokens) => handlers.handleAcpSessionsAction(params, tokens),
};
return diagnosticHandlers[action];
}
const ACP_MUTATING_ACTIONS = new Set<AcpAction>([
"spawn",
@@ -105,6 +118,6 @@ export const handleAcpCommand: CommandHandler = async (params, allowTextCommands
}
}
const handler = ACP_ACTION_HANDLERS[action];
return handler ? await handler(params, tokens) : stopWithText(resolveAcpHelpText());
const handler = await loadAcpActionHandler(action);
return await handler(params, tokens);
};

View File

@@ -12,12 +12,12 @@ import {
} from "../../../shared/string-coerce.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandBindingContext } from "./context.js";
import { resolveAcpInstallCommandHint } from "./install-hints.js";
import {
ACP_DOCTOR_USAGE,
ACP_INSTALL_USAGE,
ACP_SESSIONS_USAGE,
formatAcpCapabilitiesText,
resolveAcpInstallCommandHint,
stopWithText,
} from "./shared.js";
import { resolveBoundAcpThreadSessionKey } from "./targets.js";

View File

@@ -1,7 +1,6 @@
import { existsSync } from "node:fs";
import path from "node:path";
import type { OpenClawConfig } from "../../../config/types.openclaw.js";
import { resolveBundledPluginWorkspaceSourcePath } from "../../../plugins/bundled-plugin-metadata.js";
import { resolveBundledPluginInstallCommandHint } from "../../../plugins/bundled-sources.js";
import {
normalizeOptionalLowercaseString,
@@ -16,11 +15,8 @@ export function resolveAcpInstallCommandHint(cfg: OpenClawConfig): string {
const workspaceDir = process.cwd();
const backendId = normalizeOptionalLowercaseString(cfg.acp?.backend) ?? "acpx";
if (backendId === "acpx") {
const workspaceLocalPath = resolveBundledPluginWorkspaceSourcePath({
rootDir: workspaceDir,
pluginId: backendId,
});
if (workspaceLocalPath && existsSync(workspaceLocalPath)) {
const workspaceLocalPath = path.join(workspaceDir, "extensions", "acpx");
if (existsSync(workspaceLocalPath)) {
return `openclaw plugins install ${workspaceLocalPath}`;
}
const bundledInstallHint = resolveBundledPluginInstallCommandHint({

View File

@@ -11,7 +11,6 @@ import {
} from "../../../shared/string-coerce.js";
import type { CommandHandlerResult, HandleCommandsParams } from "../commands-types.js";
import { resolveAcpCommandChannel, resolveAcpCommandThreadId } from "./context.js";
export { resolveAcpInstallCommandHint } from "./install-hints.js";
export const COMMAND = "/acp";
export const ACP_SPAWN_USAGE =

View File

@@ -1,4 +1,3 @@
import { abortEmbeddedPiRun } from "../../agents/pi-embedded.js";
import type { SessionEntry } from "../../config/sessions.js";
import { logVerbose } from "../../globals.js";
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
@@ -27,6 +26,11 @@ type AbortTarget = {
sessionId?: string;
};
async function abortEmbeddedPiRunForSession(sessionId: string): Promise<void> {
const { abortEmbeddedPiRun } = await import("../../agents/pi-embedded-runner/runs.js");
abortEmbeddedPiRun(sessionId);
}
function resolveAbortTarget(params: {
ctx: { CommandTargetSessionKey?: string | null };
sessionKey?: string;
@@ -90,7 +94,7 @@ async function applyAbortTarget(params: {
replyRunRegistry.abort(abortTarget.key);
}
if (abortTarget.sessionId) {
abortEmbeddedPiRun(abortTarget.sessionId);
await abortEmbeddedPiRunForSession(abortTarget.sessionId);
}
const persisted = await persistAbortTargetEntry({

View File

@@ -1,7 +1,10 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { resolveSessionAgentIds } from "../../agents/agent-scope.js";
import { resolveBootstrapContextForRun } from "../../agents/bootstrap-files.js";
import { createOpenClawCodingTools } from "../../agents/pi-tools.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
import { buildAgentSystemPrompt } from "../../agents/system-prompt.js";
import { resolveCommandsSystemPromptBundle } from "./commands-system-prompt.js";
import type { HandleCommandsParams } from "./commands-types.js";
const { createOpenClawCodingToolsMock } = vi.hoisted(() => ({
@@ -49,6 +52,14 @@ vi.mock("../../agents/system-prompt.js", () => ({
buildAgentSystemPrompt: vi.fn(() => "system prompt"),
}));
vi.mock("../../agents/pi-tools.js", () => ({
createOpenClawCodingTools: createOpenClawCodingToolsMock,
}));
vi.mock("../../tts/tts.js", () => ({
buildTtsSystemPromptHint: vi.fn(() => undefined),
}));
vi.mock("../../infra/skills-remote.js", () => ({
getRemoteSkillEligibility: vi.fn(() => false),
}));
@@ -103,24 +114,16 @@ function makeParams(): HandleCommandsParams {
}
describe("resolveCommandsSystemPromptBundle", () => {
beforeEach(async () => {
vi.restoreAllMocks();
vi.resetModules();
beforeEach(() => {
vi.clearAllMocks();
createOpenClawCodingToolsMock.mockClear();
createOpenClawCodingToolsMock.mockReturnValue([]);
const piTools = await import("../../agents/pi-tools.js");
vi.spyOn(piTools, "createOpenClawCodingTools").mockImplementation(
createOpenClawCodingToolsMock,
);
const ttsRuntime = await import("../../tts/tts.js");
vi.spyOn(ttsRuntime, "buildTtsSystemPromptHint").mockReturnValue(undefined);
});
it("opts command tool builds into gateway subagent binding", async () => {
const { resolveCommandsSystemPromptBundle } = await import("./commands-system-prompt.js");
await resolveCommandsSystemPromptBundle(makeParams());
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect(vi.mocked(createOpenClawCodingTools)).toHaveBeenCalledWith(
expect.objectContaining({
allowGatewaySubagentBinding: true,
sessionKey: "agent:main:default",
@@ -139,7 +142,6 @@ describe("resolveCommandsSystemPromptBundle", () => {
params.ctx.SessionKey = "agent:main:telegram:slash-session";
params.sessionKey = "agent:main:telegram:direct:target-session";
const { resolveCommandsSystemPromptBundle } = await import("./commands-system-prompt.js");
await resolveCommandsSystemPromptBundle(params);
expect(vi.mocked(resolveSandboxRuntimeStatus)).toHaveBeenCalledWith({
@@ -157,10 +159,9 @@ describe("resolveCommandsSystemPromptBundle", () => {
defaultAgentId: "main",
});
const { resolveCommandsSystemPromptBundle } = await import("./commands-system-prompt.js");
await resolveCommandsSystemPromptBundle(params);
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect(vi.mocked(createOpenClawCodingTools)).toHaveBeenCalledWith(
expect.objectContaining({
agentId: "target",
sessionKey: "agent:target:telegram:direct:target-session",
@@ -190,7 +191,6 @@ describe("resolveCommandsSystemPromptBundle", () => {
} as HandleCommandsParams["sessionStore"];
params.sessionKey = "agent:target:telegram:direct:target-session";
const { resolveCommandsSystemPromptBundle } = await import("./commands-system-prompt.js");
await resolveCommandsSystemPromptBundle(params);
expect(vi.mocked(resolveBootstrapContextForRun)).toHaveBeenCalledWith(
@@ -198,7 +198,7 @@ describe("resolveCommandsSystemPromptBundle", () => {
sessionId: "target-session",
}),
);
expect(createOpenClawCodingToolsMock).toHaveBeenCalledWith(
expect(vi.mocked(createOpenClawCodingTools)).toHaveBeenCalledWith(
expect.objectContaining({
groupId: "target-group",
groupChannel: "#target",
@@ -209,11 +209,7 @@ describe("resolveCommandsSystemPromptBundle", () => {
});
it("uses the resolved session key and forwards full-access block reasons", async () => {
const { resolveCommandsSystemPromptBundle } = await import("./commands-system-prompt.js");
const sandboxRuntime = await import("../../agents/sandbox.js");
const systemPromptRuntime = await import("../../agents/system-prompt.js");
vi.mocked(sandboxRuntime.resolveSandboxRuntimeStatus).mockImplementation(({ sessionKey }) => {
vi.mocked(resolveSandboxRuntimeStatus).mockImplementation(({ sessionKey }) => {
expect(sessionKey).toBe("agent:target:default");
return { sandboxed: true, mode: "workspace-write" } as never;
});
@@ -229,7 +225,7 @@ describe("resolveCommandsSystemPromptBundle", () => {
await resolveCommandsSystemPromptBundle(params);
expect(vi.mocked(systemPromptRuntime.buildAgentSystemPrompt)).toHaveBeenCalledWith(
expect(vi.mocked(buildAgentSystemPrompt)).toHaveBeenCalledWith(
expect.objectContaining({
sandboxInfo: expect.objectContaining({
enabled: true,

View File

@@ -43,21 +43,18 @@ const channelPluginMocks = vi.hoisted(() => ({
}),
}));
vi.mock("../../tts/tts.js", () => ({
vi.mock("./dispatch-acp-tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.mock("./route-reply.js", () => ({
vi.mock("./route-reply.runtime.js", () => ({
routeReply: (params: unknown) => deliveryMocks.routeReply(params),
}));
vi.mock("../../channels/plugins/index.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../channels/plugins/index.js")>();
return {
...actual,
getChannelPlugin: (channelId: string) => channelPluginMocks.getChannelPlugin(channelId),
};
});
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: (channelId: string) => channelPluginMocks.getChannelPlugin(channelId),
normalizeChannelId: (channelId?: string | null) => channelId?.trim().toLowerCase() || null,
}));
vi.mock("../../infra/outbound/message-action-runner.js", () => ({
runMessageAction: (params: unknown) => deliveryMocks.runMessageAction(params),

View File

@@ -9,6 +9,7 @@ import type { SessionBindingRecord } from "../../infra/outbound/session-binding-
import type { MediaUnderstandingSkipError } from "../../media-understanding/errors.js";
import { withFetchPreconnect } from "../../test-utils/fetch-mock.js";
import { resolveAcpAttachments } from "./dispatch-acp-attachments.js";
import { tryDispatchAcpReply } from "./dispatch-acp.js";
import type { ReplyDispatcher } from "./reply-dispatcher.js";
import { buildTestCtx } from "./test-ctx.js";
import { createAcpSessionMeta, createAcpTestConfig } from "./test-fixtures/acp-runtime.js";
@@ -79,10 +80,88 @@ const bindingServiceMocks = vi.hoisted(() => ({
unbind: vi.fn<(input: unknown) => Promise<SessionBindingRecord[]>>(async () => []),
}));
vi.mock("./dispatch-acp-manager.runtime.js", () => ({
getAcpSessionManager: () => managerMocks,
getSessionBindingService: () => ({
listBySession: (targetSessionKey: string) =>
bindingServiceMocks.listBySession(targetSessionKey),
unbind: (input: unknown) => bindingServiceMocks.unbind(input),
}),
}));
vi.mock("../../acp/policy.js", () => ({
resolveAcpDispatchPolicyError: (cfg: OpenClawConfig) =>
policyMocks.resolveAcpDispatchPolicyError(cfg),
resolveAcpAgentPolicyError: (cfg: OpenClawConfig, agent: string) =>
policyMocks.resolveAcpAgentPolicyError(cfg, agent),
}));
vi.mock("./route-reply.runtime.js", () => ({
routeReply: (params: unknown) => routeMocks.routeReply(params),
}));
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: (channelId: string) => channelPluginMocks.getChannelPlugin(channelId),
getLoadedChannelPlugin: (channelId: string) => channelPluginMocks.getChannelPlugin(channelId),
normalizeChannelId: (channelId?: string | null) => channelId?.trim().toLowerCase() || null,
}));
vi.mock("../../infra/outbound/message-action-runner.js", () => ({
runMessageAction: (params: unknown) => messageActionMocks.runMessageAction(params),
}));
vi.mock("./dispatch-acp-tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.mock("../../tts/status-config.js", () => ({
resolveStatusTtsSnapshot: () => ({
autoMode: "always",
provider: "auto",
maxLength: 1500,
summarize: true,
}),
}));
vi.mock("./dispatch-acp-media.runtime.js", () => ({
applyMediaUnderstanding: (params: unknown) =>
mediaUnderstandingMocks.applyMediaUnderstanding(params),
isMediaUnderstandingSkipError: (error: unknown): error is MediaUnderstandingSkipError =>
error instanceof Error && error.name === "MediaUnderstandingSkipError",
normalizeAttachments: (ctx: { MediaPath?: string; MediaType?: string }) =>
ctx.MediaPath
? [
{
path: ctx.MediaPath,
mime: ctx.MediaType,
index: 0,
},
]
: [],
resolveMediaAttachmentLocalRoots: (params: {
cfg: { channels?: Record<string, { attachmentRoots?: string[] } | undefined> };
ctx: { Provider?: string; Surface?: string };
}) => {
const channel = params.ctx.Provider ?? params.ctx.Surface ?? "";
return params.cfg.channels?.[channel]?.attachmentRoots ?? [];
},
MediaAttachmentCache: class {
async getBuffer(): Promise<never> {
const error = new Error("outside allowed roots");
error.name = "MediaUnderstandingSkipError";
throw error;
}
},
}));
vi.mock("./dispatch-acp-session.runtime.js", () => ({
readAcpSessionEntry: (params: { sessionKey: string; cfg?: OpenClawConfig }) =>
sessionMetaMocks.readAcpSessionEntry(params),
}));
const sessionKey = "agent:codex-acp:session-1";
const originalFetch = globalThis.fetch;
type MockTtsReply = Awaited<ReturnType<typeof ttsMocks.maybeApplyTtsToPayload>>;
let tryDispatchAcpReply: typeof import("./dispatch-acp.js").tryDispatchAcpReply;
function createDispatcher(): {
dispatcher: ReplyDispatcher;
@@ -246,68 +325,7 @@ function expectRoutedPayload(callIndex: number, payload: Partial<MockTtsReply>)
}
describe("tryDispatchAcpReply", () => {
beforeEach(async () => {
vi.resetModules();
vi.doMock("../../acp/control-plane/manager.js", () => ({
getAcpSessionManager: () => managerMocks,
}));
vi.doMock("../../acp/policy.js", () => ({
resolveAcpDispatchPolicyError: (cfg: OpenClawConfig) =>
policyMocks.resolveAcpDispatchPolicyError(cfg),
resolveAcpAgentPolicyError: (cfg: OpenClawConfig, agent: string) =>
policyMocks.resolveAcpAgentPolicyError(cfg, agent),
}));
vi.doMock("./route-reply.js", () => ({
routeReply: (params: unknown) => routeMocks.routeReply(params),
}));
vi.doMock("../../channels/plugins/index.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../channels/plugins/index.js")>();
return {
...actual,
getChannelPlugin: (channelId: string) => channelPluginMocks.getChannelPlugin(channelId),
};
});
vi.doMock("../../infra/outbound/message-action-runner.js", () => ({
runMessageAction: (params: unknown) => messageActionMocks.runMessageAction(params),
}));
vi.doMock("../../tts/tts.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
resolveTtsConfig: (cfg: OpenClawConfig) => ttsMocks.resolveTtsConfig(cfg),
}));
vi.doMock("../../tts/tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.doMock("../../tts/status-config.js", () => ({
resolveStatusTtsSnapshot: () => ({
autoMode: "always",
provider: "auto",
maxLength: 1500,
summarize: true,
}),
}));
vi.doMock("./dispatch-acp-tts.runtime.js", () => ({
maybeApplyTtsToPayload: (params: unknown) => ttsMocks.maybeApplyTtsToPayload(params),
}));
vi.doMock("../../media-understanding/apply.js", () => ({
applyMediaUnderstanding: (params: unknown) =>
mediaUnderstandingMocks.applyMediaUnderstanding(params),
}));
vi.doMock("../../acp/runtime/session-meta.js", () => ({
readAcpSessionEntry: (params: { sessionKey: string; cfg?: OpenClawConfig }) =>
sessionMetaMocks.readAcpSessionEntry(params),
}));
vi.doMock("./dispatch-acp-session.runtime.js", () => ({
readAcpSessionEntry: (params: { sessionKey: string; cfg?: OpenClawConfig }) =>
sessionMetaMocks.readAcpSessionEntry(params),
}));
vi.doMock("../../infra/outbound/session-binding-service.js", () => ({
getSessionBindingService: () => ({
listBySession: (targetSessionKey: string) =>
bindingServiceMocks.listBySession(targetSessionKey),
unbind: (input: unknown) => bindingServiceMocks.unbind(input),
}),
}));
({ tryDispatchAcpReply } = await import("./dispatch-acp.js"));
beforeEach(() => {
managerMocks.resolveSession.mockReset();
managerMocks.runTurn.mockReset();
managerMocks.runTurn.mockImplementation(
@@ -747,12 +765,8 @@ describe("tryDispatchAcpReply", () => {
it("does not unbind valid bindings on generic ACP runTurn init failure", async () => {
setReadyAcpResolution();
// Match the post-reset module instance so dispatch-acp preserves the ACP error code.
const { AcpRuntimeError: FreshAcpRuntimeError } = await import("../../acp/runtime/errors.js");
managerMocks.runTurn.mockRejectedValueOnce(
new FreshAcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
"Could not initialize ACP session runtime.",
),
new AcpRuntimeError("ACP_SESSION_INIT_FAILED", "Could not initialize ACP session runtime."),
);
const { dispatcher } = createDispatcher();
@@ -778,9 +792,8 @@ describe("tryDispatchAcpReply", () => {
sessionKey: canonicalSessionKey,
meta: createAcpSessionMeta(),
});
const { AcpRuntimeError: FreshAcpRuntimeError } = await import("../../acp/runtime/errors.js");
managerMocks.runTurn.mockRejectedValueOnce(
new FreshAcpRuntimeError(
new AcpRuntimeError(
"ACP_SESSION_INIT_FAILED",
`ACP metadata is missing for ${canonicalSessionKey}. Recreate this ACP session with /acp spawn and rebind the thread.`,
),

View File

@@ -3,40 +3,38 @@ import type { SkillCommandSpec } from "../../agents/skills.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { TemplateContext } from "../templating.js";
import { clearInlineDirectives } from "./get-reply-directives-utils.js";
import { handleInlineActions } from "./get-reply-inline-actions.js";
import { stripInlineStatus } from "./reply-inline.js";
import { buildTestCtx } from "./test-ctx.js";
import type { TypingController } from "./typing.js";
const handleCommandsMock = vi.fn();
const getChannelPluginMock = vi.fn();
const createOpenClawToolsMock = vi.fn();
const buildStatusReplyMock = vi.fn();
const { buildStatusReplyMock, createOpenClawToolsMock, getChannelPluginMock, handleCommandsMock } =
vi.hoisted(() => ({
buildStatusReplyMock: vi.fn(),
createOpenClawToolsMock: vi.fn(),
getChannelPluginMock: vi.fn(),
handleCommandsMock: vi.fn(),
}));
let handleInlineActions: typeof import("./get-reply-inline-actions.js").handleInlineActions;
type HandleInlineActionsInput = Parameters<
typeof import("./get-reply-inline-actions.js").handleInlineActions
>[0];
async function loadFreshInlineActionsModuleForTest() {
vi.resetModules();
vi.doMock("./commands.runtime.js", () => ({
handleCommands: (...args: unknown[]) => handleCommandsMock(...args),
buildStatusReply: (...args: unknown[]) => buildStatusReplyMock(...args),
}));
vi.doMock("../../agents/openclaw-tools.runtime.js", () => ({
createOpenClawTools: (...args: unknown[]) => createOpenClawToolsMock(...args),
}));
vi.doMock("../../channels/plugins/index.js", async () => {
const actual = await vi.importActual<typeof import("../../channels/plugins/index.js")>(
"../../channels/plugins/index.js",
);
return {
...actual,
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
};
});
({ handleInlineActions } = await import("./get-reply-inline-actions.js"));
}
vi.mock("./commands.runtime.js", () => ({
handleCommands: (...args: unknown[]) => handleCommandsMock(...args),
buildStatusReply: (...args: unknown[]) => buildStatusReplyMock(...args),
}));
vi.mock("../../agents/openclaw-tools.runtime.js", () => ({
createOpenClawTools: (...args: unknown[]) => createOpenClawToolsMock(...args),
}));
vi.mock("../../channels/plugins/index.js", () => ({
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
getLoadedChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
listChannelPlugins: () => [],
normalizeChannelId: (value?: string) => value?.trim().toLowerCase() || null,
}));
const createTypingController = (): TypingController => ({
onReplyStart: async () => {},
@@ -119,7 +117,7 @@ async function expectInlineActionSkipped(params: {
}
describe("handleInlineActions", () => {
beforeEach(async () => {
beforeEach(() => {
handleCommandsMock.mockReset();
handleCommandsMock.mockResolvedValue({ shouldContinue: true, reply: undefined });
getChannelPluginMock.mockReset();
@@ -134,7 +132,6 @@ describe("handleInlineActions", () => {
? { mentions: { stripPatterns: () => ["<@!?\\d+>"] } }
: undefined,
);
await loadFreshInlineActionsModuleForTest();
});
it("skips whatsapp replies when config is empty and From !== To", async () => {

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";
import type { OpenClawConfig } from "../../config/config.js";
import type { MsgContext } from "../templating.js";
import {
@@ -18,13 +18,10 @@ const mocks = vi.hoisted(() => ({
resolveReplyDirectives: vi.fn(),
}));
vi.mock("../../agents/workspace.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../agents/workspace.js")>();
return {
...actual,
ensureAgentWorkspace: (...args: unknown[]) => mocks.ensureAgentWorkspace(...args),
};
});
vi.mock("../../agents/workspace.js", () => ({
DEFAULT_AGENT_WORKSPACE_DIR: "/tmp/openclaw-workspace",
ensureAgentWorkspace: (...args: unknown[]) => mocks.ensureAgentWorkspace(...args),
}));
vi.mock("./directive-handling.defaults.js", () => ({
resolveDefaultModel: vi.fn(() => ({
defaultProvider: "openai",
@@ -38,13 +35,9 @@ vi.mock("./get-reply-directives.js", () => ({
vi.mock("./get-reply-inline-actions.js", () => ({
handleInlineActions: vi.fn(async () => ({ kind: "reply", reply: { text: "ok" } })),
}));
vi.mock("./session.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./session.js")>();
return {
...actual,
initSessionState: (...args: unknown[]) => mocks.initSessionState(...args),
};
});
vi.mock("./session.js", () => ({
initSessionState: (...args: unknown[]) => mocks.initSessionState(...args),
}));
let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig;
let loadConfigMock: typeof import("../../config/config.js").loadConfig;
@@ -74,8 +67,11 @@ function buildCtx(overrides: Partial<MsgContext> = {}): MsgContext {
}
describe("getReplyFromConfig fast test bootstrap", () => {
beforeEach(async () => {
beforeAll(async () => {
await loadGetReplyRuntimeForTest();
});
beforeEach(() => {
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
mocks.ensureAgentWorkspace.mockReset();
mocks.initSessionState.mockReset();

View File

@@ -19,6 +19,7 @@ import {
} from "../../config/sessions/reset.js";
import { resolveAndPersistSessionFile } from "../../config/sessions/session-file.js";
import { resolveSessionKey } from "../../config/sessions/session-key.js";
import { resolveMaintenanceConfigFromInput } from "../../config/sessions/store-maintenance.js";
import { loadSessionStore, updateSessionStore } from "../../config/sessions/store.js";
import {
DEFAULT_RESET_TRIGGERS,
@@ -241,6 +242,7 @@ export async function initSessionState(params: {
? { ...ctx, SessionKey: targetSessionKey }
: ctx;
const sessionCfg = cfg.session;
const maintenanceConfig = resolveMaintenanceConfigFromInput(sessionCfg?.maintenance);
const mainKey = normalizeMainKey(sessionCfg?.mainKey);
const agentId = resolveSessionAgentId({
sessionKey: sessionCtxForState.SessionKey,
@@ -631,6 +633,7 @@ export async function initSessionState(params: {
sessionsDir: path.dirname(storePath),
fallbackSessionFile,
activeSessionKey: sessionKey,
maintenanceConfig,
});
sessionEntry = resolvedSessionFile.sessionEntry;
if (isNewSession) {
@@ -661,6 +664,7 @@ export async function initSessionState(params: {
},
{
activeSessionKey: sessionKey,
maintenanceConfig,
onWarn: (warning) =>
deliverSessionMaintenanceWarning({
cfg,