From bb0bfabec85d6e85ffec2142d94fec4ef02a3b42 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Sat, 11 Apr 2026 13:16:56 +0100 Subject: [PATCH] perf: trim agent test runtime imports --- ...th-profiles.ensureauthprofilestore.test.ts | 36 ++++ ...dels-config.providers.auth-aliases.test.ts | 5 + ...fig.providers.ollama-autodiscovery.test.ts | 166 ------------------ .../models-config.providers.ollama.test.ts | 85 +++++++++ .../compact.hooks.harness.ts | 9 + ...dded-subscribe.handlers.compaction.test.ts | 18 +- src/agents/provider-auth-aliases.ts | 62 ++++++- ...bagent-registry.persistence.resume.test.ts | 25 ++- .../subagent-registry.persistence.test.ts | 17 +- src/agents/subagent-spawn.attachments.test.ts | 85 +++------ src/agents/tool-images.log.test.ts | 14 +- src/agents/tools/image-generate-tool.test.ts | 18 +- src/agents/tools/message-tool.test.ts | 1 + src/agents/tools/message-tool.ts | 5 +- src/auto-reply/reply/abort.ts | 2 +- src/auto-reply/reply/agent-runner-memory.ts | 25 ++- src/auto-reply/reply/agent-runner.ts | 2 +- src/auto-reply/reply/commands-acp.test.ts | 7 + src/auto-reply/reply/commands-acp.ts | 93 +++++----- .../reply/commands-acp/diagnostics.ts | 2 +- .../reply/commands-acp/install-hints.ts | 8 +- src/auto-reply/reply/commands-acp/shared.ts | 1 - .../reply/commands-session-abort.ts | 8 +- .../reply/commands-system-prompt.test.ts | 40 ++--- .../reply/dispatch-acp-delivery.test.ts | 15 +- src/auto-reply/reply/dispatch-acp.test.ts | 153 ++++++++-------- ...ine-actions.skip-when-config-empty.test.ts | 51 +++--- .../reply/get-reply.fast-path.test.ts | 28 ++- src/auto-reply/reply/session.ts | 4 + 29 files changed, 527 insertions(+), 458 deletions(-) delete mode 100644 src/agents/models-config.providers.ollama-autodiscovery.test.ts diff --git a/src/agents/auth-profiles.ensureauthprofilestore.test.ts b/src/agents/auth-profiles.ensureauthprofilestore.test.ts index dbcd906c97e..5513a9e6199 100644 --- a/src/agents/auth-profiles.ensureauthprofilestore.test.ts +++ b/src/agents/auth-profiles.ensureauthprofilestore.test.ts @@ -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(prefix: string, run: (agentDir: string) => T): T { const agentDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); diff --git a/src/agents/models-config.providers.auth-aliases.test.ts b/src/agents/models-config.providers.auth-aliases.test.ts index 555fa1ff49f..d362aee1c5a 100644 --- a/src/agents/models-config.providers.auth-aliases.test.ts +++ b/src/agents/models-config.providers.auth-aliases.test.ts @@ -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", () => { diff --git a/src/agents/models-config.providers.ollama-autodiscovery.test.ts b/src/agents/models-config.providers.ollama-autodiscovery.test.ts deleted file mode 100644 index 860a60a0b87..00000000000 --- a/src/agents/models-config.providers.ollama-autodiscovery.test.ts +++ /dev/null @@ -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 | 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 { - ollamaCatalogProviderPromise ??= resolvePluginDiscoveryProviders({ - env: createCatalogLoadEnv(), - onlyPluginIds: ["ollama"], - }).then((providers) => providers.find((provider) => provider.id === "ollama")); - return ollamaCatalogProviderPromise; - } - - async function runOllamaCatalog(params?: { - explicitProviders?: Record; - }): Promise { - 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(); - }); -}); diff --git a/src/agents/models-config.providers.ollama.test.ts b/src/agents/models-config.providers.ollama.test.ts index ddb76283cf6..19f816cf1b5 100644 --- a/src/agents/models-config.providers.ollama.test.ts +++ b/src/agents/models-config.providers.ollama.test.ts @@ -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(); diff --git a/src/agents/pi-embedded-runner/compact.hooks.harness.ts b/src/agents/pi-embedded-runner/compact.hooks.harness.ts index f3e94cb414d..0a07f181e28 100644 --- a/src/agents/pi-embedded-runner/compact.hooks.harness.ts +++ b/src/agents/pi-embedded-runner/compact.hooks.harness.ts @@ -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, })); diff --git a/src/agents/pi-embedded-subscribe.handlers.compaction.test.ts b/src/agents/pi-embedded-subscribe.handlers.compaction.test.ts index e457c0ff8dc..059143e17ca 100644 --- a/src/agents/pi-embedded-subscribe.handlers.compaction.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.compaction.test.ts @@ -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-")); diff --git a/src/agents/provider-auth-aliases.ts b/src/agents/provider-auth-aliases.ts index f32a6d74cb7..4e8e73407ff 100644 --- a/src/agents/provider-auth-aliases.ts +++ b/src/agents/provider-auth-aliases.ts @@ -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["entries"]>; + const PROVIDER_AUTH_ALIAS_ORIGIN_PRIORITY: Readonly> = { 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( diff --git a/src/agents/subagent-registry.persistence.resume.test.ts b/src/agents/subagent-registry.persistence.resume.test.ts index 34c85510b32..80fbf23737a 100644 --- a/src/agents/subagent-registry.persistence.resume.test.ts +++ b/src/agents/subagent-registry.persistence.resume.test.ts @@ -11,6 +11,7 @@ import { captureEnv } from "../test-utils/env.js"; const hoisted = vi.hoisted(() => ({ announceSpy: vi.fn(async () => true), + allowedRunIds: undefined as Set | undefined, registryPath: undefined as string | undefined, })); const { announceSpy } = hoisted; @@ -46,10 +47,16 @@ vi.mock("./subagent-registry.store.js", async () => { runs: Map, ) => { 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) diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index c01a9f06bdc..05fa09316d0 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -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 ", diff --git a/src/agents/subagent-spawn.attachments.test.ts b/src/agents/subagent-spawn.attachments.test.ts index 8f18568a129..1de81c54fad 100644 --- a/src/agents/subagent-spawn.attachments.test.ts +++ b/src/agents/subagent-spawn.attachments.test.ts @@ -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 = { ...createSubagentSpawnTestConfig(), }; let workspaceDirOverride = ""; -let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests; +let subagentSpawnModule: Awaited>; + +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", diff --git a/src/agents/tool-images.log.test.ts b/src/agents/tool-images.log.test.ts index b454aef0216..479d598972f 100644 --- a/src/agents/tool-images.log.test.ts +++ b/src/agents/tool-images.log.test.ts @@ -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 { - 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 { } 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" }, ]; diff --git a/src/agents/tools/image-generate-tool.test.ts b/src/agents/tools/image-generate-tool.test.ts index facd9af0c4c..1c07015a5bb 100644 --- a/src/agents/tools/image-generate-tool.test.ts +++ b/src/agents/tools/image-generate-tool.test.ts @@ -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( + "../../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"); diff --git a/src/agents/tools/message-tool.test.ts b/src/agents/tools/message-tool.test.ts index 6e406fc8b40..c4235bb0875 100644 --- a/src/agents/tools/message-tool.test.ts +++ b/src/agents/tools/message-tool.test.ts @@ -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, }); diff --git a/src/agents/tools/message-tool.ts b/src/agents/tools/message-tool.ts index 19d8b795b57..2bc91f558c2 100644 --- a/src/agents/tools/message-tool.ts +++ b/src/agents/tools/message-tool.ts @@ -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, diff --git a/src/auto-reply/reply/abort.ts b/src/auto-reply/reply/abort.ts index 61ea9997f29..c4bfa8df5f6 100644 --- a/src/auto-reply/reply/abort.ts +++ b/src/auto-reply/reply/abort.ts @@ -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, diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts index 56e155233ee..a787d3bbbec 100644 --- a/src/auto-reply/reply/agent-runner-memory.ts +++ b/src/auto-reply/reply/agent-runner-memory.ts @@ -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 +): Promise< + Awaited> +> { + const { compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); + return await compactEmbeddedPiSession(...args); +} + +async function runEmbeddedPiAgentDefault( + ...args: Parameters +): Promise>> { + 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): void { Object.assign(memoryDeps, { runWithModelFallback, - compactEmbeddedPiSession, - runEmbeddedPiAgent, + compactEmbeddedPiSession: compactEmbeddedPiSessionDefault, + runEmbeddedPiAgent: runEmbeddedPiAgentDefault, registerAgentRunContext, refreshQueuedFollowupSession, incrementCompactionCount, diff --git a/src/auto-reply/reply/agent-runner.ts b/src/auto-reply/reply/agent-runner.ts index f56ed3af604..f4f0fac5123 100644 --- a/src/auto-reply/reply/agent-runner.ts +++ b/src/auto-reply/reply/agent-runner.ts @@ -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, diff --git a/src/auto-reply/reply/commands-acp.test.ts b/src/auto-reply/reply/commands-acp.test.ts index bb00bcebf7f..6931dae711f 100644 --- a/src/auto-reply/reply/commands-acp.test.ts +++ b/src/auto-reply/reply/commands-acp.test.ts @@ -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( "../../config/sessions.js", diff --git a/src/auto-reply/reply/commands-acp.ts b/src/auto-reply/reply/commands-acp.ts index e23faf74d10..bcd9328833b 100644 --- a/src/auto-reply/reply/commands-acp.ts +++ b/src/auto-reply/reply/commands-acp.ts @@ -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; -const ACP_ACTION_HANDLERS: Record, 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 | undefined; +let runtimeOptionHandlersPromise: + | Promise + | undefined; +let diagnosticHandlersPromise: Promise | undefined; + +async function loadAcpActionHandler(action: Exclude): Promise { + 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([ "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); }; diff --git a/src/auto-reply/reply/commands-acp/diagnostics.ts b/src/auto-reply/reply/commands-acp/diagnostics.ts index 7d495078b81..1c96d9fde76 100644 --- a/src/auto-reply/reply/commands-acp/diagnostics.ts +++ b/src/auto-reply/reply/commands-acp/diagnostics.ts @@ -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"; diff --git a/src/auto-reply/reply/commands-acp/install-hints.ts b/src/auto-reply/reply/commands-acp/install-hints.ts index 35bda19bc60..c1e4b6cd65c 100644 --- a/src/auto-reply/reply/commands-acp/install-hints.ts +++ b/src/auto-reply/reply/commands-acp/install-hints.ts @@ -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({ diff --git a/src/auto-reply/reply/commands-acp/shared.ts b/src/auto-reply/reply/commands-acp/shared.ts index 759d22fe25d..c4c633c06a1 100644 --- a/src/auto-reply/reply/commands-acp/shared.ts +++ b/src/auto-reply/reply/commands-acp/shared.ts @@ -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 = diff --git a/src/auto-reply/reply/commands-session-abort.ts b/src/auto-reply/reply/commands-session-abort.ts index 1f50212caf3..ab57a75c21d 100644 --- a/src/auto-reply/reply/commands-session-abort.ts +++ b/src/auto-reply/reply/commands-session-abort.ts @@ -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 { + 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({ diff --git a/src/auto-reply/reply/commands-system-prompt.test.ts b/src/auto-reply/reply/commands-system-prompt.test.ts index e2b2a3c5c7b..8c9c1604d94 100644 --- a/src/auto-reply/reply/commands-system-prompt.test.ts +++ b/src/auto-reply/reply/commands-system-prompt.test.ts @@ -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, diff --git a/src/auto-reply/reply/dispatch-acp-delivery.test.ts b/src/auto-reply/reply/dispatch-acp-delivery.test.ts index e56b73c5d9f..01c2e3a6855 100644 --- a/src/auto-reply/reply/dispatch-acp-delivery.test.ts +++ b/src/auto-reply/reply/dispatch-acp-delivery.test.ts @@ -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(); - 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), diff --git a/src/auto-reply/reply/dispatch-acp.test.ts b/src/auto-reply/reply/dispatch-acp.test.ts index 6e693e07d5e..8cd2339e06e 100644 --- a/src/auto-reply/reply/dispatch-acp.test.ts +++ b/src/auto-reply/reply/dispatch-acp.test.ts @@ -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>(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 }; + ctx: { Provider?: string; Surface?: string }; + }) => { + const channel = params.ctx.Provider ?? params.ctx.Surface ?? ""; + return params.cfg.channels?.[channel]?.attachmentRoots ?? []; + }, + MediaAttachmentCache: class { + async getBuffer(): Promise { + 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>; -let tryDispatchAcpReply: typeof import("./dispatch-acp.js").tryDispatchAcpReply; function createDispatcher(): { dispatcher: ReplyDispatcher; @@ -246,68 +325,7 @@ function expectRoutedPayload(callIndex: number, payload: Partial) } 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(); - 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.`, ), diff --git a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts index de79cba27f7..8d96c800499 100644 --- a/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts +++ b/src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts @@ -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( - "../../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 () => { diff --git a/src/auto-reply/reply/get-reply.fast-path.test.ts b/src/auto-reply/reply/get-reply.fast-path.test.ts index df23b6a2ce4..200e045ecbb 100644 --- a/src/auto-reply/reply/get-reply.fast-path.test.ts +++ b/src/auto-reply/reply/get-reply.fast-path.test.ts @@ -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(); - 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(); - 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 { } describe("getReplyFromConfig fast test bootstrap", () => { - beforeEach(async () => { + beforeAll(async () => { await loadGetReplyRuntimeForTest(); + }); + + beforeEach(() => { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); mocks.ensureAgentWorkspace.mockReset(); mocks.initSessionState.mockReset(); diff --git a/src/auto-reply/reply/session.ts b/src/auto-reply/reply/session.ts index b4f4d78b20d..6b4bd6f12ac 100644 --- a/src/auto-reply/reply/session.ts +++ b/src/auto-reply/reply/session.ts @@ -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,