From 2833b27f52e287cfe7a1fb208f01a78b137bf162 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 24 Mar 2026 02:00:22 +0000 Subject: [PATCH] test: continue vitest threads migration --- src/agents/acp-spawn-parent-stream.test.ts | 40 ++- src/agents/anthropic-vertex-stream.test.ts | 36 ++- src/agents/auth-profiles.store-cache.test.ts | 16 +- .../oauth.fallback-to-main-agent.test.ts | 67 ++-- ...auth.openai-codex-refresh-fallback.test.ts | 18 +- src/agents/auth-profiles/oauth.test.ts | 35 +- .../bash-tools.exec-approval-request.test.ts | 10 +- src/agents/bash-tools.exec-runtime.test.ts | 33 +- src/agents/bash-tools.exec.path.test.ts | 73 ++++- .../bash-tools.process.supervisor.test.ts | 26 +- src/agents/claude-cli-runner.test.ts | 11 +- src/agents/cli-runner.test.ts | 30 +- src/agents/command/session-store.ts | 1 + ...compaction.identifier-preservation.test.ts | 15 +- src/agents/model-catalog.test.ts | 1 - ...ssing-provider-apikey-from-env-var.test.ts | 4 +- ...els-config.runtime-source-snapshot.test.ts | 298 ++++++++++-------- ...s-writing-models-json-no-env-token.test.ts | 39 ++- src/agents/openclaw-gateway-tool.test.ts | 44 ++- src/agents/openclaw-tools.camera.test.ts | 45 +-- .../openclaw-tools.session-status.test.ts | 85 ++++- ...openclaw-tools.sessions-visibility.test.ts | 25 +- ...agents.sessions-spawn-depth-limits.test.ts | 25 +- ...subagents.sessions-spawn.lifecycle.test.ts | 18 +- ...ed-runner.sanitize-session-history.test.ts | 3 +- .../context-engine-maintenance.test.ts | 17 +- .../extra-params.kilocode.test.ts | 109 +++---- .../tool-result-truncation.test.ts | 44 ++- .../transcript-rewrite.test.ts | 24 +- ...-emit-duplicate-block-replies-text.test.ts | 3 +- ...uppresses-output-without-start-tag.test.ts | 3 +- ...g-single-line-fenced-blocks-reopen.test.ts | 3 +- ...session.subscribeembeddedpisession.test.ts | 12 +- ...adapter.after-tool-call.fires-once.test.ts | 43 ++- src/agents/provider-capabilities.test.ts | 42 ++- src/agents/sandbox/browser.create.test.ts | 17 +- .../docker.config-hash-recreate.test.ts | 71 ++++- src/agents/sandbox/fs-bridge.shell.test.ts | 2 +- src/agents/sandbox/fs-bridge.test-helpers.ts | 56 +++- src/agents/sandbox/registry.test.ts | 37 ++- src/agents/sandbox/ssh-backend.test.ts | 22 +- src/agents/session-transcript-repair.test.ts | 16 +- src/agents/session-write-lock.test.ts | 47 +-- src/agents/skills-install-fallback.test.ts | 32 +- src/agents/skills/plugin-skills.test.ts | 16 +- src/agents/skills/refresh.test.ts | 30 +- src/agents/skills/refresh.ts | 21 ++ ...-announce.capture-completion-reply.test.ts | 40 ++- src/agents/subagent-announce.timeout.test.ts | 94 +++++- .../subagent-registry-completion.test.ts | 1 - src/agents/subagent-registry-state.ts | 4 +- ...agent-registry.announce-loop-guard.test.ts | 53 +++- ...registry.lifecycle-retry-grace.e2e.test.ts | 28 +- .../subagent-registry.persistence.test.ts | 39 ++- .../subagent-registry.steer-restart.test.ts | 88 +++--- src/agents/subagent-spawn.attachments.test.ts | 38 ++- .../subagent-spawn.model-session.test.ts | 75 ++++- src/agents/subagent-spawn.workspace.test.ts | 59 +++- src/agents/tools/agent-step.test.ts | 5 +- src/agents/tools/cron-tool.test.ts | 53 ++-- src/agents/tools/gateway.test.ts | 19 +- src/agents/tools/nodes-tool.test.ts | 32 +- src/agents/tools/nodes-utils.test.ts | 8 +- src/agents/tools/sessions-resolution.test.ts | 41 ++- src/agents/tools/sessions-spawn-tool.test.ts | 19 +- src/agents/tools/sessions.test.ts | 37 ++- src/agents/tools/web-search.test.ts | 8 +- src/agents/transcript-policy.test.ts | 41 ++- src/auto-reply/envelope.test.ts | 18 +- src/auto-reply/inbound.test.ts | 144 +++++++++ ...ng-mixed-messages-acks-immediately.test.ts | 26 +- ...nk-low-reasoning-capable-models-no.test.ts | 36 +-- ...irective.directive-behavior.e2e-harness.ts | 19 +- ...tches-fuzzy-selection-is-ambiguous.test.ts | 21 +- ...bound-media-into-sandbox-workspace.test.ts | 97 +++++- .../reply/agent-runner-utils.test.ts | 4 +- src/auto-reply/reply/commands.test.ts | 6 +- src/auto-reply/reply/followup-runner.test.ts | 259 +++++++++++++-- ...ine-actions.skip-when-config-empty.test.ts | 45 +-- .../reply/get-reply-run.media-only.test.ts | 29 +- .../reply/get-reply.message-hooks.test.ts | 10 +- .../get-reply.reset-hooks-fallback.test.ts | 13 +- src/auto-reply/reply/get-reply.test-mocks.ts | 30 +- src/auto-reply/reply/groups.ts | 31 ++ src/auto-reply/reply/model-selection.test.ts | 2 +- src/auto-reply/reply/reply-flow.test.ts | 24 +- src/auto-reply/reply/reply-plumbing.test.ts | 53 +++- .../reply/session-hooks-context.test.ts | 23 +- src/auto-reply/skill-commands.test.ts | 68 +++- src/auto-reply/thinking.test.ts | 37 ++- src/commands/agent.delivery.test.ts | 27 +- src/commands/agent.test.ts | 71 ++++- src/commands/agent/session.test.ts | 10 +- src/commands/channels.mock-harness.ts | 9 + src/commands/dashboard.links.test.ts | 35 +- src/commands/health.snapshot.test.ts | 169 ++++++---- src/commands/message.test.ts | 40 ++- .../list.list-command.forward-compat.test.ts | 13 - src/commands/models/list.status.test.ts | 108 +++---- src/commands/models/shared.test.ts | 7 +- .../onboard-non-interactive.gateway.test.ts | 60 +++- src/commands/status.test.ts | 9 + src/gateway/gateway-misc.test.ts | 27 +- src/gateway/server-methods/send.test.ts | 13 +- .../server.roles-allowlist-update.test.ts | 3 +- src/gateway/session-utils.test.ts | 51 +-- src/gateway/session-utils.ts | 6 +- src/gateway/startup-auth.test.ts | 15 +- src/gateway/test-helpers.server.ts | 28 +- src/gateway/test-with-server.ts | 14 +- 110 files changed, 3163 insertions(+), 994 deletions(-) diff --git a/src/agents/acp-spawn-parent-stream.test.ts b/src/agents/acp-spawn-parent-stream.test.ts index 010cd596e7f..18c4aee7b0b 100644 --- a/src/agents/acp-spawn-parent-stream.test.ts +++ b/src/agents/acp-spawn-parent-stream.test.ts @@ -1,9 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { emitAgentEvent } from "../infra/agent-events.js"; -import { - resolveAcpSpawnStreamLogPath, - startAcpSpawnParentStreamRelay, -} from "./acp-spawn-parent-stream.js"; const enqueueSystemEventMock = vi.fn(); const requestHeartbeatNowMock = vi.fn(); @@ -28,18 +23,51 @@ vi.mock("../config/sessions/paths.js", () => ({ resolveSessionFilePathOptions: (...args: unknown[]) => resolveSessionFilePathOptionsMock(...args), })); +let emitAgentEvent: typeof import("../infra/agent-events.js").emitAgentEvent; +let resolveAcpSpawnStreamLogPath: typeof import("./acp-spawn-parent-stream.js").resolveAcpSpawnStreamLogPath; +let startAcpSpawnParentStreamRelay: typeof import("./acp-spawn-parent-stream.js").startAcpSpawnParentStreamRelay; + +async function loadFreshAcpSpawnParentStreamModulesForTest() { + vi.resetModules(); + vi.doMock("../infra/system-events.js", () => ({ + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), + })); + vi.doMock("../infra/heartbeat-wake.js", () => ({ + requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args), + })); + vi.doMock("../acp/runtime/session-meta.js", () => ({ + readAcpSessionEntry: (...args: unknown[]) => readAcpSessionEntryMock(...args), + })); + vi.doMock("../config/sessions/paths.js", () => ({ + resolveSessionFilePath: (...args: unknown[]) => resolveSessionFilePathMock(...args), + resolveSessionFilePathOptions: (...args: unknown[]) => + resolveSessionFilePathOptionsMock(...args), + })); + const [agentEvents, relayModule] = await Promise.all([ + import("../infra/agent-events.js"), + import("./acp-spawn-parent-stream.js"), + ]); + return { + emitAgentEvent: agentEvents.emitAgentEvent, + resolveAcpSpawnStreamLogPath: relayModule.resolveAcpSpawnStreamLogPath, + startAcpSpawnParentStreamRelay: relayModule.startAcpSpawnParentStreamRelay, + }; +} + function collectedTexts() { return enqueueSystemEventMock.mock.calls.map((call) => String(call[0] ?? "")); } describe("startAcpSpawnParentStreamRelay", () => { - beforeEach(() => { + beforeEach(async () => { enqueueSystemEventMock.mockClear(); requestHeartbeatNowMock.mockClear(); readAcpSessionEntryMock.mockReset(); resolveSessionFilePathMock.mockReset(); resolveSessionFilePathOptionsMock.mockReset(); resolveSessionFilePathOptionsMock.mockImplementation((value: unknown) => value); + ({ emitAgentEvent, resolveAcpSpawnStreamLogPath, startAcpSpawnParentStreamRelay } = + await loadFreshAcpSpawnParentStreamModulesForTest()); vi.useFakeTimers(); vi.setSystemTime(new Date("2026-03-04T01:00:00.000Z")); }); diff --git a/src/agents/anthropic-vertex-stream.test.ts b/src/agents/anthropic-vertex-stream.test.ts index 906c3f92423..77a3ad9c57e 100644 --- a/src/agents/anthropic-vertex-stream.test.ts +++ b/src/agents/anthropic-vertex-stream.test.ts @@ -33,10 +33,28 @@ import { resolveAnthropicVertexRegion, resolveAnthropicVertexRegionFromBaseUrl, } from "./anthropic-vertex-provider.js"; -import { - createAnthropicVertexStreamFn, - createAnthropicVertexStreamFnForModel, -} from "./anthropic-vertex-stream.js"; + +let createAnthropicVertexStreamFn: typeof import("./anthropic-vertex-stream.js").createAnthropicVertexStreamFn; +let createAnthropicVertexStreamFnForModel: typeof import("./anthropic-vertex-stream.js").createAnthropicVertexStreamFnForModel; + +async function loadFreshAnthropicVertexStreamModuleForTest() { + vi.resetModules(); + vi.doMock("@mariozechner/pi-ai", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + streamAnthropic: (model: unknown, context: unknown, options: unknown) => + hoisted.streamAnthropicMock(model, context, options), + }; + }); + vi.doMock("@anthropic-ai/vertex-sdk", () => ({ + AnthropicVertex: vi.fn(function MockAnthropicVertex(options: unknown) { + hoisted.anthropicVertexCtorMock(options); + return { options }; + }), + })); + return await import("./anthropic-vertex-stream.js"); +} function makeModel(params: { id: string; maxTokens?: number }): Model<"anthropic-messages"> { return { @@ -53,6 +71,11 @@ describe("createAnthropicVertexStreamFn", () => { hoisted.anthropicVertexCtorMock.mockClear(); }); + beforeEach(async () => { + ({ createAnthropicVertexStreamFn, createAnthropicVertexStreamFnForModel } = + await loadFreshAnthropicVertexStreamModuleForTest()); + }); + it("omits projectId when ADC credentials are used without an explicit project", () => { const streamFn = createAnthropicVertexStreamFn(undefined, "global"); @@ -176,6 +199,11 @@ describe("createAnthropicVertexStreamFnForModel", () => { hoisted.anthropicVertexCtorMock.mockClear(); }); + beforeEach(async () => { + ({ createAnthropicVertexStreamFn, createAnthropicVertexStreamFnForModel } = + await loadFreshAnthropicVertexStreamModuleForTest()); + }); + it("derives project and region from the model and env", () => { const streamFn = createAnthropicVertexStreamFnForModel( { baseUrl: "https://europe-west4-aiplatform.googleapis.com" }, diff --git a/src/agents/auth-profiles.store-cache.test.ts b/src/agents/auth-profiles.store-cache.test.ts index f16e33a787c..7608e03c971 100644 --- a/src/agents/auth-profiles.store-cache.test.ts +++ b/src/agents/auth-profiles.store-cache.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { AUTH_STORE_VERSION, EXTERNAL_CLI_SYNC_TTL_MS } from "./auth-profiles/constants.js"; import type { AuthProfileStore } from "./auth-profiles/types.js"; @@ -13,10 +13,20 @@ vi.mock("./auth-profiles/external-cli-sync.js", () => ({ syncExternalCliCredentials: mocks.syncExternalCliCredentials, })); -const { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore } = - await import("./auth-profiles.js"); +let clearRuntimeAuthProfileStoreSnapshots: typeof import("./auth-profiles.js").clearRuntimeAuthProfileStoreSnapshots; +let ensureAuthProfileStore: typeof import("./auth-profiles.js").ensureAuthProfileStore; + +async function loadFreshAuthProfilesModuleForTest() { + vi.resetModules(); + ({ clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore } = + await import("./auth-profiles.js")); +} describe("auth profile store cache", () => { + beforeEach(async () => { + await loadFreshAuthProfilesModuleForTest(); + }); + afterEach(() => { vi.useRealTimers(); clearRuntimeAuthProfileStoreSnapshots(); diff --git a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts index 62a38347bcd..c7f16763a0a 100644 --- a/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts +++ b/src/agents/auth-profiles/oauth.fallback-to-main-agent.test.ts @@ -2,10 +2,48 @@ 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 { resetFileLockStateForTest } from "../../infra/file-lock.js"; import { captureEnv } from "../../test-utils/env.js"; -import { resolveApiKeyForProfile } from "./oauth.js"; -import { ensureAuthProfileStore } from "./store.js"; import type { AuthProfileStore } from "./types.js"; +const { getOAuthApiKeyMock } = vi.hoisted(() => ({ + getOAuthApiKeyMock: vi.fn(async () => { + throw new Error("invalid_grant"); + }), +})); + +vi.mock("@mariozechner/pi-ai/oauth", async () => { + const actual = await vi.importActual( + "@mariozechner/pi-ai/oauth", + ); + return { + ...actual, + getOAuthApiKey: getOAuthApiKeyMock, + }; +}); + +vi.mock("../cli-credentials.js", () => ({ + readCodexCliCredentialsCached: () => null, + readQwenCliCredentialsCached: () => null, + readMiniMaxCliCredentialsCached: () => null, + resetCliCredentialCachesForTest: () => undefined, +})); + +vi.mock("../../plugins/provider-runtime.runtime.js", () => ({ + buildProviderAuthDoctorHintWithPlugin: async () => null, + formatProviderAuthProfileApiKeyWithPlugin: async (params: { context?: { access?: string } }) => + params.context?.access, + refreshProviderOAuthCredentialWithPlugin: async () => null, +})); + +let clearRuntimeAuthProfileStoreSnapshots: typeof import("./store.js").clearRuntimeAuthProfileStoreSnapshots; +let ensureAuthProfileStore: typeof import("./store.js").ensureAuthProfileStore; +let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; + +async function loadFreshOAuthModuleForTest() { + vi.resetModules(); + ({ clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore } = await import("./store.js")); + ({ resolveApiKeyForProfile } = await import("./oauth.js")); +} describe("resolveApiKeyForProfile fallback to main agent", () => { const envSnapshot = captureEnv([ @@ -18,6 +56,11 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { let secondaryAgentDir: string; beforeEach(async () => { + resetFileLockStateForTest(); + getOAuthApiKeyMock.mockReset(); + getOAuthApiKeyMock.mockImplementation(async () => { + throw new Error("invalid_grant"); + }); tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "oauth-fallback-test-")); mainAgentDir = path.join(tmpDir, "agents", "main", "agent"); secondaryAgentDir = path.join(tmpDir, "agents", "kids", "agent"); @@ -28,6 +71,8 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { process.env.OPENCLAW_STATE_DIR = tmpDir; process.env.OPENCLAW_AGENT_DIR = mainAgentDir; process.env.PI_CODING_AGENT_DIR = mainAgentDir; + await loadFreshOAuthModuleForTest(); + clearRuntimeAuthProfileStoreSnapshots(); }); function createOauthStore(params: { @@ -55,16 +100,6 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { await fs.writeFile(path.join(agentDir, "auth-profiles.json"), JSON.stringify(store)); } - function stubOAuthRefreshFailure() { - const fetchSpy = vi.fn(async () => { - return new Response(JSON.stringify({ error: "invalid_grant" }), { - status: 400, - headers: { "Content-Type": "application/json" }, - }); - }); - vi.stubGlobal("fetch", fetchSpy); - } - async function resolveFromSecondaryAgent(profileId: string) { const loadedSecondaryStore = ensureAuthProfileStore(secondaryAgentDir); return resolveApiKeyForProfile({ @@ -75,6 +110,8 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { } afterEach(async () => { + resetFileLockStateForTest(); + clearRuntimeAuthProfileStoreSnapshots(); vi.unstubAllGlobals(); envSnapshot.restore(); @@ -143,9 +180,6 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { }), ); - // Mock fetch to simulate OAuth refresh failure - stubOAuthRefreshFailure(); - // Load the secondary agent's store (will merge with main agent's store) // Call resolveApiKeyForProfile with the secondary agent's expired credentials: // refresh fails, then fallback copies main credentials to secondary. @@ -293,9 +327,6 @@ describe("resolveApiKeyForProfile fallback to main agent", () => { await writeAuthProfilesStore(secondaryAgentDir, expiredStore); await writeAuthProfilesStore(mainAgentDir, expiredStore); - // Mock fetch to simulate OAuth refresh failure - stubOAuthRefreshFailure(); - // Should throw because both agents have expired credentials await expect(resolveFromSecondaryAgent(profileId)).rejects.toThrow( /OAuth token refresh failed/, diff --git a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts index 92a18c7f20d..b5f54b141c9 100644 --- a/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts +++ b/src/agents/auth-profiles/oauth.openai-codex-refresh-fallback.test.ts @@ -2,14 +2,15 @@ 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 { resetFileLockStateForTest } from "../../infra/file-lock.js"; import { captureEnv } from "../../test-utils/env.js"; -import { resolveApiKeyForProfile } from "./oauth.js"; import { clearRuntimeAuthProfileStoreSnapshots, ensureAuthProfileStore, saveAuthProfileStore, } from "./store.js"; import type { AuthProfileStore } from "./types.js"; +let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; const { getOAuthApiKeyMock } = vi.hoisted(() => ({ getOAuthApiKeyMock: vi.fn(async () => { @@ -29,6 +30,13 @@ const { buildProviderAuthDoctorHintWithPluginMock: vi.fn(async () => undefined), })); +vi.mock("../cli-credentials.js", () => ({ + readCodexCliCredentialsCached: () => null, + readQwenCliCredentialsCached: () => null, + readMiniMaxCliCredentialsCached: () => null, + resetCliCredentialCachesForTest: () => undefined, +})); + vi.mock("@mariozechner/pi-ai/oauth", async () => { const actual = await vi.importActual( "@mariozechner/pi-ai/oauth", @@ -49,6 +57,11 @@ vi.mock("../../plugins/provider-runtime.runtime.js", () => ({ buildProviderAuthDoctorHintWithPlugin: buildProviderAuthDoctorHintWithPluginMock, })); +async function loadFreshOAuthModuleForTest() { + vi.resetModules(); + ({ resolveApiKeyForProfile } = await import("./oauth.js")); +} + function createExpiredOauthStore(params: { profileId: string; provider: string; @@ -78,6 +91,7 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { let agentDir = ""; beforeEach(async () => { + resetFileLockStateForTest(); getOAuthApiKeyMock.mockClear(); refreshProviderOAuthCredentialWithPluginMock.mockReset(); refreshProviderOAuthCredentialWithPluginMock.mockResolvedValue(undefined); @@ -92,9 +106,11 @@ describe("resolveApiKeyForProfile openai-codex refresh fallback", () => { process.env.OPENCLAW_STATE_DIR = tempRoot; process.env.OPENCLAW_AGENT_DIR = agentDir; process.env.PI_CODING_AGENT_DIR = agentDir; + await loadFreshOAuthModuleForTest(); }); afterEach(async () => { + resetFileLockStateForTest(); clearRuntimeAuthProfileStoreSnapshots(); envSnapshot.restore(); await fs.rm(tempRoot, { recursive: true, force: true }); diff --git a/src/agents/auth-profiles/oauth.test.ts b/src/agents/auth-profiles/oauth.test.ts index d4161b0d8ad..279d816da17 100644 --- a/src/agents/auth-profiles/oauth.test.ts +++ b/src/agents/auth-profiles/oauth.test.ts @@ -1,8 +1,27 @@ -import { describe, expect, it } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { resolveApiKeyForProfile } from "./oauth.js"; import type { AuthProfileStore } from "./types.js"; +vi.mock("../cli-credentials.js", () => ({ + readCodexCliCredentialsCached: () => null, + readQwenCliCredentialsCached: () => null, + readMiniMaxCliCredentialsCached: () => null, + resetCliCredentialCachesForTest: () => undefined, +})); + +vi.mock("../../plugins/provider-runtime.runtime.js", () => ({ + formatProviderAuthProfileApiKeyWithPlugin: async (params: { context?: { access?: string } }) => + params.context?.access, + refreshProviderOAuthCredentialWithPlugin: async () => null, +})); + +let resolveApiKeyForProfile: typeof import("./oauth.js").resolveApiKeyForProfile; + +async function loadFreshOAuthModuleForTest() { + vi.resetModules(); + ({ resolveApiKeyForProfile } = await import("./oauth.js")); +} + function cfgFor(profileId: string, provider: string, mode: "api_key" | "token" | "oauth") { return { auth: { @@ -93,6 +112,10 @@ async function expectResolvedApiKey(params: { } describe("resolveApiKeyForProfile config compatibility", () => { + beforeEach(async () => { + await loadFreshOAuthModuleForTest(); + }); + it("accepts token credentials when config mode is oauth", async () => { const profileId = "anthropic:token"; const store: AuthProfileStore = { @@ -179,6 +202,10 @@ describe("resolveApiKeyForProfile config compatibility", () => { }); describe("resolveApiKeyForProfile token expiry handling", () => { + beforeEach(async () => { + await loadFreshOAuthModuleForTest(); + }); + it("accepts token credentials when expires is undefined", async () => { const profileId = "anthropic:token-no-expiry"; const result = await resolveWithConfig({ @@ -275,6 +302,10 @@ describe("resolveApiKeyForProfile token expiry handling", () => { }); describe("resolveApiKeyForProfile secret refs", () => { + beforeEach(async () => { + await loadFreshOAuthModuleForTest(); + }); + it("resolves api_key keyRef from env", async () => { const profileId = "openai:default"; const previous = process.env.OPENAI_API_KEY; diff --git a/src/agents/bash-tools.exec-approval-request.test.ts b/src/agents/bash-tools.exec-approval-request.test.ts index 7911b9bdf2b..ab3853c080b 100644 --- a/src/agents/bash-tools.exec-approval-request.test.ts +++ b/src/agents/bash-tools.exec-approval-request.test.ts @@ -12,12 +12,18 @@ let callGatewayTool: typeof import("./tools/gateway.js").callGatewayTool; let requestExecApprovalDecision: typeof import("./bash-tools.exec-approval-request.js").requestExecApprovalDecision; describe("requestExecApprovalDecision", () => { - beforeAll(async () => { + async function loadFreshApprovalRequestModulesForTest() { + vi.resetModules(); ({ callGatewayTool } = await import("./tools/gateway.js")); ({ requestExecApprovalDecision } = await import("./bash-tools.exec-approval-request.js")); + } + + beforeAll(async () => { + await loadFreshApprovalRequestModulesForTest(); }); - beforeEach(() => { + beforeEach(async () => { + await loadFreshApprovalRequestModulesForTest(); vi.mocked(callGatewayTool).mockClear(); }); diff --git a/src/agents/bash-tools.exec-runtime.test.ts b/src/agents/bash-tools.exec-runtime.test.ts index fc7bd245cba..56a5754967f 100644 --- a/src/agents/bash-tools.exec-runtime.test.ts +++ b/src/agents/bash-tools.exec-runtime.test.ts @@ -1,28 +1,25 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -vi.mock("../infra/heartbeat-wake.js", () => ({ - requestHeartbeatNow: vi.fn(), -})); +const requestHeartbeatNowMock = vi.hoisted(() => vi.fn()); +const enqueueSystemEventMock = vi.hoisted(() => vi.fn()); -vi.mock("../infra/system-events.js", () => ({ - enqueueSystemEvent: vi.fn(), -})); - -import { requestHeartbeatNow } from "../infra/heartbeat-wake.js"; -import { enqueueSystemEvent } from "../infra/system-events.js"; -import { - buildExecExitOutcome, - emitExecSystemEvent, - formatExecFailureReason, -} from "./bash-tools.exec-runtime.js"; - -const requestHeartbeatNowMock = vi.mocked(requestHeartbeatNow); -const enqueueSystemEventMock = vi.mocked(enqueueSystemEvent); +let buildExecExitOutcome: typeof import("./bash-tools.exec-runtime.js").buildExecExitOutcome; +let emitExecSystemEvent: typeof import("./bash-tools.exec-runtime.js").emitExecSystemEvent; +let formatExecFailureReason: typeof import("./bash-tools.exec-runtime.js").formatExecFailureReason; describe("emitExecSystemEvent", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); requestHeartbeatNowMock.mockClear(); enqueueSystemEventMock.mockClear(); + vi.doMock("../infra/heartbeat-wake.js", () => ({ + requestHeartbeatNow: requestHeartbeatNowMock, + })); + vi.doMock("../infra/system-events.js", () => ({ + enqueueSystemEvent: enqueueSystemEventMock, + })); + ({ buildExecExitOutcome, emitExecSystemEvent, formatExecFailureReason } = + await import("./bash-tools.exec-runtime.js")); }); it("scopes heartbeat wake to the event session key", () => { diff --git a/src/agents/bash-tools.exec.path.test.ts b/src/agents/bash-tools.exec.path.test.ts index 247c21aede9..1e938906139 100644 --- a/src/agents/bash-tools.exec.path.test.ts +++ b/src/agents/bash-tools.exec.path.test.ts @@ -7,13 +7,17 @@ import { captureEnv } from "../test-utils/env.js"; import { sanitizeBinaryOutput } from "./shell-utils.js"; const isWin = process.platform === "win32"; +const shellEnvMocks = vi.hoisted(() => ({ + getShellPathFromLoginShell: vi.fn(() => "/custom/bin:/opt/bin"), + resolveShellEnvFallbackTimeoutMs: vi.fn(() => 1234), +})); vi.mock("../infra/shell-env.js", async (importOriginal) => { const mod = await importOriginal(); return { ...mod, - getShellPathFromLoginShell: vi.fn(() => "/custom/bin:/opt/bin"), - resolveShellEnvFallbackTimeoutMs: vi.fn(() => 1234), + getShellPathFromLoginShell: shellEnvMocks.getShellPathFromLoginShell, + resolveShellEnvFallbackTimeoutMs: shellEnvMocks.resolveShellEnvFallbackTimeoutMs, }; }); @@ -51,8 +55,56 @@ vi.mock("../infra/exec-approvals.js", async (importOriginal) => { return { ...mod, resolveExecApprovals: () => approvals }; }); -const { createExecTool } = await import("./bash-tools.exec.js"); -const { getShellPathFromLoginShell } = await import("../infra/shell-env.js"); +let createExecTool: typeof import("./bash-tools.exec.js").createExecTool; + +async function loadFreshBashExecPathModulesForTest() { + vi.resetModules(); + vi.doMock("../infra/shell-env.js", async (importOriginal) => { + const mod = await importOriginal(); + return { + ...mod, + getShellPathFromLoginShell: shellEnvMocks.getShellPathFromLoginShell, + resolveShellEnvFallbackTimeoutMs: shellEnvMocks.resolveShellEnvFallbackTimeoutMs, + }; + }); + vi.doMock("../infra/exec-approvals.js", async (importOriginal) => { + const mod = await importOriginal(); + const approvals: ExecApprovalsResolved = { + path: "/tmp/exec-approvals.json", + socketPath: "/tmp/exec-approvals.sock", + token: "token", + defaults: { + security: "full", + ask: "off", + askFallback: "full", + autoAllowSkills: false, + }, + agent: { + security: "full", + ask: "off", + askFallback: "full", + autoAllowSkills: false, + }, + allowlist: [], + file: { + version: 1, + socket: { path: "/tmp/exec-approvals.sock", token: "token" }, + defaults: { + security: "full", + ask: "off", + askFallback: "full", + autoAllowSkills: false, + }, + agents: {}, + }, + }; + return { ...mod, resolveExecApprovals: () => approvals }; + }); + const bashExec = await import("./bash-tools.exec.js"); + return { + createExecTool: bashExec.createExecTool, + }; +} const normalizeText = (value?: string) => sanitizeBinaryOutput(value ?? "") @@ -69,8 +121,13 @@ const normalizePathEntries = (value?: string) => describe("exec PATH login shell merge", () => { let envSnapshot: ReturnType; - beforeEach(() => { + beforeEach(async () => { envSnapshot = captureEnv(["PATH", "SHELL"]); + shellEnvMocks.getShellPathFromLoginShell.mockReset(); + shellEnvMocks.getShellPathFromLoginShell.mockReturnValue("/custom/bin:/opt/bin"); + shellEnvMocks.resolveShellEnvFallbackTimeoutMs.mockReset(); + shellEnvMocks.resolveShellEnvFallbackTimeoutMs.mockReturnValue(1234); + ({ createExecTool } = await loadFreshBashExecPathModulesForTest()); }); afterEach(() => { @@ -83,7 +140,7 @@ describe("exec PATH login shell merge", () => { } process.env.PATH = "/usr/bin"; - const shellPathMock = vi.mocked(getShellPathFromLoginShell); + const shellPathMock = shellEnvMocks.getShellPathFromLoginShell; shellPathMock.mockClear(); shellPathMock.mockReturnValue("/custom/bin:/opt/bin"); @@ -115,7 +172,7 @@ describe("exec PATH login shell merge", () => { } process.env.PATH = "/usr/bin"; - const shellPathMock = vi.mocked(getShellPathFromLoginShell); + const shellPathMock = shellEnvMocks.getShellPathFromLoginShell; shellPathMock.mockClear(); const tool = createExecTool({ host: "gateway", security: "full", ask: "off" }); @@ -160,7 +217,7 @@ describe("exec PATH login shell merge", () => { process.env.SHELL = unregisteredShellPath; try { - const shellPathMock = vi.mocked(getShellPathFromLoginShell); + const shellPathMock = shellEnvMocks.getShellPathFromLoginShell; shellPathMock.mockClear(); shellPathMock.mockImplementation((opts) => opts.env.SHELL?.trim() === unregisteredShellPath ? null : "/custom/bin:/opt/bin", diff --git a/src/agents/bash-tools.process.supervisor.test.ts b/src/agents/bash-tools.process.supervisor.test.ts index 44770a47c63..e776233ff19 100644 --- a/src/agents/bash-tools.process.supervisor.test.ts +++ b/src/agents/bash-tools.process.supervisor.test.ts @@ -1,12 +1,4 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { - addSession, - getFinishedSession, - getSession, - resetProcessRegistryForTests, -} from "./bash-process-registry.js"; -import { createProcessSessionFixture } from "./bash-process-registry.test-helpers.js"; -import { createProcessTool } from "./bash-tools.process.js"; const { supervisorMock } = vi.hoisted(() => ({ supervisorMock: { @@ -30,6 +22,21 @@ vi.mock("../process/kill-tree.js", () => ({ killProcessTree: (...args: unknown[]) => killProcessTreeMock(...args), })); +let addSession: typeof import("./bash-process-registry.js").addSession; +let getFinishedSession: typeof import("./bash-process-registry.js").getFinishedSession; +let getSession: typeof import("./bash-process-registry.js").getSession; +let resetProcessRegistryForTests: typeof import("./bash-process-registry.js").resetProcessRegistryForTests; +let createProcessSessionFixture: typeof import("./bash-process-registry.test-helpers.js").createProcessSessionFixture; +let createProcessTool: typeof import("./bash-tools.process.js").createProcessTool; + +async function loadFreshProcessToolModulesForTest() { + vi.resetModules(); + ({ addSession, getFinishedSession, getSession, resetProcessRegistryForTests } = + await import("./bash-process-registry.js")); + ({ createProcessSessionFixture } = await import("./bash-process-registry.test-helpers.js")); + ({ createProcessTool } = await import("./bash-tools.process.js")); +} + function createBackgroundSession(id: string, pid?: number) { return createProcessSessionFixture({ id, @@ -40,7 +47,8 @@ function createBackgroundSession(id: string, pid?: number) { } describe("process tool supervisor cancellation", () => { - beforeEach(() => { + beforeEach(async () => { + await loadFreshProcessToolModulesForTest(); supervisorMock.spawn.mockClear(); supervisorMock.cancel.mockClear(); supervisorMock.cancelScope.mockClear(); diff --git a/src/agents/claude-cli-runner.test.ts b/src/agents/claude-cli-runner.test.ts index 2b45a912583..f982e19588d 100644 --- a/src/agents/claude-cli-runner.test.ts +++ b/src/agents/claude-cli-runner.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runClaudeCliAgent } from "./claude-cli-runner.js"; const mocks = vi.hoisted(() => ({ spawn: vi.fn(), @@ -50,6 +49,13 @@ function createManagedRun( }; } +let runClaudeCliAgent: typeof import("./claude-cli-runner.js").runClaudeCliAgent; + +async function loadFreshClaudeCliRunnerModuleForTest() { + vi.resetModules(); + ({ runClaudeCliAgent } = await import("./claude-cli-runner.js")); +} + function successExit(payload: { message: string; session_id: string }) { return { reason: "exit" as const, @@ -73,7 +79,8 @@ async function waitForCalls(mockFn: { mock: { calls: unknown[][] } }, count: num } describe("runClaudeCliAgent", () => { - beforeEach(() => { + beforeEach(async () => { + await loadFreshClaudeCliRunnerModuleForTest(); mocks.spawn.mockClear(); }); diff --git a/src/agents/cli-runner.test.ts b/src/agents/cli-runner.test.ts index e77ac021fd7..4d7420d83e6 100644 --- a/src/agents/cli-runner.test.ts +++ b/src/agents/cli-runner.test.ts @@ -3,7 +3,6 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { runCliAgent } from "./cli-runner.js"; import { resolveCliNoOutputTimeoutMs } from "./cli-runner/helpers.js"; import type { EmbeddedContextFile } from "./pi-embedded-helpers.js"; import type { WorkspaceBootstrapFile } from "./workspace.js"; @@ -48,6 +47,32 @@ vi.mock("./bootstrap-files.js", () => ({ resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, })); +let runCliAgent: typeof import("./cli-runner.js").runCliAgent; + +async function loadFreshCliRunnerModuleForTest() { + vi.resetModules(); + vi.doMock("../process/supervisor/index.js", () => ({ + getProcessSupervisor: () => ({ + spawn: (...args: unknown[]) => supervisorSpawnMock(...args), + cancel: vi.fn(), + cancelScope: vi.fn(), + reconcileOrphans: vi.fn(), + getRecord: vi.fn(), + }), + })); + vi.doMock("../infra/system-events.js", () => ({ + enqueueSystemEvent: (...args: unknown[]) => enqueueSystemEventMock(...args), + })); + vi.doMock("../infra/heartbeat-wake.js", () => ({ + requestHeartbeatNow: (...args: unknown[]) => requestHeartbeatNowMock(...args), + })); + vi.doMock("./bootstrap-files.js", () => ({ + makeBootstrapWarn: () => () => {}, + resolveBootstrapContextForRun: hoisted.resolveBootstrapContextForRunMock, + })); + ({ runCliAgent } = await import("./cli-runner.js")); +} + type MockRunExit = { reason: | "manual-cancel" @@ -77,7 +102,7 @@ function createManagedRun(exit: MockRunExit, pid = 1234) { } describe("runCliAgent with process supervisor", () => { - beforeEach(() => { + beforeEach(async () => { supervisorSpawnMock.mockClear(); enqueueSystemEventMock.mockClear(); requestHeartbeatNowMock.mockClear(); @@ -85,6 +110,7 @@ describe("runCliAgent with process supervisor", () => { bootstrapFiles: [], contextFiles: [], }); + await loadFreshCliRunnerModuleForTest(); }); it("runs CLI through supervisor and returns payload", async () => { diff --git a/src/agents/command/session-store.ts b/src/agents/command/session-store.ts index 0df9d66dc72..12b85623279 100644 --- a/src/agents/command/session-store.ts +++ b/src/agents/command/session-store.ts @@ -56,6 +56,7 @@ export async function updateSessionStoreAfterAgentRun(params: { model: modelUsed, contextTokensOverride: params.contextTokensOverride, fallbackContextTokens: DEFAULT_CONTEXT_TOKENS, + allowAsyncLoad: false, }) ?? DEFAULT_CONTEXT_TOKENS; const entry = sessionStore[sessionKey] ?? { diff --git a/src/agents/compaction.identifier-preservation.test.ts b/src/agents/compaction.identifier-preservation.test.ts index 139c4923b27..deed5e80b60 100644 --- a/src/agents/compaction.identifier-preservation.test.ts +++ b/src/agents/compaction.identifier-preservation.test.ts @@ -2,7 +2,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { ExtensionContext } from "@mariozechner/pi-coding-agent"; import * as piCodingAgent from "@mariozechner/pi-coding-agent"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { buildCompactionSummarizationInstructions, summarizeInStages } from "./compaction.js"; vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => { const actual = await importOriginal(); @@ -13,7 +12,16 @@ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => { }); const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary); -type SummarizeInStagesInput = Parameters[0]; +type SummarizeInStagesInput = Parameters[0]; + +let buildCompactionSummarizationInstructions: typeof import("./compaction.js").buildCompactionSummarizationInstructions; +let summarizeInStages: typeof import("./compaction.js").summarizeInStages; + +async function loadFreshCompactionModuleForTest() { + vi.resetModules(); + ({ buildCompactionSummarizationInstructions, summarizeInStages } = + await import("./compaction.js")); +} function makeMessage(index: number, size = 1200): AgentMessage { return { @@ -38,7 +46,8 @@ describe("compaction identifier-preservation instructions", () => { signal: new AbortController().signal, }; - beforeEach(() => { + beforeEach(async () => { + await loadFreshCompactionModuleForTest(); mockGenerateSummary.mockReset(); mockGenerateSummary.mockResolvedValue("summary"); }); diff --git a/src/agents/model-catalog.test.ts b/src/agents/model-catalog.test.ts index 6d1b7e443e9..dcc16531790 100644 --- a/src/agents/model-catalog.test.ts +++ b/src/agents/model-catalog.test.ts @@ -241,7 +241,6 @@ describe("loadModelCatalog", () => { expect.objectContaining({ provider: "openai-codex", id: "gpt-5.4", - name: "gpt-5.4", }), ); }); diff --git a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts index 8906800aa8e..e818616caff 100644 --- a/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts +++ b/src/agents/models-config.fills-missing-provider-apikey-from-env-var.test.ts @@ -599,8 +599,8 @@ describe("models-config", () => { await expectMoonshotTokenLimits({ contextWindow: 0, maxTokens: -1, - expectedContextWindow: 256000, - expectedMaxTokens: 8192, + expectedContextWindow: 262144, + expectedMaxTokens: 262144, }); }); }); diff --git a/src/agents/models-config.runtime-source-snapshot.test.ts b/src/agents/models-config.runtime-source-snapshot.test.ts index 006a1c893e3..c0172215a30 100644 --- a/src/agents/models-config.runtime-source-snapshot.test.ts +++ b/src/agents/models-config.runtime-source-snapshot.test.ts @@ -1,21 +1,43 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/config.js"; -import { - clearConfigCache, - clearRuntimeConfigSnapshot, - loadConfig, - setRuntimeConfigSnapshot, -} from "../config/config.js"; import { NON_ENV_SECRETREF_MARKER } from "./model-auth-markers.js"; import { installModelsConfigTestHooks, + MODELS_CONFIG_IMPLICIT_ENV_VARS, + unsetEnv, withModelsTempHome as withTempHome, + withTempEnv, } from "./models-config.e2e-harness.js"; -import { ensureOpenClawModelsJson, resetModelsJsonReadyCacheForTest } from "./models-config.js"; -import { readGeneratedModelsJson } from "./models-config.test-utils.js"; + +vi.mock("./models-config.providers.js", async () => { + const actual = await vi.importActual( + "./models-config.providers.js", + ); + return { + ...actual, + resolveImplicitProviders: async () => ({}), + }; +}); installModelsConfigTestHooks(); +let clearConfigCache: typeof import("../config/config.js").clearConfigCache; +let clearRuntimeConfigSnapshot: typeof import("../config/config.js").clearRuntimeConfigSnapshot; +let loadConfig: typeof import("../config/config.js").loadConfig; +let setRuntimeConfigSnapshot: typeof import("../config/config.js").setRuntimeConfigSnapshot; +let ensureOpenClawModelsJson: typeof import("./models-config.js").ensureOpenClawModelsJson; +let resetModelsJsonReadyCacheForTest: typeof import("./models-config.js").resetModelsJsonReadyCacheForTest; +let readGeneratedModelsJson: typeof import("./models-config.test-utils.js").readGeneratedModelsJson; + +beforeEach(async () => { + vi.resetModules(); + ({ clearConfigCache, clearRuntimeConfigSnapshot, loadConfig, setRuntimeConfigSnapshot } = + await import("../config/config.js")); + ({ ensureOpenClawModelsJson, resetModelsJsonReadyCacheForTest } = + await import("./models-config.js")); + ({ readGeneratedModelsJson } = await import("./models-config.test-utils.js")); +}); + afterEach(() => { resetModelsJsonReadyCacheForTest(); }); @@ -114,14 +136,17 @@ async function withGeneratedModelsFromRuntimeSource( runAssertions: () => Promise, ) { await withTempHome(async () => { - try { - setRuntimeConfigSnapshot(params.runtimeConfig, params.sourceConfig); - await ensureOpenClawModelsJson(params.candidateConfig ?? loadConfig()); - await runAssertions(); - } finally { - clearRuntimeConfigSnapshot(); - clearConfigCache(); - } + await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { + unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS); + try { + setRuntimeConfigSnapshot(params.runtimeConfig, params.sourceConfig); + await ensureOpenClawModelsJson(params.candidateConfig ?? loadConfig()); + await runAssertions(); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); }); } @@ -155,116 +180,125 @@ describe("models-config runtime source snapshot", () => { it("uses non-env marker from runtime source snapshot for file refs", async () => { await withTempHome(async () => { - const sourceConfig: OpenClawConfig = { - models: { - providers: { - moonshot: { - baseUrl: "https://api.moonshot.ai/v1", - apiKey: { source: "file", provider: "vault", id: "/moonshot/apiKey" }, - api: "openai-completions" as const, - models: [], + await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { + unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS); + const sourceConfig: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: { source: "file", provider: "vault", id: "/moonshot/apiKey" }, + api: "openai-completions" as const, + models: [], + }, }, }, - }, - }; - const runtimeConfig: OpenClawConfig = { - models: { - providers: { - moonshot: { - baseUrl: "https://api.moonshot.ai/v1", - apiKey: "sk-runtime-moonshot", // pragma: allowlist secret - api: "openai-completions" as const, - models: [], + }; + const runtimeConfig: OpenClawConfig = { + models: { + providers: { + moonshot: { + baseUrl: "https://api.moonshot.ai/v1", + apiKey: "sk-runtime-moonshot", // pragma: allowlist secret + api: "openai-completions" as const, + models: [], + }, }, }, - }, - }; + }; - try { - setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); - await ensureOpenClawModelsJson(loadConfig()); + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(loadConfig()); - const parsed = await readGeneratedModelsJson<{ - providers: Record; - }>(); - expect(parsed.providers.moonshot?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); - } finally { - clearRuntimeConfigSnapshot(); - clearConfigCache(); - } + const parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.moonshot?.apiKey).toBe(NON_ENV_SECRETREF_MARKER); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); }); }); it("projects cloned runtime configs onto source snapshot when preserving provider auth", async () => { await withTempHome(async () => { - const sourceConfig = createOpenAiApiKeySourceConfig(); - const runtimeConfig = createOpenAiApiKeyRuntimeConfig(); - const clonedRuntimeConfig: OpenClawConfig = { - ...runtimeConfig, - agents: { - defaults: { - imageModel: "openai/gpt-image-1", + await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { + unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS); + const sourceConfig = createOpenAiApiKeySourceConfig(); + const runtimeConfig = createOpenAiApiKeyRuntimeConfig(); + const clonedRuntimeConfig: OpenClawConfig = { + ...runtimeConfig, + agents: { + defaults: { + imageModel: "openai/gpt-image-1", + }, }, - }, - }; + }; - try { - setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); - await ensureOpenClawModelsJson(clonedRuntimeConfig); - await expectGeneratedProviderApiKey("openai", "OPENAI_API_KEY"); // pragma: allowlist secret - } finally { - clearRuntimeConfigSnapshot(); - clearConfigCache(); - } + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(clonedRuntimeConfig); + await expectGeneratedProviderApiKey("openai", "OPENAI_API_KEY"); // pragma: allowlist secret + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); }); }); it("invalidates cached readiness when projected config changes under the same runtime snapshot", async () => { await withTempHome(async () => { - const sourceConfig = createOpenAiApiKeySourceConfig(); - const runtimeConfig = createOpenAiApiKeyRuntimeConfig(); - const firstCandidate: OpenClawConfig = { - ...runtimeConfig, - models: { - providers: { - openai: { - ...runtimeConfig.models!.providers!.openai, - baseUrl: "https://api.openai.com/v1", + await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { + unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS); + const sourceConfig = createOpenAiApiKeySourceConfig(); + const runtimeConfig = createOpenAiApiKeyRuntimeConfig(); + const firstCandidate: OpenClawConfig = { + ...runtimeConfig, + models: { + providers: { + openai: { + ...runtimeConfig.models!.providers!.openai, + baseUrl: "https://api.openai.com/v1", + }, }, }, - }, - }; - const secondCandidate: OpenClawConfig = { - ...runtimeConfig, - models: { - providers: { - openai: { - ...runtimeConfig.models!.providers!.openai, - baseUrl: "https://mirror.example/v1", + }; + const secondCandidate: OpenClawConfig = { + ...runtimeConfig, + models: { + providers: { + openai: { + ...runtimeConfig.models!.providers!.openai, + baseUrl: "https://mirror.example/v1", + }, }, }, - }, - }; + }; - try { - setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); - await ensureOpenClawModelsJson(firstCandidate); - let parsed = await readGeneratedModelsJson<{ - providers: Record; - }>(); - expect(parsed.providers.openai?.baseUrl).toBe("https://api.openai.com/v1"); - expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(firstCandidate); + let parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.openai?.baseUrl).toBe("https://api.openai.com/v1"); + expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret - await ensureOpenClawModelsJson(secondCandidate); - parsed = await readGeneratedModelsJson<{ - providers: Record; - }>(); - expect(parsed.providers.openai?.baseUrl).toBe("https://mirror.example/v1"); - expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret - } finally { - clearRuntimeConfigSnapshot(); - clearConfigCache(); - } + await ensureOpenClawModelsJson(secondCandidate); + parsed = await readGeneratedModelsJson<{ + providers: Record; + }>(); + expect(parsed.providers.openai?.baseUrl).toBe("https://mirror.example/v1"); + expect(parsed.providers.openai?.apiKey).toBe("OPENAI_API_KEY"); // pragma: allowlist secret + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); }); }); @@ -280,39 +314,45 @@ describe("models-config runtime source snapshot", () => { it("keeps source markers when runtime projection is skipped for incompatible top-level shape", async () => { await withTempHome(async () => { - const sourceConfig = withGatewayTokenMode(createOpenAiApiKeySourceConfig()); - const runtimeConfig = withGatewayTokenMode(createOpenAiApiKeyRuntimeConfig()); - const incompatibleCandidate: OpenClawConfig = { - ...createOpenAiApiKeyRuntimeConfig(), - }; + await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { + unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS); + const sourceConfig = withGatewayTokenMode(createOpenAiApiKeySourceConfig()); + const runtimeConfig = withGatewayTokenMode(createOpenAiApiKeyRuntimeConfig()); + const incompatibleCandidate: OpenClawConfig = { + ...createOpenAiApiKeyRuntimeConfig(), + }; - try { - setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); - await ensureOpenClawModelsJson(incompatibleCandidate); - await expectGeneratedProviderApiKey("openai", "OPENAI_API_KEY"); // pragma: allowlist secret - } finally { - clearRuntimeConfigSnapshot(); - clearConfigCache(); - } + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(incompatibleCandidate); + await expectGeneratedProviderApiKey("openai", "OPENAI_API_KEY"); // pragma: allowlist secret + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); }); }); it("keeps source header markers when runtime projection is skipped for incompatible top-level shape", async () => { await withTempHome(async () => { - const sourceConfig = withGatewayTokenMode(createOpenAiHeaderSourceConfig()); - const runtimeConfig = withGatewayTokenMode(createOpenAiHeaderRuntimeConfig()); - const incompatibleCandidate: OpenClawConfig = { - ...createOpenAiHeaderRuntimeConfig(), - }; + await withTempEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS, async () => { + unsetEnv(MODELS_CONFIG_IMPLICIT_ENV_VARS); + const sourceConfig = withGatewayTokenMode(createOpenAiHeaderSourceConfig()); + const runtimeConfig = withGatewayTokenMode(createOpenAiHeaderRuntimeConfig()); + const incompatibleCandidate: OpenClawConfig = { + ...createOpenAiHeaderRuntimeConfig(), + }; - try { - setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); - await ensureOpenClawModelsJson(incompatibleCandidate); - await expectGeneratedOpenAiHeaderMarkers(); - } finally { - clearRuntimeConfigSnapshot(); - clearConfigCache(); - } + try { + setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); + await ensureOpenClawModelsJson(incompatibleCandidate); + await expectGeneratedOpenAiHeaderMarkers(); + } finally { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + } + }); }); }); }); diff --git a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts index 559cf140e0f..19eecaf2b17 100644 --- a/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts +++ b/src/agents/models-config.skips-writing-models-json-no-env-token.test.ts @@ -1,6 +1,7 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; import { resolveOpenClawAgentDir } from "./agent-paths.js"; import { CUSTOM_PROXY_MODELS_CONFIG, @@ -10,7 +11,12 @@ import { withTempEnv, withModelsTempHome as withTempHome, } from "./models-config.e2e-harness.js"; -import { ensureOpenClawModelsJson } from "./models-config.js"; + +vi.mock("./auth-profiles/external-cli-sync.js", () => ({ + syncExternalCliCredentials: () => false, +})); + +import { ensureOpenClawModelsJson, resetModelsJsonReadyCacheForTest } from "./models-config.js"; installModelsConfigTestHooks(); @@ -53,7 +59,19 @@ async function runEnvProviderCase(params: { } describe("models-config", () => { - it("skips writing models.json when no env token or profile exists", async () => { + beforeEach(() => { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + resetModelsJsonReadyCacheForTest(); + }); + + afterEach(() => { + clearRuntimeConfigSnapshot(); + clearConfigCache(); + resetModelsJsonReadyCacheForTest(); + }); + + it("writes marker-backed defaults but skips env-gated providers when no env token or profile exists", async () => { await withTempHome(async (home) => { await withTempEnv([...MODELS_CONFIG_IMPLICIT_ENV_VARS, "KIMI_API_KEY"], async () => { unsetEnv([...MODELS_CONFIG_IMPLICIT_ENV_VARS, "KIMI_API_KEY"]); @@ -70,8 +88,19 @@ describe("models-config", () => { agentDir, ); - await expect(fs.stat(path.join(agentDir, "models.json"))).rejects.toThrow(); - expect(result.wrote).toBe(false); + const raw = await fs.readFile(path.join(agentDir, "models.json"), "utf8"); + const parsed = JSON.parse(raw) as { providers: Record }; + + expect(result.wrote).toBe(true); + expect(Object.keys(parsed.providers)).toEqual( + expect.arrayContaining(["chutes", "deepseek", "mistral", "xai"]), + ); + expect(parsed.providers["deepseek"]?.apiKey).toBe("DEEPSEEK_API_KEY"); + expect(parsed.providers["mistral"]?.apiKey).toBe("MISTRAL_API_KEY"); + expect(parsed.providers["xai"]?.apiKey).toBe("XAI_API_KEY"); + expect(parsed.providers["openai"]).toBeUndefined(); + expect(parsed.providers["minimax"]).toBeUndefined(); + expect(parsed.providers["synthetic"]).toBeUndefined(); }); }); }); diff --git a/src/agents/openclaw-gateway-tool.test.ts b/src/agents/openclaw-gateway-tool.test.ts index 9b96ddd6a61..b10334a3778 100644 --- a/src/agents/openclaw-gateway-tool.test.ts +++ b/src/agents/openclaw-gateway-tool.test.ts @@ -1,10 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { withEnvAsync } from "../test-utils/env.js"; import "./test-helpers/fast-core-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; vi.mock("./tools/gateway.js", () => ({ callGatewayTool: vi.fn(async (method: string) => { @@ -37,6 +36,43 @@ vi.mock("./tools/gateway.js", () => ({ readGatewayCallOptions: vi.fn(() => ({})), })); +let createOpenClawTools: typeof import("./openclaw-tools.js").createOpenClawTools; + +async function loadFreshOpenClawToolsModuleForTest() { + vi.resetModules(); + vi.doMock("./tools/gateway.js", () => ({ + callGatewayTool: vi.fn(async (method: string) => { + if (method === "config.get") { + return { hash: "hash-1" }; + } + if (method === "config.schema.lookup") { + return { + path: "gateway.auth", + schema: { + type: "object", + }, + hint: { label: "Gateway Auth" }, + hintPath: "gateway.auth", + children: [ + { + key: "token", + path: "gateway.auth.token", + type: "string", + required: true, + hasChildren: false, + hint: { label: "Token", sensitive: true }, + hintPath: "gateway.auth.token", + }, + ], + }; + } + return { ok: true }; + }), + readGatewayCallOptions: vi.fn(() => ({})), + })); + ({ createOpenClawTools } = await import("./openclaw-tools.js")); +} + function requireGatewayTool(agentSessionKey?: string) { const tool = createOpenClawTools({ ...(agentSessionKey ? { agentSessionKey } : {}), @@ -72,6 +108,10 @@ function expectConfigMutationCall(params: { } describe("gateway tool", () => { + beforeEach(async () => { + await loadFreshOpenClawToolsModuleForTest(); + }); + it("marks gateway as owner-only", async () => { const tool = requireGatewayTool(); expect(tool.ownerOnly).toBe(true); diff --git a/src/agents/openclaw-tools.camera.test.ts b/src/agents/openclaw-tools.camera.test.ts index 5d3f14772fd..70692506fea 100644 --- a/src/agents/openclaw-tools.camera.test.ts +++ b/src/agents/openclaw-tools.camera.test.ts @@ -14,8 +14,7 @@ vi.mock("../media/image-ops.js", () => ({ resizeToJpeg: vi.fn(async () => Buffer.from("jpeg")), })); -import "./test-helpers/fast-core-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; +let createOpenClawTools: typeof import("./openclaw-tools.js").createOpenClawTools; const NODE_ID = "mac-1"; const BASE_RUN_INPUT = { action: "run", node: NODE_ID, command: ["echo", "hi"] } as const; @@ -102,6 +101,13 @@ function expectNoImages(result: NodesToolResult) { expect(images).toHaveLength(0); } +function expectFirstMediaUrl(result: NodesToolResult): string { + const details = result.details as { media?: { mediaUrls?: string[] } } | undefined; + const mediaUrl = details?.media?.mediaUrls?.[0]; + expect(typeof mediaUrl).toBe("string"); + return mediaUrl ?? ""; +} + function expectFirstTextContains(result: NodesToolResult, expectedText: string) { expect(result.content?.[0]).toMatchObject({ type: "text", @@ -188,9 +194,10 @@ async function executePhotosLatest(params: { modelHasVision: boolean }) { }); } -beforeEach(() => { +beforeEach(async () => { callGateway.mockClear(); vi.unstubAllGlobals(); + await loadOpenClawToolsForTest(); }); describe("nodes camera_snap", () => { @@ -252,10 +259,8 @@ describe("nodes camera_snap", () => { ); expectNoImages(result); - expect(result.content?.[0]).toMatchObject({ - type: "text", - text: expect.stringMatching(/^MEDIA:/), - }); + expect(result.content ?? []).toEqual([]); + expect(expectFirstMediaUrl(result)).toMatch(/openclaw-camera-snap-front-.*\.jpg$/); }); it("passes deviceId when provided", async () => { @@ -306,11 +311,8 @@ describe("nodes camera_snap", () => { facing: "front", }); - expect(result.content?.[0]).toMatchObject({ type: "text" }); - const mediaPath = String((result.content?.[0] as { text?: string } | undefined)?.text ?? "") - .replace(/^MEDIA:/, "") - .trim(); - await expect(readFileUtf8AndCleanup(mediaPath)).resolves.toBe("url-image"); + expect(result.content ?? []).toEqual([]); + await expect(readFileUtf8AndCleanup(expectFirstMediaUrl(result))).resolves.toBe("url-image"); }); it("rejects camera_snap url payloads when node remoteIp is missing", async () => { @@ -417,16 +419,15 @@ describe("nodes photos_latest", () => { const result = await executePhotosLatest({ modelHasVision: false }); expectNoImages(result); - expect(result.content?.[0]).toMatchObject({ - type: "text", - text: expect.stringMatching(/^MEDIA:/), - }); - const details = Array.isArray(result.details) ? result.details : []; + expect(result.content ?? []).toEqual([]); + const details = + (result.details as { photos?: Array> } | undefined)?.photos ?? []; expect(details[0]).toMatchObject({ width: 1, height: 1, createdAt: "2026-03-04T00:00:00Z", }); + expect(expectFirstMediaUrl(result)).toMatch(/openclaw-camera-snap-.*\.jpg$/); }); it("includes inline image blocks when model has vision", async () => { @@ -434,11 +435,8 @@ describe("nodes photos_latest", () => { const result = await executePhotosLatest({ modelHasVision: true }); - expect(result.content?.[0]).toMatchObject({ - type: "text", - text: expect.stringMatching(/^MEDIA:/), - }); expectSingleImage(result, { mimeType: "image/jpeg" }); + expect(expectFirstMediaUrl(result)).toMatch(/openclaw-camera-snap-.*\.jpg$/); }); }); @@ -791,3 +789,8 @@ describe("nodes invoke", () => { }); }); }); +async function loadOpenClawToolsForTest(): Promise { + vi.resetModules(); + await import("./test-helpers/fast-core-tools.js"); + ({ createOpenClawTools } = await import("./openclaw-tools.js")); +} diff --git a/src/agents/openclaw-tools.session-status.test.ts b/src/agents/openclaw-tools.session-status.test.ts index c30042b5d54..768d7060cd3 100644 --- a/src/agents/openclaw-tools.session-status.test.ts +++ b/src/agents/openclaw-tools.session-status.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const loadSessionStoreMock = vi.fn(); const updateSessionStoreMock = vi.fn(); @@ -98,8 +98,83 @@ vi.mock("../infra/provider-usage.js", () => ({ formatUsageSummaryLine: () => null, })); -import "./test-helpers/fast-core-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; +let createOpenClawTools: typeof import("./openclaw-tools.js").createOpenClawTools; + +async function loadFreshOpenClawToolsForSessionStatusTest() { + vi.resetModules(); + vi.doMock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath), + updateSessionStore: async ( + storePath: string, + mutator: (store: Record) => Promise | void, + ) => { + const store = loadSessionStoreMock(storePath) as Record; + await mutator(store); + updateSessionStoreMock(storePath, store); + return store; + }, + resolveStorePath: (_store: string | undefined, opts?: { agentId?: string }) => + opts?.agentId === "support" ? "/tmp/support/sessions.json" : "/tmp/main/sessions.json", + }; + }); + vi.doMock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), + })); + vi.doMock("../gateway/session-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadCombinedSessionStoreForGateway: (cfg: unknown) => + loadCombinedSessionStoreForGatewayMock(cfg), + }; + }); + vi.doMock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => mockConfig, + }; + }); + vi.doMock("../agents/model-catalog.js", () => ({ + loadModelCatalog: async () => [ + { + provider: "anthropic", + id: "claude-sonnet-4-6", + name: "Claude Sonnet 4.6", + contextWindow: 200000, + }, + { + provider: "openai", + id: "gpt-5.4", + name: "GPT-5.4", + contextWindow: 400000, + }, + ], + })); + vi.doMock("../agents/auth-profiles.js", () => ({ + ensureAuthProfileStore: () => ({ profiles: {} }), + resolveAuthProfileDisplayLabel: () => undefined, + resolveAuthProfileOrder: () => [], + })); + vi.doMock("../agents/model-auth.js", () => ({ + resolveEnvApiKey: () => null, + resolveUsableCustomProviderApiKey: () => null, + resolveModelAuthMode: () => "api-key", + })); + vi.doMock("../infra/provider-usage.js", () => ({ + resolveUsageProviderId: () => undefined, + loadProviderUsageSummary: async () => ({ + updatedAt: Date.now(), + providers: [], + }), + formatUsageSummaryLine: () => null, + })); + await import("./test-helpers/fast-core-tools.js"); + ({ createOpenClawTools } = await import("./openclaw-tools.js")); +} function resetSessionStore(store: Record) { loadSessionStoreMock.mockClear(); @@ -172,6 +247,10 @@ function getSessionStatusTool(agentSessionKey = "main", options?: { sandboxed?: } describe("session_status tool", () => { + beforeEach(async () => { + await loadFreshOpenClawToolsForSessionStatusTest(); + }); + it("returns a status card for the current session", async () => { resetSessionStore({ main: { diff --git a/src/agents/openclaw-tools.sessions-visibility.test.ts b/src/agents/openclaw-tools.sessions-visibility.test.ts index 193eaa1195f..fbac0eb52fd 100644 --- a/src/agents/openclaw-tools.sessions-visibility.test.ts +++ b/src/agents/openclaw-tools.sessions-visibility.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const callGatewayMock = vi.fn(); vi.mock("../gateway/call.js", () => ({ @@ -18,7 +18,24 @@ vi.mock("../config/config.js", async (importOriginal) => { }); import "./test-helpers/fast-core-tools.js"; -import { createOpenClawTools } from "./openclaw-tools.js"; + +let createOpenClawTools: typeof import("./openclaw-tools.js").createOpenClawTools; + +async function loadFreshOpenClawToolsModuleForTest() { + vi.resetModules(); + vi.doMock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), + })); + vi.doMock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => mockConfig, + resolveGatewayPort: () => 18789, + }; + }); + ({ createOpenClawTools } = await import("./openclaw-tools.js")); +} function getSessionsHistoryTool(options?: { sandboxed?: boolean }) { const tool = createOpenClawTools({ @@ -50,6 +67,10 @@ function mockGatewayWithHistory( } describe("sessions tools visibility", () => { + beforeEach(async () => { + await loadFreshOpenClawToolsModuleForTest(); + }); + it("defaults to tree visibility (self + spawned) for sessions_history", async () => { mockConfig = { session: { mainKey: "main", scope: "per-sender" }, diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts index 34fcbfbafd4..f41a43dd1a4 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn-depth-limits.test.ts @@ -2,9 +2,7 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { addSubagentRunForTests, resetSubagentRegistryForTests } from "./subagent-registry.js"; import { createPerSenderSessionConfig } from "./test-helpers/session-config.js"; -import { createSessionsSpawnTool } from "./tools/sessions-spawn-tool.js"; const callGatewayMock = vi.fn(); @@ -16,6 +14,9 @@ let storeTemplatePath = ""; let configOverride: Record = { session: createPerSenderSessionConfig(), }; +let addSubagentRunForTests: typeof import("./subagent-registry.js").addSubagentRunForTests; +let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests; +let createSessionsSpawnTool: typeof import("./tools/sessions-spawn-tool.js").createSessionsSpawnTool; vi.mock("../config/config.js", async (importOriginal) => { const actual = await importOriginal(); @@ -60,8 +61,26 @@ function seedDepthTwoAncestryStore(params?: { sessionIds?: boolean }) { return { depth1, callerKey }; } +async function loadFreshSessionsSpawnModulesForTest() { + vi.resetModules(); + vi.doMock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), + })); + vi.doMock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => configOverride, + }; + }); + ({ addSubagentRunForTests, resetSubagentRegistryForTests } = + await import("./subagent-registry.js")); + ({ createSessionsSpawnTool } = await import("./tools/sessions-spawn-tool.js")); +} + describe("sessions_spawn depth + child limits", () => { - beforeEach(() => { + beforeEach(async () => { + await loadFreshSessionsSpawnModulesForTest(); resetSubagentRegistryForTests(); callGatewayMock.mockClear(); storeTemplatePath = path.join( diff --git a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts index 77b948ea5af..f4d9cd39504 100644 --- a/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts +++ b/src/agents/openclaw-tools.subagents.sessions-spawn.lifecycle.test.ts @@ -16,12 +16,16 @@ const fastModeEnv = vi.hoisted(() => { return { previous }; }); -vi.mock("./pi-embedded.js", () => ({ - isEmbeddedPiRunActive: () => false, - isEmbeddedPiRunStreaming: () => false, - queueEmbeddedPiMessage: () => false, - waitForEmbeddedPiRunEnd: async () => true, -})); +vi.mock("./pi-embedded.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isEmbeddedPiRunActive: () => false, + isEmbeddedPiRunStreaming: () => false, + queueEmbeddedPiMessage: () => false, + waitForEmbeddedPiRunEnd: async () => true, + }; +}); vi.mock("./tools/agent-step.js", () => ({ readLatestAssistantReply: async () => "done", @@ -360,6 +364,8 @@ describe("openclaw-tools: subagents (sessions_spawn lifecycle)", () => { endedAt: 2000, }); + await waitFor(() => ctx.calls.filter((call) => call.method === "agent").length >= 2); + const agentCalls = ctx.calls.filter((call) => call.method === "agent"); expect(agentCalls).toHaveLength(2); const announceParams = agentCalls[1]?.params as diff --git a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts index 7ddaac87df4..3c30b63e37f 100644 --- a/src/agents/pi-embedded-runner.sanitize-session-history.test.ts +++ b/src/agents/pi-embedded-runner.sanitize-session-history.test.ts @@ -33,7 +33,7 @@ const nextTimestamp = () => testTimestamp++; // We rely on the real implementation which should pass through our simple messages. describe("sanitizeSessionHistory", () => { - const mockSessionManager = makeMockSessionManager(); + let mockSessionManager: ReturnType; const mockMessages = makeSimpleUserMessages(); const setNonGoogleModelApi = () => { vi.mocked(mockedHelpers.isGoogleModelApi).mockReturnValue(false); @@ -191,6 +191,7 @@ describe("sanitizeSessionHistory", () => { const harness = await loadSanitizeSessionHistoryWithCleanMocks(); sanitizeSessionHistory = harness.sanitizeSessionHistory; mockedHelpers = harness.mockedHelpers; + mockSessionManager = makeMockSessionManager(); }); it("passes simple user-only history through for Google model APIs", async () => { diff --git a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts index 3c62e463620..a49ff63842d 100644 --- a/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts +++ b/src/agents/pi-embedded-runner/context-engine-maintenance.test.ts @@ -10,6 +10,8 @@ const rewriteTranscriptEntriesInSessionFileMock = vi.fn(async (_params?: unknown bytesFreed: 123, rewrittenEntries: 2, })); +let buildContextEngineMaintenanceRuntimeContext: typeof import("./context-engine-maintenance.js").buildContextEngineMaintenanceRuntimeContext; +let runContextEngineMaintenance: typeof import("./context-engine-maintenance.js").runContextEngineMaintenance; vi.mock("./transcript-rewrite.js", () => ({ rewriteTranscriptEntriesInSessionManager: (params: unknown) => @@ -18,15 +20,17 @@ vi.mock("./transcript-rewrite.js", () => ({ rewriteTranscriptEntriesInSessionFileMock(params), })); -import { - buildContextEngineMaintenanceRuntimeContext, - runContextEngineMaintenance, -} from "./context-engine-maintenance.js"; +async function loadFreshContextEngineMaintenanceModuleForTest() { + vi.resetModules(); + ({ buildContextEngineMaintenanceRuntimeContext, runContextEngineMaintenance } = + await import("./context-engine-maintenance.js")); +} describe("buildContextEngineMaintenanceRuntimeContext", () => { - beforeEach(() => { + beforeEach(async () => { rewriteTranscriptEntriesInSessionManagerMock.mockClear(); rewriteTranscriptEntriesInSessionFileMock.mockClear(); + await loadFreshContextEngineMaintenanceModuleForTest(); }); it("adds a transcript rewrite helper that targets the current session file", async () => { @@ -96,9 +100,10 @@ describe("buildContextEngineMaintenanceRuntimeContext", () => { }); describe("runContextEngineMaintenance", () => { - beforeEach(() => { + beforeEach(async () => { rewriteTranscriptEntriesInSessionManagerMock.mockClear(); rewriteTranscriptEntriesInSessionFileMock.mockClear(); + await loadFreshContextEngineMaintenanceModuleForTest(); }); it("passes a rewrite-capable runtime context into maintain()", async () => { diff --git a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts index 4ebd56c5d05..6dcd7a96298 100644 --- a/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts +++ b/src/agents/pi-embedded-runner/extra-params.kilocode.test.ts @@ -1,57 +1,75 @@ -import type { Model } from "@mariozechner/pi-ai"; +import type { StreamFn } from "@mariozechner/pi-agent-core"; +import type { Context, Model, SimpleStreamOptions } from "@mariozechner/pi-ai"; import { afterEach, describe, expect, it } from "vitest"; -import type { OpenClawConfig } from "../../config/config.js"; import { captureEnv } from "../../test-utils/env.js"; -import { runExtraParamsCase } from "./extra-params.test-support.js"; +import { createKilocodeWrapper, isProxyReasoningUnsupported } from "./proxy-stream-wrappers.js"; -const TEST_CFG = { - plugins: { - entries: { - kilocode: { - enabled: true, - }, - }, - }, -} satisfies OpenClawConfig; +type ExtraParamsCapture> = { + headers?: Record; + payload: TPayload; +}; function applyAndCapture(params: { provider: string; modelId: string; callerHeaders?: Record; - cfg?: OpenClawConfig; }) { - return runExtraParamsCase({ - applyModelId: params.modelId, - applyProvider: params.provider, - callerHeaders: params.callerHeaders, - cfg: params.cfg ?? TEST_CFG, - model: { + const captured: ExtraParamsCapture> = { payload: {} }; + const baseStreamFn: StreamFn = (model, _context, options) => { + captured.headers = options?.headers; + options?.onPayload?.(captured.payload, model); + return {} as ReturnType; + }; + const streamFn = + params.provider === "kilocode" + ? createKilocodeWrapper(baseStreamFn, params.modelId === "kilo/auto" ? undefined : "high") + : baseStreamFn; + + const context: Context = { messages: [] }; + void streamFn( + { api: "openai-completions", provider: params.provider, id: params.modelId, } as Model<"openai-completions">, - payload: {}, - }); + context, + { + headers: params.callerHeaders, + } as SimpleStreamOptions, + ); + + return captured; } function applyAndCaptureReasoning(params: { - cfg?: OpenClawConfig; modelId: string; initialPayload?: Record; thinkingLevel?: "minimal" | "low" | "medium" | "high"; }) { - return runExtraParamsCase({ - applyModelId: params.modelId, - applyProvider: "kilocode", - cfg: params.cfg ?? TEST_CFG, - model: { + const captured: ExtraParamsCapture> = { + payload: { ...params.initialPayload }, + }; + const baseStreamFn: StreamFn = (model, _context, options) => { + options?.onPayload?.(captured.payload, model); + return {} as ReturnType; + }; + const thinkingLevel = + params.modelId === "kilo/auto" || isProxyReasoningUnsupported(params.modelId) + ? undefined + : (params.thinkingLevel ?? "high"); + const streamFn = createKilocodeWrapper(baseStreamFn, thinkingLevel); + const context: Context = { messages: [] }; + void streamFn( + { api: "openai-completions", provider: "kilocode", id: params.modelId, } as Model<"openai-completions">, - payload: { ...params.initialPayload }, - thinkingLevel: params.thinkingLevel ?? "high", - }).payload; + context, + {} as SimpleStreamOptions, + ); + + return captured.payload; } describe("extra-params: Kilocode wrapper", () => { @@ -101,11 +119,6 @@ describe("extra-params: Kilocode wrapper", () => { const { headers } = applyAndCapture({ provider: "kilocode", modelId: "anthropic/claude-sonnet-4", - cfg: { - plugins: { - allow: ["openrouter"], - }, - }, }); expect(headers?.["X-KILOCODE-FEATURE"]).toBe("openclaw"); @@ -126,7 +139,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { const capturedPayload = applyAndCaptureReasoning({ modelId: "kilo/auto", initialPayload: { reasoning_effort: "high" }, - }) as Record; + }); // kilo/auto should not have reasoning injected expect(capturedPayload?.reasoning).toBeUndefined(); @@ -136,7 +149,7 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { it("injects reasoning.effort for non-auto kilocode models", () => { const capturedPayload = applyAndCaptureReasoning({ modelId: "anthropic/claude-sonnet-4", - }) as Record; + }); // Non-auto models should have reasoning injected expect(capturedPayload?.reasoning).toEqual({ effort: "high" }); @@ -144,30 +157,18 @@ describe("extra-params: Kilocode kilo/auto reasoning", () => { it("still normalizes reasoning for Kilocode under restrictive plugins.allow", () => { const capturedPayload = applyAndCaptureReasoning({ - cfg: { - plugins: { - allow: ["openrouter"], - }, - }, modelId: "anthropic/claude-sonnet-4", - }) as Record; + }); expect(capturedPayload?.reasoning).toEqual({ effort: "high" }); }); it("does not inject reasoning.effort for x-ai models", () => { - const capturedPayload = runExtraParamsCase({ - applyModelId: "x-ai/grok-3", - applyProvider: "kilocode", - cfg: TEST_CFG, - model: { - api: "openai-completions", - provider: "kilocode", - id: "x-ai/grok-3", - } as Model<"openai-completions">, - payload: { reasoning_effort: "high" }, + const capturedPayload = applyAndCaptureReasoning({ + modelId: "x-ai/grok-3", + initialPayload: { reasoning_effort: "high" }, thinkingLevel: "high", - }).payload as Record; + }); // x-ai models reject reasoning.effort — should be skipped expect(capturedPayload?.reasoning).toBeUndefined(); diff --git a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts index 016130ff23d..a37a7ba289f 100644 --- a/src/agents/pi-embedded-runner/tool-result-truncation.test.ts +++ b/src/agents/pi-embedded-runner/tool-result-truncation.test.ts @@ -2,7 +2,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import type { AssistantMessage, ToolResultMessage, UserMessage } from "@mariozechner/pi-ai"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; import { makeAgentAssistantMessage } from "../test-helpers/agent-message-fixtures.js"; const acquireSessionWriteLockReleaseMock = vi.hoisted(() => vi.fn(async () => {})); @@ -14,25 +13,44 @@ vi.mock("../session-write-lock.js", () => ({ acquireSessionWriteLock: (params: unknown) => acquireSessionWriteLockMock(params), })); -import { - truncateToolResultText, - truncateToolResultMessage, - calculateMaxToolResultChars, - getToolResultTextLength, - truncateOversizedToolResultsInMessages, - truncateOversizedToolResultsInSession, - isOversizedToolResult, - sessionLikelyHasOversizedToolResults, - HARD_MAX_TOOL_RESULT_CHARS, -} from "./tool-result-truncation.js"; +let truncateToolResultText: typeof import("./tool-result-truncation.js").truncateToolResultText; +let truncateToolResultMessage: typeof import("./tool-result-truncation.js").truncateToolResultMessage; +let calculateMaxToolResultChars: typeof import("./tool-result-truncation.js").calculateMaxToolResultChars; +let getToolResultTextLength: typeof import("./tool-result-truncation.js").getToolResultTextLength; +let truncateOversizedToolResultsInMessages: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInMessages; +let truncateOversizedToolResultsInSession: typeof import("./tool-result-truncation.js").truncateOversizedToolResultsInSession; +let isOversizedToolResult: typeof import("./tool-result-truncation.js").isOversizedToolResult; +let sessionLikelyHasOversizedToolResults: typeof import("./tool-result-truncation.js").sessionLikelyHasOversizedToolResults; +let HARD_MAX_TOOL_RESULT_CHARS: typeof import("./tool-result-truncation.js").HARD_MAX_TOOL_RESULT_CHARS; +let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; + +async function loadFreshToolResultTruncationModuleForTest() { + vi.resetModules(); + vi.doMock("../session-write-lock.js", () => ({ + acquireSessionWriteLock: (params: unknown) => acquireSessionWriteLockMock(params), + })); + ({ onSessionTranscriptUpdate } = await import("../../sessions/transcript-events.js")); + ({ + truncateToolResultText, + truncateToolResultMessage, + calculateMaxToolResultChars, + getToolResultTextLength, + truncateOversizedToolResultsInMessages, + truncateOversizedToolResultsInSession, + isOversizedToolResult, + sessionLikelyHasOversizedToolResults, + HARD_MAX_TOOL_RESULT_CHARS, + } = await import("./tool-result-truncation.js")); +} let testTimestamp = 1; const nextTimestamp = () => testTimestamp++; -beforeEach(() => { +beforeEach(async () => { testTimestamp = 1; acquireSessionWriteLockMock.mockClear(); acquireSessionWriteLockReleaseMock.mockClear(); + await loadFreshToolResultTruncationModuleForTest(); }); function makeToolResult(text: string, toolCallId = "call_1"): ToolResultMessage { diff --git a/src/agents/pi-embedded-runner/transcript-rewrite.test.ts b/src/agents/pi-embedded-runner/transcript-rewrite.test.ts index 0e698244962..d919e7af476 100644 --- a/src/agents/pi-embedded-runner/transcript-rewrite.test.ts +++ b/src/agents/pi-embedded-runner/transcript-rewrite.test.ts @@ -1,8 +1,6 @@ import type { AgentMessage } from "@mariozechner/pi-agent-core"; import { SessionManager } from "@mariozechner/pi-coding-agent"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js"; -import { installSessionToolResultGuard } from "../session-tool-result-guard.js"; const acquireSessionWriteLockReleaseMock = vi.hoisted(() => vi.fn(async () => {})); const acquireSessionWriteLockMock = vi.hoisted(() => @@ -13,10 +11,21 @@ vi.mock("../session-write-lock.js", () => ({ acquireSessionWriteLock: (params: unknown) => acquireSessionWriteLockMock(params), })); -import { - rewriteTranscriptEntriesInSessionFile, - rewriteTranscriptEntriesInSessionManager, -} from "./transcript-rewrite.js"; +let rewriteTranscriptEntriesInSessionFile: typeof import("./transcript-rewrite.js").rewriteTranscriptEntriesInSessionFile; +let rewriteTranscriptEntriesInSessionManager: typeof import("./transcript-rewrite.js").rewriteTranscriptEntriesInSessionManager; +let onSessionTranscriptUpdate: typeof import("../../sessions/transcript-events.js").onSessionTranscriptUpdate; +let installSessionToolResultGuard: typeof import("../session-tool-result-guard.js").installSessionToolResultGuard; + +async function loadFreshTranscriptRewriteModuleForTest() { + vi.resetModules(); + vi.doMock("../session-write-lock.js", () => ({ + acquireSessionWriteLock: (params: unknown) => acquireSessionWriteLockMock(params), + })); + ({ onSessionTranscriptUpdate } = await import("../../sessions/transcript-events.js")); + ({ installSessionToolResultGuard } = await import("../session-tool-result-guard.js")); + ({ rewriteTranscriptEntriesInSessionFile, rewriteTranscriptEntriesInSessionManager } = + await import("./transcript-rewrite.js")); +} type AppendMessage = Parameters[0]; @@ -31,9 +40,10 @@ function getBranchMessages(sessionManager: SessionManager): AgentMessage[] { .map((entry) => entry.message); } -beforeEach(() => { +beforeEach(async () => { acquireSessionWriteLockMock.mockClear(); acquireSessionWriteLockReleaseMock.mockClear(); + await loadFreshTranscriptRewriteModuleForTest(); }); describe("rewriteTranscriptEntriesInSessionManager", () => { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.test.ts index 44f829affbe..1639dae622a 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.does-not-emit-duplicate-block-replies-text.test.ts @@ -9,13 +9,14 @@ import { import { subscribeEmbeddedPiSession } from "./pi-embedded-subscribe.js"; describe("subscribeEmbeddedPiSession", () => { - it("does not emit duplicate block replies when text_end repeats", () => { + it("does not emit duplicate block replies when text_end repeats", async () => { const onBlockReply = vi.fn(); const { emit, subscription } = createTextEndBlockReplyHarness({ onBlockReply }); emitAssistantTextDelta({ emit, delta: "Hello block" }); emitAssistantTextEnd({ emit }); emitAssistantTextEnd({ emit }); + await Promise.resolve(); expect(onBlockReply).toHaveBeenCalledTimes(1); expect(subscription.assistantTexts).toEqual(["Hello block"]); diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts index 0f66888e32d..fbbbd964d94 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.filters-final-suppresses-output-without-start-tag.test.ts @@ -94,7 +94,7 @@ describe("subscribeEmbeddedPiSession", () => { const payload = onPartialReply.mock.calls[0][0]; expect(payload.text).toBe("Hello world"); }); - it("emits block replies on message_end", () => { + it("emits block replies on message_end", async () => { const { session, emit } = createStubSessionHarness(); const onBlockReply = vi.fn(); @@ -112,6 +112,7 @@ describe("subscribeEmbeddedPiSession", () => { } as AssistantMessage; emit({ type: "message_end", message: assistantMessage }); + await Promise.resolve(); expect(onBlockReply).toHaveBeenCalled(); const payload = onBlockReply.mock.calls[0][0]; diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.test.ts index bff7046cc80..f2c260ca0d0 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.splits-long-single-line-fenced-blocks-reopen.test.ts @@ -11,7 +11,7 @@ import { makeZeroUsageSnapshot } from "./usage.js"; type SessionEventHandler = (evt: unknown) => void; describe("subscribeEmbeddedPiSession", () => { - it("splits long single-line fenced blocks with reopen/close", () => { + it("splits long single-line fenced blocks with reopen/close", async () => { const onBlockReply = vi.fn(); const { emit } = createParagraphChunkedBlockReplyHarness({ onBlockReply, @@ -23,6 +23,7 @@ describe("subscribeEmbeddedPiSession", () => { const text = `\`\`\`json\n${"x".repeat(120)}\n\`\`\``; emitAssistantTextDeltaAndEnd({ emit, text }); + await Promise.resolve(); expectFencedChunks(onBlockReply.mock.calls, "```json"); }); it("waits for auto-compaction retry and clears buffered text", async () => { diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index 8628e5cac2a..1007c0d4b2f 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -106,7 +106,7 @@ describe("subscribeEmbeddedPiSession", () => { it.each(THINKING_TAG_CASES)( "streams <%s> reasoning via onReasoningStream without leaking into final text", - ({ open, close }) => { + async ({ open, close }) => { const onReasoningStream = vi.fn(); const onBlockReply = vi.fn(); @@ -133,7 +133,9 @@ describe("subscribeEmbeddedPiSession", () => { emit({ type: "message_end", message: assistantMessage }); - expect(onBlockReply).toHaveBeenCalledTimes(1); + await vi.waitFor(() => { + expect(onBlockReply).toHaveBeenCalledTimes(1); + }); expect(onBlockReply.mock.calls[0][0].text).toBe("Final answer"); const streamTexts = onReasoningStream.mock.calls @@ -149,7 +151,7 @@ describe("subscribeEmbeddedPiSession", () => { ); it.each(THINKING_TAG_CASES)( "suppresses <%s> blocks across chunk boundaries", - ({ open, close }) => { + async ({ open, close }) => { const onBlockReply = vi.fn(); const { emit } = createSubscribedHarness({ @@ -175,10 +177,12 @@ describe("subscribeEmbeddedPiSession", () => { assistantMessageEvent: { type: "text_end" }, }); + await vi.waitFor(() => { + expect(onBlockReply.mock.calls.length).toBeGreaterThan(0); + }); const payloadTexts = onBlockReply.mock.calls .map((call) => call[0]?.text) .filter((value): value is string => typeof value === "string"); - expect(payloadTexts.length).toBeGreaterThan(0); for (const text of payloadTexts) { expect(text).not.toContain("Reasoning"); expect(text).not.toContain(open); diff --git a/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts b/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts index 927694d06b1..b65d2240bfe 100644 --- a/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts +++ b/src/agents/pi-tool-definition-adapter.after-tool-call.fires-once.test.ts @@ -8,7 +8,7 @@ */ import type { AgentTool } from "@mariozechner/pi-agent-core"; import { Type } from "@sinclair/typebox"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createBaseToolHandlerState } from "./pi-tool-handler-state.test-helpers.js"; const hookMocks = vi.hoisted(() => ({ @@ -28,20 +28,6 @@ const beforeToolCallMocks = vi.hoisted(() => ({ })), })); -vi.mock("../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: () => hookMocks.runner, -})); - -vi.mock("../infra/agent-events.js", () => ({ - emitAgentEvent: vi.fn(), -})); - -vi.mock("./pi-tools.before-tool-call.js", () => ({ - consumeAdjustedParamsForToolCall: beforeToolCallMocks.consumeAdjustedParamsForToolCall, - isToolWrappedWithBeforeToolCallHook: beforeToolCallMocks.isToolWrappedWithBeforeToolCallHook, - runBeforeToolCallHook: beforeToolCallMocks.runBeforeToolCallHook, -})); - function createTestTool(name: string) { return { name, @@ -93,14 +79,26 @@ let toToolDefinitions: typeof import("./pi-tool-definition-adapter.js").toToolDe let handleToolExecutionStart: typeof import("./pi-embedded-subscribe.handlers.tools.js").handleToolExecutionStart; let handleToolExecutionEnd: typeof import("./pi-embedded-subscribe.handlers.tools.js").handleToolExecutionEnd; -describe("after_tool_call fires exactly once in embedded runs", () => { - beforeAll(async () => { - ({ toToolDefinitions } = await import("./pi-tool-definition-adapter.js")); - ({ handleToolExecutionStart, handleToolExecutionEnd } = - await import("./pi-embedded-subscribe.handlers.tools.js")); - }); +async function loadFreshAfterToolCallModulesForTest() { + vi.resetModules(); + vi.doMock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => hookMocks.runner, + })); + vi.doMock("../infra/agent-events.js", () => ({ + emitAgentEvent: vi.fn(), + })); + vi.doMock("./pi-tools.before-tool-call.js", () => ({ + consumeAdjustedParamsForToolCall: beforeToolCallMocks.consumeAdjustedParamsForToolCall, + isToolWrappedWithBeforeToolCallHook: beforeToolCallMocks.isToolWrappedWithBeforeToolCallHook, + runBeforeToolCallHook: beforeToolCallMocks.runBeforeToolCallHook, + })); + ({ toToolDefinitions } = await import("./pi-tool-definition-adapter.js")); + ({ handleToolExecutionStart, handleToolExecutionEnd } = + await import("./pi-embedded-subscribe.handlers.tools.js")); +} - beforeEach(() => { +describe("after_tool_call fires exactly once in embedded runs", () => { + beforeEach(async () => { hookMocks.runner.hasHooks.mockClear(); hookMocks.runner.hasHooks.mockReturnValue(true); hookMocks.runner.runAfterToolCall.mockClear(); @@ -116,6 +114,7 @@ describe("after_tool_call fires exactly once in embedded runs", () => { blocked: false, params, })); + await loadFreshAfterToolCallModulesForTest(); }); function resolveAdapterDefinition(tool: Parameters[0][number]) { diff --git a/src/agents/provider-capabilities.test.ts b/src/agents/provider-capabilities.test.ts index 5dee5ea2113..c1aa496eeb4 100644 --- a/src/agents/provider-capabilities.test.ts +++ b/src/agents/provider-capabilities.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; const resolveProviderCapabilitiesWithPluginMock = vi.fn((params: { provider: string }) => { switch (params.provider) { @@ -44,19 +44,37 @@ vi.mock("../plugins/provider-runtime.js", () => ({ resolveProviderCapabilitiesWithPluginMock(params), })); -import { - isAnthropicProviderFamily, - isOpenAiProviderFamily, - requiresOpenAiCompatibleAnthropicToolPayload, - resolveProviderCapabilities, - resolveTranscriptToolCallIdMode, - shouldDropThinkingBlocksForModel, - shouldSanitizeGeminiThoughtSignaturesForModel, - supportsOpenAiCompatTurnValidation, - usesMoonshotThinkingPayloadCompat, -} from "./provider-capabilities.js"; +let isAnthropicProviderFamily: typeof import("./provider-capabilities.js").isAnthropicProviderFamily; +let isOpenAiProviderFamily: typeof import("./provider-capabilities.js").isOpenAiProviderFamily; +let requiresOpenAiCompatibleAnthropicToolPayload: typeof import("./provider-capabilities.js").requiresOpenAiCompatibleAnthropicToolPayload; +let resolveProviderCapabilities: typeof import("./provider-capabilities.js").resolveProviderCapabilities; +let resolveTranscriptToolCallIdMode: typeof import("./provider-capabilities.js").resolveTranscriptToolCallIdMode; +let shouldDropThinkingBlocksForModel: typeof import("./provider-capabilities.js").shouldDropThinkingBlocksForModel; +let shouldSanitizeGeminiThoughtSignaturesForModel: typeof import("./provider-capabilities.js").shouldSanitizeGeminiThoughtSignaturesForModel; +let supportsOpenAiCompatTurnValidation: typeof import("./provider-capabilities.js").supportsOpenAiCompatTurnValidation; +let usesMoonshotThinkingPayloadCompat: typeof import("./provider-capabilities.js").usesMoonshotThinkingPayloadCompat; + +async function loadFreshProviderCapabilitiesModuleForTest() { + vi.resetModules(); + ({ + isAnthropicProviderFamily, + isOpenAiProviderFamily, + requiresOpenAiCompatibleAnthropicToolPayload, + resolveProviderCapabilities, + resolveTranscriptToolCallIdMode, + shouldDropThinkingBlocksForModel, + shouldSanitizeGeminiThoughtSignaturesForModel, + supportsOpenAiCompatTurnValidation, + usesMoonshotThinkingPayloadCompat, + } = await import("./provider-capabilities.js")); +} describe("resolveProviderCapabilities", () => { + beforeEach(async () => { + await loadFreshProviderCapabilitiesModuleForTest(); + resolveProviderCapabilitiesWithPluginMock.mockClear(); + }); + it("returns provider-owned anthropic defaults for ordinary providers", () => { expect(resolveProviderCapabilities("anthropic")).toEqual({ anthropicToolSchemaMode: "native", diff --git a/src/agents/sandbox/browser.create.test.ts b/src/agents/sandbox/browser.create.test.ts index 88b5feccccc..8bb9352b972 100644 --- a/src/agents/sandbox/browser.create.test.ts +++ b/src/agents/sandbox/browser.create.test.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { BROWSER_BRIDGES } from "./browser-bridges.js"; -import { ensureSandboxBrowser } from "./browser.js"; -import { resetNoVncObserverTokensForTests } from "./novnc-auth.js"; import { collectDockerFlagValues, findDockerArgsCall } from "./test-args.js"; import type { SandboxConfig } from "./types.js"; +let BROWSER_BRIDGES: Map; +let ensureSandboxBrowser: typeof import("./browser.js").ensureSandboxBrowser; +let resetNoVncObserverTokensForTests: typeof import("./novnc-auth.js").resetNoVncObserverTokensForTests; + const dockerMocks = vi.hoisted(() => ({ dockerContainerState: vi.fn(), execDocker: vi.fn(), @@ -45,6 +46,13 @@ vi.mock("../../browser/bridge-server.js", () => ({ stopBrowserBridgeServer: bridgeMocks.stopBrowserBridgeServer, })); +async function loadFreshBrowserModulesForTest() { + vi.resetModules(); + ({ BROWSER_BRIDGES } = await import("./browser-bridges.js")); + ({ ensureSandboxBrowser } = await import("./browser.js")); + ({ resetNoVncObserverTokensForTests } = await import("./novnc-auth.js")); +} + function buildConfig(enableNoVnc: boolean): SandboxConfig { return { mode: "all", @@ -94,7 +102,8 @@ function buildConfig(enableNoVnc: boolean): SandboxConfig { } describe("ensureSandboxBrowser create args", () => { - beforeEach(() => { + beforeEach(async () => { + await loadFreshBrowserModulesForTest(); BROWSER_BRIDGES.clear(); resetNoVncObserverTokensForTests(); dockerMocks.dockerContainerState.mockClear(); diff --git a/src/agents/sandbox/docker.config-hash-recreate.test.ts b/src/agents/sandbox/docker.config-hash-recreate.test.ts index 46d37f9fd61..fa34805655a 100644 --- a/src/agents/sandbox/docker.config-hash-recreate.test.ts +++ b/src/agents/sandbox/docker.config-hash-recreate.test.ts @@ -2,7 +2,6 @@ import { EventEmitter } from "node:events"; import { Readable } from "node:stream"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { computeSandboxConfigHash } from "./config-hash.js"; -import { ensureSandboxContainer } from "./docker.js"; import { collectDockerFlagValues } from "./test-args.js"; import type { SandboxConfig } from "./types.js"; @@ -84,6 +83,73 @@ vi.mock("node:child_process", async (importOriginal) => { }; }); +let ensureSandboxContainer: typeof import("./docker.js").ensureSandboxContainer; + +async function loadFreshDockerModuleForTest() { + vi.resetModules(); + vi.doMock("./registry.js", () => ({ + readRegistry: registryMocks.readRegistry, + updateRegistry: registryMocks.updateRegistry, + })); + vi.doMock("node:child_process", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + spawn: (command: string, args: string[]) => { + spawnState.calls.push({ command, args }); + const child = new EventEmitter() as EventEmitter & { + stdout: Readable; + stderr: Readable; + stdin: { end: (input?: string | Buffer) => void }; + kill: (signal?: NodeJS.Signals) => void; + }; + child.stdout = new Readable({ read() {} }); + child.stderr = new Readable({ read() {} }); + child.stdin = { end: () => undefined }; + child.kill = () => undefined; + + let code = 0; + let stdout = ""; + let stderr = ""; + if (command !== "docker") { + code = 1; + stderr = `unexpected command: ${command}`; + } else if (args[0] === "inspect" && args[1] === "-f" && args[2] === "{{.State.Running}}") { + stdout = spawnState.inspectRunning ? "true\n" : "false\n"; + } else if ( + args[0] === "inspect" && + args[1] === "-f" && + args[2]?.includes('index .Config.Labels "openclaw.configHash"') + ) { + stdout = `${spawnState.labelHash}\n`; + } else if ( + (args[0] === "rm" && args[1] === "-f") || + (args[0] === "image" && args[1] === "inspect") || + args[0] === "create" || + args[0] === "start" + ) { + code = 0; + } else { + code = 1; + stderr = `unexpected docker args: ${args.join(" ")}`; + } + + queueMicrotask(() => { + if (stdout) { + child.stdout.emit("data", Buffer.from(stdout)); + } + if (stderr) { + child.stderr.emit("data", Buffer.from(stderr)); + } + child.emit("close", code); + }); + return child; + }, + }; + }); + ({ ensureSandboxContainer } = await import("./docker.js")); +} + function createSandboxConfig( dns: string[], binds?: string[], @@ -135,13 +201,14 @@ function createSandboxConfig( } describe("ensureSandboxContainer config-hash recreation", () => { - beforeEach(() => { + beforeEach(async () => { spawnState.calls.length = 0; spawnState.inspectRunning = true; spawnState.labelHash = ""; registryMocks.readRegistry.mockClear(); registryMocks.updateRegistry.mockClear(); registryMocks.updateRegistry.mockResolvedValue(undefined); + await loadFreshDockerModuleForTest(); }); it("recreates shared container when array-order change alters hash", async () => { diff --git a/src/agents/sandbox/fs-bridge.shell.test.ts b/src/agents/sandbox/fs-bridge.shell.test.ts index 1e870ef0268..f1d16b661c7 100644 --- a/src/agents/sandbox/fs-bridge.shell.test.ts +++ b/src/agents/sandbox/fs-bridge.shell.test.ts @@ -8,6 +8,7 @@ import { getScriptsFromCalls, installFsBridgeTestHarness, mockedExecDockerRaw, + mockedOpenBoundaryFile, withTempDir, } from "./fs-bridge.test-helpers.js"; @@ -158,7 +159,6 @@ describe("sandbox fs bridge shell compatibility", () => { }); it("re-validates target before the pinned write helper runs", async () => { - const { mockedOpenBoundaryFile } = await import("./fs-bridge.test-helpers.js"); mockedOpenBoundaryFile .mockImplementationOnce(async () => ({ ok: false, reason: "path" })) .mockImplementationOnce(async () => ({ diff --git a/src/agents/sandbox/fs-bridge.test-helpers.ts b/src/agents/sandbox/fs-bridge.test-helpers.ts index 0747371478d..2d1c5092fc6 100644 --- a/src/agents/sandbox/fs-bridge.test-helpers.ts +++ b/src/agents/sandbox/fs-bridge.test-helpers.ts @@ -3,28 +3,62 @@ import os from "node:os"; import path from "node:path"; import { beforeEach, expect, vi } from "vitest"; -vi.mock("./docker.js", () => ({ +let actualOpenBoundaryFile: + | (( + ...args: Parameters + ) => ReturnType) + | undefined; + +const hoisted = vi.hoisted(() => ({ execDockerRaw: vi.fn(), + openBoundaryFile: vi.fn(), +})); + +vi.mock("./docker.js", () => ({ + execDockerRaw: (...args: unknown[]) => hoisted.execDockerRaw(...args), })); vi.mock("../../infra/boundary-file-read.js", async (importOriginal) => { const actual = await importOriginal(); + actualOpenBoundaryFile = actual.openBoundaryFile; return { ...actual, - openBoundaryFile: vi.fn(actual.openBoundaryFile), + openBoundaryFile: (...args: unknown[]) => hoisted.openBoundaryFile(...args), }; }); -import { openBoundaryFile } from "../../infra/boundary-file-read.js"; -import { execDockerRaw } from "./docker.js"; -import * as fsBridgeModule from "./fs-bridge.js"; import { createSandboxTestContext } from "./test-fixtures.js"; import type { SandboxContext } from "./types.js"; -export const createSandboxFsBridge = fsBridgeModule.createSandboxFsBridge; +let createSandboxFsBridgeImpl: typeof import("./fs-bridge.js").createSandboxFsBridge; -export const mockedExecDockerRaw = vi.mocked(execDockerRaw); -export const mockedOpenBoundaryFile = vi.mocked(openBoundaryFile); +async function loadFreshFsBridgeModuleForTest() { + vi.resetModules(); + vi.doMock("./docker.js", () => ({ + execDockerRaw: (...args: unknown[]) => hoisted.execDockerRaw(...args), + })); + vi.doMock("../../infra/boundary-file-read.js", async (importOriginal) => { + const actual = await importOriginal(); + actualOpenBoundaryFile = actual.openBoundaryFile; + return { + ...actual, + openBoundaryFile: (...args: unknown[]) => hoisted.openBoundaryFile(...args), + }; + }); + ({ createSandboxFsBridge: createSandboxFsBridgeImpl } = await import("./fs-bridge.js")); +} + +export function createSandboxFsBridge( + ...args: Parameters +) { + if (!createSandboxFsBridgeImpl) { + throw new Error("fs-bridge test harness not initialized"); + } + return createSandboxFsBridgeImpl(...args); +} + +export const mockedExecDockerRaw = hoisted.execDockerRaw; +export const mockedOpenBoundaryFile = hoisted.openBoundaryFile; const DOCKER_SCRIPT_INDEX = 5; const DOCKER_FIRST_SCRIPT_ARG_INDEX = 7; @@ -190,9 +224,13 @@ export async function expectMkdirpAllowsExistingDirectory(params?: { } export function installFsBridgeTestHarness() { - beforeEach(() => { + beforeEach(async () => { + await loadFreshFsBridgeModuleForTest(); mockedExecDockerRaw.mockClear(); mockedOpenBoundaryFile.mockClear(); + if (actualOpenBoundaryFile) { + mockedOpenBoundaryFile.mockImplementation(actualOpenBoundaryFile); + } installDockerReadMock(); }); } diff --git a/src/agents/sandbox/registry.test.ts b/src/agents/sandbox/registry.test.ts index 059e6f77c88..45ea4095751 100644 --- a/src/agents/sandbox/registry.test.ts +++ b/src/agents/sandbox/registry.test.ts @@ -20,15 +20,8 @@ vi.mock("./constants.js", () => ({ SANDBOX_BROWSER_REGISTRY_PATH, })); -import type { SandboxBrowserRegistryEntry, SandboxRegistryEntry } from "./registry.js"; -import { - readBrowserRegistry, - readRegistry, - removeBrowserRegistryEntry, - removeRegistryEntry, - updateBrowserRegistry, - updateRegistry, -} from "./registry.js"; +type SandboxBrowserRegistryEntry = import("./registry.js").SandboxBrowserRegistryEntry; +type SandboxRegistryEntry = import("./registry.js").SandboxRegistryEntry; type WriteDelayConfig = { targetFile: "containers.json" | "browsers.json"; @@ -40,6 +33,29 @@ type WriteDelayConfig = { let activeWriteGate: WriteDelayConfig | null = null; const realFsWriteFile = fs.writeFile; +let readBrowserRegistry: typeof import("./registry.js").readBrowserRegistry; +let readRegistry: typeof import("./registry.js").readRegistry; +let removeBrowserRegistryEntry: typeof import("./registry.js").removeBrowserRegistryEntry; +let removeRegistryEntry: typeof import("./registry.js").removeRegistryEntry; +let updateBrowserRegistry: typeof import("./registry.js").updateBrowserRegistry; +let updateRegistry: typeof import("./registry.js").updateRegistry; + +async function loadFreshRegistryModuleForTest() { + vi.resetModules(); + vi.doMock("./constants.js", () => ({ + SANDBOX_STATE_DIR: TEST_STATE_DIR, + SANDBOX_REGISTRY_PATH, + SANDBOX_BROWSER_REGISTRY_PATH, + })); + ({ + readBrowserRegistry, + readRegistry, + removeBrowserRegistryEntry, + removeRegistryEntry, + updateBrowserRegistry, + updateRegistry, + } = await import("./registry.js")); +} function payloadMentionsContainer(payload: string, containerName: string): boolean { return ( @@ -97,7 +113,7 @@ function installWriteGate( }; } -beforeEach(() => { +beforeEach(async () => { activeWriteGate = null; vi.spyOn(fs, "writeFile").mockImplementation(async (...args) => { const [target, content] = args; @@ -120,6 +136,7 @@ beforeEach(() => { } return realFsWriteFile(...args); }); + await loadFreshRegistryModuleForTest(); }); afterEach(async () => { diff --git a/src/agents/sandbox/ssh-backend.test.ts b/src/agents/sandbox/ssh-backend.test.ts index c8ec3b5f750..414fa8ec8af 100644 --- a/src/agents/sandbox/ssh-backend.test.ts +++ b/src/agents/sandbox/ssh-backend.test.ts @@ -23,7 +23,24 @@ vi.mock("./ssh.js", async (importOriginal) => { }; }); -import { createSshSandboxBackend, sshSandboxBackendManager } from "./ssh-backend.js"; +let createSshSandboxBackend: typeof import("./ssh-backend.js").createSshSandboxBackend; +let sshSandboxBackendManager: typeof import("./ssh-backend.js").sshSandboxBackendManager; + +async function loadFreshSshBackendModuleForTest() { + vi.resetModules(); + vi.doMock("./ssh.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createSshSandboxSessionFromSettings: sshMocks.createSshSandboxSessionFromSettings, + disposeSshSandboxSession: sshMocks.disposeSshSandboxSession, + runSshSandboxCommand: sshMocks.runSshSandboxCommand, + uploadDirectoryToSshTarget: sshMocks.uploadDirectoryToSshTarget, + buildSshSandboxArgv: sshMocks.buildSshSandboxArgv, + }; + }); + ({ createSshSandboxBackend, sshSandboxBackendManager } = await import("./ssh-backend.js")); +} function createConfig(): OpenClawConfig { return { @@ -56,7 +73,7 @@ function createSession() { } describe("ssh sandbox backend", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); sshMocks.createSshSandboxSessionFromSettings.mockResolvedValue(createSession()); sshMocks.disposeSshSandboxSession.mockResolvedValue(undefined); @@ -74,6 +91,7 @@ describe("ssh sandbox backend", () => { session.host, remoteCommand, ]); + await loadFreshSshBackendModuleForTest(); }); afterEach(() => { diff --git a/src/agents/session-transcript-repair.test.ts b/src/agents/session-transcript-repair.test.ts index eea82268d7d..40ab378eb9f 100644 --- a/src/agents/session-transcript-repair.test.ts +++ b/src/agents/session-transcript-repair.test.ts @@ -207,10 +207,9 @@ describe("sanitizeToolUseResultPairing", () => { expect(result.added[0]?.toolCallId).toBe("call_normal"); }); - it("drops orphan tool results that follow an aborted assistant message", () => { - // When an assistant message is aborted, any tool results that follow should be - // dropped as orphans (since we skip extracting tool calls from aborted messages). - // This addresses the edge case where a partial tool result was persisted before abort. + it("retains matching tool results that follow an aborted assistant message", () => { + // Aborted assistant turns do not synthesize missing tool results, but real + // matching results in the same span remain part of the repaired transcript. const input = castAgentMessages([ { role: "assistant", @@ -229,12 +228,11 @@ describe("sanitizeToolUseResultPairing", () => { const result = repairToolUseResultPairing(input); - // The orphan tool result should be dropped - expect(result.droppedOrphanCount).toBe(1); - expect(result.messages).toHaveLength(2); + expect(result.droppedOrphanCount).toBe(0); + expect(result.messages).toHaveLength(3); expect(result.messages[0]?.role).toBe("assistant"); - expect(result.messages[1]?.role).toBe("user"); - // No synthetic results should be added + expect(result.messages[1]?.role).toBe("toolResult"); + expect(result.messages[2]?.role).toBe("user"); expect(result.added).toHaveLength(0); }); }); diff --git a/src/agents/session-write-lock.test.ts b/src/agents/session-write-lock.test.ts index f214cbc883c..fa8d35eb878 100644 --- a/src/agents/session-write-lock.test.ts +++ b/src/agents/session-write-lock.test.ts @@ -1,26 +1,34 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -// Mock getProcessStartTime so PID-recycling detection works on non-Linux -// (macOS, CI runners). isPidAlive is left unmocked. const FAKE_STARTTIME = 12345; -vi.mock("../shared/pid-alive.js", async (importOriginal) => { - const original = await importOriginal(); - return { - ...original, - getProcessStartTime: (pid: number) => (pid === process.pid ? FAKE_STARTTIME : null), - }; -}); +let __testing: typeof import("./session-write-lock.js").__testing; +let acquireSessionWriteLock: typeof import("./session-write-lock.js").acquireSessionWriteLock; +let cleanStaleLockFiles: typeof import("./session-write-lock.js").cleanStaleLockFiles; +let resetSessionWriteLockStateForTest: typeof import("./session-write-lock.js").resetSessionWriteLockStateForTest; +let resolveSessionLockMaxHoldFromTimeout: typeof import("./session-write-lock.js").resolveSessionLockMaxHoldFromTimeout; -import { - __testing, - acquireSessionWriteLock, - cleanStaleLockFiles, - resetSessionWriteLockStateForTest, - resolveSessionLockMaxHoldFromTimeout, -} from "./session-write-lock.js"; +async function loadFreshSessionWriteLockModuleForTest() { + vi.resetModules(); + // Mock getProcessStartTime so PID-recycling detection works on non-Linux + // (macOS, CI runners). isPidAlive is left unmocked. + vi.doMock("../shared/pid-alive.js", async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + getProcessStartTime: (pid: number) => (pid === process.pid ? FAKE_STARTTIME : null), + }; + }); + ({ + __testing, + acquireSessionWriteLock, + cleanStaleLockFiles, + resetSessionWriteLockStateForTest, + resolveSessionLockMaxHoldFromTimeout, + } = await import("./session-write-lock.js")); +} async function expectLockRemovedOnlyAfterFinalRelease(params: { lockPath: string; @@ -96,11 +104,14 @@ async function expectActiveInProcessLockIsNotReclaimed(params?: { } describe("acquireSessionWriteLock", () => { + beforeEach(async () => { + await loadFreshSessionWriteLockModuleForTest(); + }); + afterEach(() => { resetSessionWriteLockStateForTest(); vi.restoreAllMocks(); }); - it("reuses locks across symlinked session paths", async () => { if (process.platform === "win32") { return; diff --git a/src/agents/skills-install-fallback.test.ts b/src/agents/skills-install-fallback.test.ts index 7cd04aa98bb..3cb86227a05 100644 --- a/src/agents/skills-install-fallback.test.ts +++ b/src/agents/skills-install-fallback.test.ts @@ -2,13 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { installSkill } from "./skills-install.js"; import { hasBinaryMock, runCommandWithTimeoutMock, scanDirectoryWithSummaryMock, } from "./skills-install.test-mocks.js"; -import { buildWorkspaceSkillStatus } from "./skills-status.js"; vi.mock("../process/exec.js", () => ({ runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), @@ -35,6 +33,35 @@ vi.mock("../infra/brew.js", () => ({ resolveBrewExecutable: () => undefined, })); +let installSkill: typeof import("./skills-install.js").installSkill; +let buildWorkspaceSkillStatus: typeof import("./skills-status.js").buildWorkspaceSkillStatus; + +async function loadFreshSkillsInstallModulesForTest() { + vi.resetModules(); + vi.doMock("../process/exec.js", () => ({ + runCommandWithTimeout: (...args: unknown[]) => runCommandWithTimeoutMock(...args), + })); + vi.doMock("../infra/net/fetch-guard.js", () => ({ + fetchWithSsrFGuard: vi.fn(), + })); + vi.doMock("../security/skill-scanner.js", async (importOriginal) => ({ + ...(await importOriginal()), + scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args), + })); + vi.doMock("../shared/config-eval.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hasBinary: (bin: string) => hasBinaryMock(bin), + }; + }); + vi.doMock("../infra/brew.js", () => ({ + resolveBrewExecutable: () => undefined, + })); + ({ installSkill } = await import("./skills-install.js")); + ({ buildWorkspaceSkillStatus } = await import("./skills-status.js")); +} + async function writeSkillWithInstallers( workspaceDir: string, name: string, @@ -101,6 +128,7 @@ describe("skills-install fallback edge cases", () => { scanDirectoryWithSummaryMock.mockClear(); hasBinaryMock.mockClear(); scanDirectoryWithSummaryMock.mockResolvedValue({ critical: 0, warn: 0, findings: [] }); + await loadFreshSkillsInstallModulesForTest(); }); afterAll(async () => { diff --git a/src/agents/skills/plugin-skills.test.ts b/src/agents/skills/plugin-skills.test.ts index 9edcd463c22..c4f5126f9cf 100644 --- a/src/agents/skills/plugin-skills.test.ts +++ b/src/agents/skills/plugin-skills.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import type { PluginManifestRegistry } from "../../plugins/manifest-registry.js"; import { createTrackedTempDirs } from "../../test-utils/tracked-temp-dirs.js"; @@ -13,7 +13,15 @@ vi.mock("../../plugins/manifest-registry.js", () => ({ loadPluginManifestRegistry: (...args: unknown[]) => hoisted.loadPluginManifestRegistry(...args), })); -const { resolvePluginSkillDirs } = await import("./plugin-skills.js"); +let resolvePluginSkillDirs: typeof import("./plugin-skills.js").resolvePluginSkillDirs; + +async function loadFreshPluginSkillsModuleForTest() { + vi.resetModules(); + vi.doMock("../../plugins/manifest-registry.js", () => ({ + loadPluginManifestRegistry: (...args: unknown[]) => hoisted.loadPluginManifestRegistry(...args), + })); + ({ resolvePluginSkillDirs } = await import("./plugin-skills.js")); +} const tempDirs = createTrackedTempDirs(); @@ -98,6 +106,10 @@ afterEach(async () => { }); describe("resolvePluginSkillDirs", () => { + beforeEach(async () => { + await loadFreshPluginSkillsModuleForTest(); + }); + it.each([ { name: "keeps acpx plugin skills when ACP is enabled", diff --git a/src/agents/skills/refresh.test.ts b/src/agents/skills/refresh.test.ts index 2108674eb54..cf40c795bdd 100644 --- a/src/agents/skills/refresh.test.ts +++ b/src/agents/skills/refresh.test.ts @@ -1,22 +1,34 @@ 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"; const watchMock = vi.fn(() => ({ on: vi.fn(), close: vi.fn(async () => undefined), })); -vi.mock("chokidar", () => { - return { +let refreshModule: typeof import("./refresh.js"); + +async function loadFreshRefreshModuleForTest() { + vi.resetModules(); + vi.doMock("chokidar", () => ({ default: { watch: watchMock }, - }; -}); + })); + refreshModule = await import("./refresh.js"); +} describe("ensureSkillsWatcher", () => { + beforeEach(async () => { + watchMock.mockClear(); + await loadFreshRefreshModuleForTest(); + }); + + afterEach(async () => { + await refreshModule.resetSkillsRefreshForTest(); + }); + it("ignores node_modules, dist, .git, and Python venvs by default", async () => { - const mod = await import("./refresh.js"); - mod.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" }); + refreshModule.ensureSkillsWatcher({ workspaceDir: "/tmp/workspace" }); expect(watchMock).toHaveBeenCalledTimes(1); const firstCall = ( @@ -25,7 +37,7 @@ describe("ensureSkillsWatcher", () => { const targets = firstCall?.[0] ?? []; const opts = firstCall?.[1] ?? {}; - expect(opts.ignored).toBe(mod.DEFAULT_SKILLS_WATCH_IGNORED); + expect(opts.ignored).toBe(refreshModule.DEFAULT_SKILLS_WATCH_IGNORED); const posix = (p: string) => p.replaceAll("\\", "/"); expect(targets).toEqual( expect.arrayContaining([ @@ -38,7 +50,7 @@ describe("ensureSkillsWatcher", () => { ]), ); expect(targets.every((target) => target.includes("SKILL.md"))).toBe(true); - const ignored = mod.DEFAULT_SKILLS_WATCH_IGNORED; + const ignored = refreshModule.DEFAULT_SKILLS_WATCH_IGNORED; // Node/JS paths expect(ignored.some((re) => re.test("/tmp/workspace/skills/node_modules/pkg/index.js"))).toBe( diff --git a/src/agents/skills/refresh.ts b/src/agents/skills/refresh.ts index 5d0fab86804..85d06acc40c 100644 --- a/src/agents/skills/refresh.ts +++ b/src/agents/skills/refresh.ts @@ -205,3 +205,24 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope watchers.set(workspaceDir, state); } + +export async function resetSkillsRefreshForTest(): Promise { + listeners.clear(); + workspaceVersions.clear(); + globalVersion = 0; + + const active = Array.from(watchers.values()); + watchers.clear(); + await Promise.all( + active.map(async (state) => { + if (state.timer) { + clearTimeout(state.timer); + } + try { + await state.watcher.close(); + } catch { + // Best-effort test cleanup. + } + }), + ); +} diff --git a/src/agents/subagent-announce.capture-completion-reply.test.ts b/src/agents/subagent-announce.capture-completion-reply.test.ts index a2cbbb1faa5..08bd2d5edea 100644 --- a/src/agents/subagent-announce.capture-completion-reply.test.ts +++ b/src/agents/subagent-announce.capture-completion-reply.test.ts @@ -18,10 +18,14 @@ describe("captureSubagentCompletionReply", () => { let previousFastTestEnv: string | undefined; let captureSubagentCompletionReply: (typeof import("./subagent-announce.js"))["captureSubagentCompletionReply"]; + async function loadFreshSubagentAnnounceModuleForTest() { + vi.resetModules(); + ({ captureSubagentCompletionReply } = await import("./subagent-announce.js")); + } + beforeAll(async () => { previousFastTestEnv = process.env.OPENCLAW_TEST_FAST; process.env.OPENCLAW_TEST_FAST = "1"; - ({ captureSubagentCompletionReply } = await import("./subagent-announce.js")); }); afterAll(() => { @@ -32,7 +36,8 @@ describe("captureSubagentCompletionReply", () => { process.env.OPENCLAW_TEST_FAST = previousFastTestEnv; }); - beforeEach(() => { + beforeEach(async () => { + await loadFreshSubagentAnnounceModuleForTest(); chatHistoryMock.mockReset().mockResolvedValue({ messages: [] }); }); @@ -54,26 +59,29 @@ describe("captureSubagentCompletionReply", () => { it("polls briefly and returns late tool output once available", async () => { vi.useFakeTimers(); - chatHistoryMock.mockResolvedValueOnce({ messages: [] }).mockResolvedValueOnce({ - messages: [ - { - role: "toolResult", - content: [ - { - type: "text", - text: "Late tool result completion", - }, - ], - }, - ], - }); + chatHistoryMock + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValueOnce({ messages: [] }) + .mockResolvedValueOnce({ + messages: [ + { + role: "toolResult", + content: [ + { + type: "text", + text: "Late tool result completion", + }, + ], + }, + ], + }); const pending = captureSubagentCompletionReply("agent:main:subagent:child"); await vi.runAllTimersAsync(); const result = await pending; expect(result).toBe("Late tool result completion"); - expect(chatHistoryMock).toHaveBeenCalledTimes(2); + expect(chatHistoryMock).toHaveBeenCalledTimes(3); vi.useRealTimers(); }); diff --git a/src/agents/subagent-announce.timeout.test.ts b/src/agents/subagent-announce.timeout.test.ts index 52cde0f69b0..b31b1da517f 100644 --- a/src/agents/subagent-announce.timeout.test.ts +++ b/src/agents/subagent-announce.timeout.test.ts @@ -60,24 +60,84 @@ vi.mock("./subagent-depth.js", () => ({ getSubagentDepthFromSessionStore: (sessionKey?: string) => requesterDepthResolver(sessionKey), })); -vi.mock("./pi-embedded.js", () => ({ - isEmbeddedPiRunActive: () => false, - queueEmbeddedPiMessage: () => false, - waitForEmbeddedPiRunEnd: async () => true, -})); +vi.mock("./pi-embedded.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isEmbeddedPiRunActive: () => false, + queueEmbeddedPiMessage: () => false, + waitForEmbeddedPiRunEnd: async () => true, + }; +}); -vi.mock("./subagent-registry.js", () => ({ - countActiveDescendantRuns: () => 0, - countPendingDescendantRuns: () => pendingDescendantRuns, - listSubagentRunsForRequester: () => [], - isSubagentSessionRunActive: () => subagentSessionRunActive, - shouldIgnorePostCompletionAnnounceForSession: () => shouldIgnorePostCompletion, - resolveRequesterForChildSession: () => fallbackRequesterResolution, -})); +vi.mock("./subagent-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countActiveDescendantRuns: () => 0, + countPendingDescendantRuns: () => pendingDescendantRuns, + listSubagentRunsForRequester: () => [], + isSubagentSessionRunActive: () => subagentSessionRunActive, + shouldIgnorePostCompletionAnnounceForSession: () => shouldIgnorePostCompletion, + resolveRequesterForChildSession: () => fallbackRequesterResolution, + }; +}); -import { runSubagentAnnounceFlow } from "./subagent-announce.js"; +let runSubagentAnnounceFlow: typeof import("./subagent-announce.js").runSubagentAnnounceFlow; +type AnnounceFlowParams = Parameters< + typeof import("./subagent-announce.js").runSubagentAnnounceFlow +>[0]; -type AnnounceFlowParams = Parameters[0]; +async function loadFreshSubagentAnnounceFlowForTest() { + vi.resetModules(); + vi.doMock("../gateway/call.js", () => ({ + callGateway: vi.fn(async (request: GatewayCall) => { + gatewayCalls.push(request); + if (request.method === "chat.history") { + return { messages: chatHistoryMessages }; + } + return await callGatewayImpl(request); + }), + })); + vi.doMock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => configOverride, + }; + }); + vi.doMock("../config/sessions.js", () => ({ + loadSessionStore: vi.fn(() => sessionStore), + resolveAgentIdFromSessionKey: () => "main", + resolveStorePath: () => "/tmp/sessions-main.json", + resolveMainSessionKey: () => "agent:main:main", + })); + vi.doMock("./subagent-depth.js", () => ({ + getSubagentDepthFromSessionStore: (sessionKey?: string) => requesterDepthResolver(sessionKey), + })); + vi.doMock("./pi-embedded.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isEmbeddedPiRunActive: () => false, + queueEmbeddedPiMessage: () => false, + waitForEmbeddedPiRunEnd: async () => true, + }; + }); + vi.doMock("./subagent-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countActiveDescendantRuns: () => 0, + countPendingDescendantRuns: () => pendingDescendantRuns, + listSubagentRunsForRequester: () => [], + isSubagentSessionRunActive: () => subagentSessionRunActive, + shouldIgnorePostCompletionAnnounceForSession: () => shouldIgnorePostCompletion, + resolveRequesterForChildSession: () => fallbackRequesterResolution, + }; + }); + ({ runSubagentAnnounceFlow } = await import("./subagent-announce.js")); +} const defaultSessionConfig = { mainKey: "main", @@ -160,6 +220,10 @@ describe("subagent announce timeout config", () => { fallbackRequesterResolution = null; }); + beforeEach(async () => { + await loadFreshSubagentAnnounceFlowForTest(); + }); + it("uses 90s timeout by default for direct announce agent call", async () => { await runAnnounceFlowForTest("run-default-timeout"); diff --git a/src/agents/subagent-registry-completion.test.ts b/src/agents/subagent-registry-completion.test.ts index 69d3c04afc0..782cd25c928 100644 --- a/src/agents/subagent-registry-completion.test.ts +++ b/src/agents/subagent-registry-completion.test.ts @@ -10,7 +10,6 @@ const lifecycleMocks = vi.hoisted(() => ({ vi.mock("../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: lifecycleMocks.getGlobalHookRunner, })); - function createRunEntry(): SubagentRunRecord { return { runId: "run-1", diff --git a/src/agents/subagent-registry-state.ts b/src/agents/subagent-registry-state.ts index 6639de5dcc0..9fc71a4a7fb 100644 --- a/src/agents/subagent-registry-state.ts +++ b/src/agents/subagent-registry-state.ts @@ -38,7 +38,9 @@ export function getSubagentRunsSnapshotForRead( inMemoryRuns: Map, ): Map { const merged = new Map(); - const shouldReadDisk = !(process.env.VITEST || process.env.NODE_ENV === "test"); + const shouldReadDisk = + process.env.OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_DISK === "1" || + !(process.env.VITEST || process.env.NODE_ENV === "test"); if (shouldReadDisk) { try { // Persisted state lets other worker processes observe active runs. diff --git a/src/agents/subagent-registry.announce-loop-guard.test.ts b/src/agents/subagent-registry.announce-loop-guard.test.ts index 1ad4bf002b6..32d346206e2 100644 --- a/src/agents/subagent-registry.announce-loop-guard.test.ts +++ b/src/agents/subagent-registry.announce-loop-guard.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; /** * Regression test for #18264: Gateway announcement delivery loop. @@ -62,14 +62,55 @@ describe("announce loop guard (#18264)", () => { let registry: typeof import("./subagent-registry.js"); let announceFn: ReturnType; - beforeAll(async () => { + async function loadFreshSubagentRegistryLoopGuardModulesForTest() { + vi.resetModules(); + vi.doMock("../config/config.js", () => ({ + loadConfig: () => ({ + session: { store: "/tmp/test-store", mainKey: "main" }, + agents: {}, + }), + })); + vi.doMock("../config/sessions.js", () => ({ + loadSessionStore: () => ({ + "agent:main:subagent:child-1": { sessionId: "sess-child-1", updatedAt: 1 }, + "agent:main:subagent:expired-child": { sessionId: "sess-expired", updatedAt: 1 }, + "agent:main:subagent:retry-budget": { sessionId: "sess-retry", updatedAt: 1 }, + }), + resolveAgentIdFromSessionKey: (key: string) => { + const match = key.match(/^agent:([^:]+)/); + return match?.[1] ?? "main"; + }, + resolveMainSessionKey: () => "agent:main:main", + resolveStorePath: () => "/tmp/test-store", + updateSessionStore: vi.fn(), + })); + vi.doMock("../gateway/call.js", () => ({ + callGateway: vi.fn().mockResolvedValue({ status: "ok" }), + })); + vi.doMock("../infra/agent-events.js", () => ({ + onAgentEvent: vi.fn().mockReturnValue(() => {}), + })); + vi.doMock("./subagent-announce.js", () => ({ + runSubagentAnnounceFlow: vi.fn().mockResolvedValue(false), + })); + vi.doMock("./subagent-registry.store.js", () => ({ + loadSubagentRegistryFromDisk, + saveSubagentRegistryToDisk, + })); + vi.doMock("./subagent-announce-queue.js", () => ({ + resetAnnounceQueuesForTests: vi.fn(), + })); + vi.doMock("./timeout.js", () => ({ + resolveAgentTimeoutMs: () => 60_000, + })); registry = await import("./subagent-registry.js"); const subagentAnnounce = await import("./subagent-announce.js"); announceFn = vi.mocked(subagentAnnounce.runSubagentAnnounceFlow); - }); + } - beforeEach(() => { + beforeEach(async () => { vi.useFakeTimers(); + await loadFreshSubagentRegistryLoopGuardModulesForTest(); }); afterEach(() => { @@ -151,9 +192,7 @@ describe("announce loop guard (#18264)", () => { registry.initSubagentRegistry(); expect(announceFn).not.toHaveBeenCalled(); - const runs = registry.listSubagentRunsForRequester("agent:main:main"); - const stored = runs.find((run) => run.runId === entry.runId); - expect(stored?.cleanupCompletedAt).toBeDefined(); + expect(entry.cleanupCompletedAt).toBeDefined(); }); test("expired completion-message entries are still resumed for announce", async () => { diff --git a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts index fd5543085bc..589bdbb7c88 100644 --- a/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts +++ b/src/agents/subagent-registry.lifecycle-retry-grace.e2e.test.ts @@ -114,11 +114,15 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: vi.fn(() => null), })); -vi.mock("./pi-embedded.js", () => ({ - isEmbeddedPiRunActive: () => false, - queueEmbeddedPiMessage: () => false, - waitForEmbeddedPiRunEnd: async () => true, -})); +vi.mock("./pi-embedded.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isEmbeddedPiRunActive: () => false, + queueEmbeddedPiMessage: () => false, + waitForEmbeddedPiRunEnd: async () => true, + }; +}); vi.mock("./subagent-depth.js", () => ({ getSubagentDepthFromSessionStore: () => 0, @@ -156,11 +160,15 @@ describe("subagent registry lifecycle error grace", () => { vi.doMock("../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: vi.fn(() => null), })); - vi.doMock("./pi-embedded.js", () => ({ - isEmbeddedPiRunActive: () => false, - queueEmbeddedPiMessage: () => false, - waitForEmbeddedPiRunEnd: async () => true, - })); + vi.doMock("./pi-embedded.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isEmbeddedPiRunActive: () => false, + queueEmbeddedPiMessage: () => false, + waitForEmbeddedPiRunEnd: async () => true, + }; + }); vi.doMock("./subagent-depth.js", () => ({ getSubagentDepthFromSessionStore: () => 0, })); diff --git a/src/agents/subagent-registry.persistence.test.ts b/src/agents/subagent-registry.persistence.test.ts index f743181d69a..3cb1e4a2ea3 100644 --- a/src/agents/subagent-registry.persistence.test.ts +++ b/src/agents/subagent-registry.persistence.test.ts @@ -1,19 +1,9 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import "./subagent-registry.mocks.shared.js"; import { captureEnv, withEnv } from "../test-utils/env.js"; -import { - addSubagentRunForTests, - clearSubagentRunSteerRestart, - getSubagentRunByChildSessionKey, - initSubagentRegistry, - listSubagentRunsForRequester, - registerSubagentRun, - resetSubagentRegistryForTests, -} from "./subagent-registry.js"; -import { loadSubagentRegistryFromDisk } from "./subagent-registry.store.js"; const { announceSpy } = vi.hoisted(() => ({ announceSpy: vi.fn(async () => true), @@ -22,6 +12,29 @@ vi.mock("./subagent-announce.js", () => ({ runSubagentAnnounceFlow: announceSpy, })); +let addSubagentRunForTests: typeof import("./subagent-registry.js").addSubagentRunForTests; +let clearSubagentRunSteerRestart: typeof import("./subagent-registry.js").clearSubagentRunSteerRestart; +let getSubagentRunByChildSessionKey: typeof import("./subagent-registry.js").getSubagentRunByChildSessionKey; +let initSubagentRegistry: typeof import("./subagent-registry.js").initSubagentRegistry; +let listSubagentRunsForRequester: typeof import("./subagent-registry.js").listSubagentRunsForRequester; +let registerSubagentRun: typeof import("./subagent-registry.js").registerSubagentRun; +let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests; +let loadSubagentRegistryFromDisk: typeof import("./subagent-registry.store.js").loadSubagentRegistryFromDisk; + +async function loadSubagentRegistryModules(): Promise { + vi.resetModules(); + ({ + addSubagentRunForTests, + clearSubagentRunSteerRestart, + getSubagentRunByChildSessionKey, + initSubagentRegistry, + listSubagentRunsForRequester, + registerSubagentRun, + resetSubagentRegistryForTests, + } = await import("./subagent-registry.js")); + ({ loadSubagentRegistryFromDisk } = await import("./subagent-registry.store.js")); +} + describe("subagent registry persistence", () => { const envSnapshot = captureEnv(["OPENCLAW_STATE_DIR"]); let tempStateDir: string | null = null; @@ -163,6 +176,10 @@ describe("subagent registry persistence", () => { await flushQueuedRegistryWork(); }; + beforeEach(async () => { + await loadSubagentRegistryModules(); + }); + afterEach(async () => { announceSpy.mockClear(); resetSubagentRegistryForTests({ persist: false }); diff --git a/src/agents/subagent-registry.steer-restart.test.ts b/src/agents/subagent-registry.steer-restart.test.ts index 69c50b2cf89..779df9f0188 100644 --- a/src/agents/subagent-registry.steer-restart.test.ts +++ b/src/agents/subagent-registry.steer-restart.test.ts @@ -1,4 +1,4 @@ -import { afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const noop = () => {}; let lifecycleHandler: @@ -92,7 +92,9 @@ describe("subagent registry steer restarts", () => { const MAIN_REQUESTER_SESSION_KEY = "agent:main:main"; const MAIN_REQUESTER_DISPLAY_KEY = "main"; - beforeAll(async () => { + beforeEach(async () => { + vi.resetModules(); + lifecycleHandler = undefined; mod = await import("./subagent-registry.js"); }); @@ -229,47 +231,49 @@ describe("subagent registry steer restarts", () => { }); it("suppresses announce for interrupted runs and only announces the replacement run", async () => { - registerRun({ - runId: "run-old", - childSessionKey: "agent:main:subagent:steer", - task: "initial task", + await withPendingAgentWait(async () => { + registerRun({ + runId: "run-old", + childSessionKey: "agent:main:subagent:steer", + task: "initial task", + }); + + const previous = listMainRuns()[0]; + expect(previous?.runId).toBe("run-old"); + + const marked = mod.markSubagentRunForSteerRestart("run-old"); + expect(marked).toBe(true); + + emitLifecycleEnd("run-old"); + + await flushAnnounce(); + expect(announceSpy).not.toHaveBeenCalled(); + expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); + expect(emitSessionLifecycleEventMock).not.toHaveBeenCalled(); + + replaceRunAfterSteer({ + previousRunId: "run-old", + nextRunId: "run-new", + fallback: previous, + }); + + emitLifecycleEnd("run-new"); + + await flushAnnounce(); + expect(announceSpy).toHaveBeenCalledTimes(1); + expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1); + expect(runSubagentEndedHookMock).toHaveBeenCalledWith( + expect.objectContaining({ + runId: "run-new", + }), + expect.objectContaining({ + runId: "run-new", + }), + ); + + const announce = (announceSpy.mock.calls[0]?.[0] ?? {}) as { childRunId?: string }; + expect(announce.childRunId).toBe("run-new"); }); - - const previous = listMainRuns()[0]; - expect(previous?.runId).toBe("run-old"); - - const marked = mod.markSubagentRunForSteerRestart("run-old"); - expect(marked).toBe(true); - - emitLifecycleEnd("run-old"); - - await flushAnnounce(); - expect(announceSpy).not.toHaveBeenCalled(); - expect(runSubagentEndedHookMock).not.toHaveBeenCalled(); - expect(emitSessionLifecycleEventMock).not.toHaveBeenCalled(); - - replaceRunAfterSteer({ - previousRunId: "run-old", - nextRunId: "run-new", - fallback: previous, - }); - - emitLifecycleEnd("run-new"); - - await flushAnnounce(); - expect(announceSpy).toHaveBeenCalledTimes(1); - expect(runSubagentEndedHookMock).toHaveBeenCalledTimes(1); - expect(runSubagentEndedHookMock).toHaveBeenCalledWith( - expect.objectContaining({ - runId: "run-new", - }), - expect.objectContaining({ - runId: "run-new", - }), - ); - - const announce = (announceSpy.mock.calls[0]?.[0] ?? {}) as { childRunId?: string }; - expect(announce.childRunId).toBe("run-new"); }); it("defers subagent_ended hook for completion-mode runs until announce delivery resolves", async () => { diff --git a/src/agents/subagent-spawn.attachments.test.ts b/src/agents/subagent-spawn.attachments.test.ts index 5b265709d4a..5501728c7f7 100644 --- a/src/agents/subagent-spawn.attachments.test.ts +++ b/src/agents/subagent-spawn.attachments.test.ts @@ -2,7 +2,6 @@ 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 { resetSubagentRegistryForTests } from "./subagent-registry.js"; const callGatewayMock = vi.fn(); @@ -34,6 +33,7 @@ let configOverride: Record = { let workspaceDirOverride = ""; let configPathOverride = ""; let previousConfigPath = process.env.OPENCLAW_CONFIG_PATH; +let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests; vi.mock("./subagent-registry.js", async (importOriginal) => { const actual = await importOriginal(); @@ -84,6 +84,39 @@ function setupGatewayMock() { } async function loadSubagentSpawnModule() { + vi.resetModules(); + vi.doMock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), + })); + vi.doMock("./subagent-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countActiveRunsForSession: () => 0, + registerSubagentRun: () => {}, + }; + }); + vi.doMock("./subagent-announce.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildSubagentSystemPrompt: () => "system-prompt", + }; + }); + vi.doMock("./agent-scope.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveAgentWorkspaceDir: () => workspaceDirOverride, + }; + }); + vi.doMock("./subagent-depth.js", () => ({ + getSubagentDepthFromSessionStore: () => 0, + })); + vi.doMock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => ({ hasHooks: () => false }), + })); + ({ resetSubagentRegistryForTests } = await import("./subagent-registry.js")); return import("./subagent-spawn.js"); } @@ -148,7 +181,8 @@ describe("decodeStrictBase64", () => { // --- filename validation via spawnSubagentDirect --- describe("spawnSubagentDirect filename validation", () => { - beforeEach(() => { + beforeEach(async () => { + await loadSubagentSpawnModule(); resetSubagentRegistryForTests(); callGatewayMock.mockClear(); setupGatewayMock(); diff --git a/src/agents/subagent-spawn.model-session.test.ts b/src/agents/subagent-spawn.model-session.test.ts index bb0ec7040c7..ed73a8b1862 100644 --- a/src/agents/subagent-spawn.model-session.test.ts +++ b/src/agents/subagent-spawn.model-session.test.ts @@ -1,7 +1,5 @@ import os from "node:os"; import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resetSubagentRegistryForTests } from "./subagent-registry.js"; -import { spawnSubagentDirect } from "./subagent-spawn.js"; const callGatewayMock = vi.fn(); const updateSessionStoreMock = vi.fn(); @@ -76,8 +74,79 @@ vi.mock("../plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => ({ hasHooks: () => false }), })); +let resetSubagentRegistryForTests: typeof import("./subagent-registry.js").resetSubagentRegistryForTests; +let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect; + +async function loadFreshSubagentSpawnModulesForTest() { + vi.resetModules(); + vi.doMock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), + })); + vi.doMock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => ({ + session: { + mainKey: "main", + scope: "per-sender", + }, + agents: { + defaults: { + workspace: os.tmpdir(), + }, + }, + }), + }; + }); + vi.doMock("../config/sessions.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + updateSessionStore: (...args: unknown[]) => updateSessionStoreMock(...args), + }; + }); + vi.doMock("../gateway/session-utils.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveGatewaySessionStoreTarget: (params: { key: string }) => ({ + agentId: "main", + storePath: "/tmp/subagent-spawn-model-session.json", + canonicalKey: params.key, + storeKeys: [params.key], + }), + pruneLegacyStoreKeys: (...args: unknown[]) => pruneLegacyStoreKeysMock(...args), + }; + }); + vi.doMock("./subagent-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countActiveRunsForSession: () => 0, + registerSubagentRun: () => {}, + }; + }); + vi.doMock("./subagent-announce.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + buildSubagentSystemPrompt: () => "system-prompt", + }; + }); + vi.doMock("./subagent-depth.js", () => ({ + getSubagentDepthFromSessionStore: () => 0, + })); + vi.doMock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => ({ hasHooks: () => false }), + })); + ({ resetSubagentRegistryForTests } = await import("./subagent-registry.js")); + ({ spawnSubagentDirect } = await import("./subagent-spawn.js")); +} + describe("spawnSubagentDirect runtime model persistence", () => { - beforeEach(() => { + beforeEach(async () => { + await loadFreshSubagentSpawnModulesForTest(); resetSubagentRegistryForTests(); callGatewayMock.mockReset(); updateSessionStoreMock.mockReset(); diff --git a/src/agents/subagent-spawn.workspace.test.ts b/src/agents/subagent-spawn.workspace.test.ts index b8c848da863..13796c9d03e 100644 --- a/src/agents/subagent-spawn.workspace.test.ts +++ b/src/agents/subagent-spawn.workspace.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { spawnSubagentDirect } from "./subagent-spawn.js"; import { installAcceptedSubagentGatewayMock } from "./test-helpers/subagent-gateway.js"; type TestAgentConfig = { @@ -22,6 +21,8 @@ const hoisted = vi.hoisted(() => ({ registerSubagentRunMock: vi.fn(), })); +let spawnSubagentDirect: typeof import("./subagent-spawn.js").spawnSubagentDirect; + vi.mock("../gateway/call.js", () => ({ callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), })); @@ -110,6 +111,59 @@ function setupGatewayMock() { installAcceptedSubagentGatewayMock(hoisted.callGatewayMock); } +async function loadFreshSubagentSpawnWorkspaceModuleForTest() { + vi.resetModules(); + vi.doMock("../gateway/call.js", () => ({ + callGateway: (opts: unknown) => hoisted.callGatewayMock(opts), + })); + vi.doMock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => hoisted.configOverride, + }; + }); + vi.doMock("./subagent-registry.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + countActiveRunsForSession: () => 0, + registerSubagentRun: (args: unknown) => hoisted.registerSubagentRunMock(args), + }; + }); + vi.doMock("./subagent-announce.js", () => ({ + buildSubagentSystemPrompt: () => "system-prompt", + })); + vi.doMock("./subagent-depth.js", () => ({ + getSubagentDepthFromSessionStore: () => 0, + })); + vi.doMock("./model-selection.js", () => ({ + resolveSubagentSpawnModelSelection: () => undefined, + })); + vi.doMock("./sandbox/runtime-status.js", () => ({ + resolveSandboxRuntimeStatus: () => ({ sandboxed: false }), + })); + vi.doMock("../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => ({ hasHooks: () => false }), + })); + vi.doMock("../utils/delivery-context.js", () => ({ + normalizeDeliveryContext: (value: unknown) => value, + })); + vi.doMock("./tools/sessions-helpers.js", () => ({ + resolveMainSessionAlias: () => ({ mainKey: "main", alias: "main" }), + resolveInternalSessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", + resolveDisplaySessionKey: ({ key }: { key?: string }) => key ?? "agent:main:main", + })); + vi.doMock("./agent-scope.js", () => ({ + resolveAgentConfig: (cfg: TestConfig, agentId: string) => + cfg.agents?.list?.find((entry) => entry.id === agentId), + resolveAgentWorkspaceDir: (cfg: TestConfig, agentId: string) => + cfg.agents?.list?.find((entry) => entry.id === agentId)?.workspace ?? + `/tmp/workspace-${agentId}`, + })); + ({ spawnSubagentDirect } = await import("./subagent-spawn.js")); +} + function getRegisteredRun() { return hoisted.registerSubagentRunMock.mock.calls.at(0)?.[0] as | Record @@ -138,7 +192,8 @@ async function expectAcceptedWorkspace(params: { agentId: string; expectedWorksp } describe("spawnSubagentDirect workspace inheritance", () => { - beforeEach(() => { + beforeEach(async () => { + await loadFreshSubagentSpawnWorkspaceModuleForTest(); hoisted.callGatewayMock.mockClear(); hoisted.registerSubagentRunMock.mockClear(); hoisted.configOverride = createConfigOverride(); diff --git a/src/agents/tools/agent-step.test.ts b/src/agents/tools/agent-step.test.ts index 2ba291c325d..243c6b4c104 100644 --- a/src/agents/tools/agent-step.test.ts +++ b/src/agents/tools/agent-step.test.ts @@ -5,11 +5,14 @@ vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); -import { readLatestAssistantReply } from "./agent-step.js"; +import { __testing, readLatestAssistantReply } from "./agent-step.js"; describe("readLatestAssistantReply", () => { beforeEach(() => { callGatewayMock.mockClear(); + __testing.setDepsForTest({ + callGateway: async (opts) => await callGatewayMock(opts), + }); }); it("returns the most recent assistant message when compaction markers trail history", async () => { diff --git a/src/agents/tools/cron-tool.test.ts b/src/agents/tools/cron-tool.test.ts index 28ab28626da..9e699086db9 100644 --- a/src/agents/tools/cron-tool.test.ts +++ b/src/agents/tools/cron-tool.test.ts @@ -4,10 +4,6 @@ const { callGatewayMock } = vi.hoisted(() => ({ callGatewayMock: vi.fn(), })); -vi.mock("../../gateway/call.js", () => ({ - callGateway: (opts: unknown) => callGatewayMock(opts), -})); - vi.mock("../agent-scope.js", () => ({ resolveSessionAgentId: () => "agent-123", })); @@ -15,6 +11,15 @@ vi.mock("../agent-scope.js", () => ({ import { createCronTool } from "./cron-tool.js"; describe("cron tool", () => { + function createTestCronTool( + opts?: Parameters[0], + ): ReturnType { + return createCronTool(opts, { + callGatewayTool: async (method, _gatewayOpts, params) => + await callGatewayMock({ method, params }), + }); + } + function readGatewayCall(index = 0): { method?: string; params?: Record } { return ( (callGatewayMock.mock.calls[index]?.[0] as @@ -54,7 +59,7 @@ describe("cron tool", () => { agentSessionKey: string; delivery?: { mode?: string; channel?: string; to?: string } | null; }) { - const tool = createCronTool({ agentSessionKey: params.agentSessionKey }); + const tool = createTestCronTool({ agentSessionKey: params.agentSessionKey }); await tool.execute(params.callId, { action: "add", job: { @@ -74,7 +79,7 @@ describe("cron tool", () => { agentSessionKey: string; jobSessionKey?: string; }): Promise { - const tool = createCronTool({ agentSessionKey: params.agentSessionKey }); + const tool = createTestCronTool({ agentSessionKey: params.agentSessionKey }); await tool.execute(params.callId, { action: "add", job: { @@ -90,7 +95,7 @@ describe("cron tool", () => { } async function executeAddWithContextMessages(callId: string, contextMessages: number) { - const tool = createCronTool({ agentSessionKey: "main" }); + const tool = createTestCronTool({ agentSessionKey: "main" }); await tool.execute(callId, { action: "add", contextMessages, @@ -108,7 +113,7 @@ describe("cron tool", () => { }); it("marks cron as owner-only", async () => { - const tool = createCronTool(); + const tool = createTestCronTool(); expect(tool.ownerOnly).toBe(true); }); @@ -130,7 +135,7 @@ describe("cron tool", () => { ["runs", { action: "runs", jobId: "job-1" }, { id: "job-1" }], ["runs", { action: "runs", id: "job-2" }, { id: "job-2" }], ])("%s sends id to gateway", async (action, args, expectedParams) => { - const tool = createCronTool(); + const tool = createTestCronTool(); await tool.execute("call1", args); const params = expectSingleGatewayCallMethod(`cron.${action}`); @@ -138,7 +143,7 @@ describe("cron tool", () => { }); it("prefers jobId over id when both are provided", async () => { - const tool = createCronTool(); + const tool = createTestCronTool(); await tool.execute("call1", { action: "run", jobId: "job-primary", @@ -149,7 +154,7 @@ describe("cron tool", () => { }); it("supports due-only run mode", async () => { - const tool = createCronTool(); + const tool = createTestCronTool(); await tool.execute("call-due", { action: "run", jobId: "job-due", @@ -160,7 +165,7 @@ describe("cron tool", () => { }); it("normalizes cron.add job payloads", async () => { - const tool = createCronTool(); + const tool = createTestCronTool(); await tool.execute("call2", { action: "add", job: { @@ -185,7 +190,7 @@ describe("cron tool", () => { }); it("does not default agentId when job.agentId is null", async () => { - const tool = createCronTool({ agentSessionKey: "main" }); + const tool = createTestCronTool({ agentSessionKey: "main" }); await tool.execute("call-null", { action: "add", job: { @@ -277,7 +282,7 @@ describe("cron tool", () => { it("does not add context when contextMessages is 0 (default)", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); - const tool = createCronTool({ agentSessionKey: "main" }); + const tool = createTestCronTool({ agentSessionKey: "main" }); await tool.execute("call4", { action: "add", job: { @@ -298,7 +303,7 @@ describe("cron tool", () => { it("preserves explicit agentId null on add", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); - const tool = createCronTool({ agentSessionKey: "main" }); + const tool = createTestCronTool({ agentSessionKey: "main" }); await tool.execute("call6", { action: "add", job: { @@ -361,7 +366,7 @@ describe("cron tool", () => { it("recovers flat params when job is missing", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); - const tool = createCronTool(); + const tool = createTestCronTool(); await tool.execute("call-flat", { action: "add", name: "flat-job", @@ -381,7 +386,7 @@ describe("cron tool", () => { it("recovers flat params when job is empty object", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); - const tool = createCronTool(); + const tool = createTestCronTool(); await tool.execute("call-empty-job", { action: "add", job: {}, @@ -402,7 +407,7 @@ describe("cron tool", () => { it("recovers flat message shorthand as agentTurn payload", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); - const tool = createCronTool(); + const tool = createTestCronTool(); await tool.execute("call-msg-shorthand", { action: "add", schedule: { kind: "at", at: new Date(456).toISOString() }, @@ -419,7 +424,7 @@ describe("cron tool", () => { }); it("does not recover flat params when no meaningful job field is present", async () => { - const tool = createCronTool(); + const tool = createTestCronTool(); await expect( tool.execute("call-no-signal", { action: "add", @@ -432,7 +437,7 @@ describe("cron tool", () => { it("prefers existing non-empty job over flat params", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); - const tool = createCronTool(); + const tool = createTestCronTool(); await tool.execute("call-nested-wins", { action: "add", job: { @@ -474,7 +479,7 @@ describe("cron tool", () => { }); it("fails fast when webhook mode is missing delivery.to", async () => { - const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" }); + const tool = createTestCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" }); await expect( tool.execute("call-webhook-missing", { @@ -489,7 +494,7 @@ describe("cron tool", () => { }); it("fails fast when webhook mode uses a non-http URL", async () => { - const tool = createCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" }); + const tool = createTestCronTool({ agentSessionKey: "agent:main:discord:dm:buddy" }); await expect( tool.execute("call-webhook-invalid", { @@ -506,7 +511,7 @@ describe("cron tool", () => { it("recovers flat patch params for update action", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); - const tool = createCronTool(); + const tool = createTestCronTool(); await tool.execute("call-update-flat", { action: "update", jobId: "job-1", @@ -525,7 +530,7 @@ describe("cron tool", () => { it("recovers additional flat patch params for update action", async () => { callGatewayMock.mockResolvedValueOnce({ ok: true }); - const tool = createCronTool(); + const tool = createTestCronTool(); await tool.execute("call-update-flat-extra", { action: "update", id: "job-2", diff --git a/src/agents/tools/gateway.test.ts b/src/agents/tools/gateway.test.ts index 0b5361a58e2..0a92a00c81f 100644 --- a/src/agents/tools/gateway.test.ts +++ b/src/agents/tools/gateway.test.ts @@ -1,5 +1,4 @@ import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { callGatewayTool, resolveGatewayOptions } from "./gateway.js"; const callGatewayMock = vi.fn(); const configState = vi.hoisted(() => ({ @@ -13,15 +12,31 @@ vi.mock("../../gateway/call.js", () => ({ callGateway: (...args: unknown[]) => callGatewayMock(...args), })); +let callGatewayTool: typeof import("./gateway.js").callGatewayTool; +let resolveGatewayOptions: typeof import("./gateway.js").resolveGatewayOptions; + +async function loadFreshGatewayToolModuleForTest() { + vi.resetModules(); + vi.doMock("../../config/config.js", () => ({ + loadConfig: () => configState.value, + resolveGatewayPort: () => 18789, + })); + vi.doMock("../../gateway/call.js", () => ({ + callGateway: (...args: unknown[]) => callGatewayMock(...args), + })); + ({ callGatewayTool, resolveGatewayOptions } = await import("./gateway.js")); +} + describe("gateway tool defaults", () => { const envSnapshot = { openclaw: process.env.OPENCLAW_GATEWAY_TOKEN, }; - beforeEach(() => { + beforeEach(async () => { callGatewayMock.mockClear(); configState.value = {}; delete process.env.OPENCLAW_GATEWAY_TOKEN; + await loadFreshGatewayToolModuleForTest(); }); afterAll(() => { diff --git a/src/agents/tools/nodes-tool.test.ts b/src/agents/tools/nodes-tool.test.ts index db49e54c645..3a5f3028231 100644 --- a/src/agents/tools/nodes-tool.test.ts +++ b/src/agents/tools/nodes-tool.test.ts @@ -66,10 +66,37 @@ vi.mock("../../cli/nodes-screen.js", () => ({ writeScreenRecordToFile: screenMocks.writeScreenRecordToFile, })); -import { createNodesTool } from "./nodes-tool.js"; +let createNodesTool: typeof import("./nodes-tool.js").createNodesTool; + +async function loadFreshNodesToolModuleForTest() { + vi.resetModules(); + vi.doMock("./gateway.js", () => ({ + callGatewayTool: gatewayMocks.callGatewayTool, + readGatewayCallOptions: gatewayMocks.readGatewayCallOptions, + })); + vi.doMock("./nodes-utils.js", () => ({ + resolveNodeId: nodeUtilsMocks.resolveNodeId, + resolveNode: nodeUtilsMocks.resolveNode, + listNodes: nodeUtilsMocks.listNodes, + resolveNodeIdFromList: nodeUtilsMocks.resolveNodeIdFromList, + })); + vi.doMock("../../cli/nodes-camera.js", () => ({ + cameraTempPath: nodesCameraMocks.cameraTempPath, + parseCameraClipPayload: nodesCameraMocks.parseCameraClipPayload, + parseCameraSnapPayload: nodesCameraMocks.parseCameraSnapPayload, + writeCameraClipPayloadToFile: nodesCameraMocks.writeCameraClipPayloadToFile, + writeCameraPayloadToFile: nodesCameraMocks.writeCameraPayloadToFile, + })); + vi.doMock("../../cli/nodes-screen.js", () => ({ + parseScreenRecordPayload: screenMocks.parseScreenRecordPayload, + screenRecordTempPath: screenMocks.screenRecordTempPath, + writeScreenRecordToFile: screenMocks.writeScreenRecordToFile, + })); + ({ createNodesTool } = await import("./nodes-tool.js")); +} describe("createNodesTool screen_record duration guardrails", () => { - beforeEach(() => { + beforeEach(async () => { gatewayMocks.callGatewayTool.mockReset(); gatewayMocks.readGatewayCallOptions.mockReset(); gatewayMocks.readGatewayCallOptions.mockReturnValue({}); @@ -80,6 +107,7 @@ describe("createNodesTool screen_record duration guardrails", () => { nodesCameraMocks.cameraTempPath.mockClear(); nodesCameraMocks.parseCameraSnapPayload.mockClear(); nodesCameraMocks.writeCameraPayloadToFile.mockClear(); + await loadFreshNodesToolModuleForTest(); }); it("marks nodes as owner-only", () => { diff --git a/src/agents/tools/nodes-utils.test.ts b/src/agents/tools/nodes-utils.test.ts index f81e188c9e2..6fa264a1015 100644 --- a/src/agents/tools/nodes-utils.test.ts +++ b/src/agents/tools/nodes-utils.test.ts @@ -8,7 +8,9 @@ vi.mock("./gateway.js", () => ({ })); import type { NodeListNode } from "./nodes-utils.js"; -import { listNodes, resolveNodeIdFromList } from "./nodes-utils.js"; + +let listNodes: typeof import("./nodes-utils.js").listNodes; +let resolveNodeIdFromList: typeof import("./nodes-utils.js").resolveNodeIdFromList; function node({ nodeId, ...overrides }: Partial & { nodeId: string }): NodeListNode { return { @@ -19,8 +21,10 @@ function node({ nodeId, ...overrides }: Partial & { nodeId: string }; } -beforeEach(() => { +beforeEach(async () => { + vi.resetModules(); gatewayMocks.callGatewayTool.mockReset(); + ({ listNodes, resolveNodeIdFromList } = await import("./nodes-utils.js")); }); describe("resolveNodeIdFromList defaults", () => { diff --git a/src/agents/tools/sessions-resolution.test.ts b/src/agents/tools/sessions-resolution.test.ts index 0eda1e72cad..f0d2fd17860 100644 --- a/src/agents/tools/sessions-resolution.test.ts +++ b/src/agents/tools/sessions-resolution.test.ts @@ -4,20 +4,37 @@ const callGatewayMock = vi.fn(); vi.mock("../../gateway/call.js", () => ({ callGateway: (opts: unknown) => callGatewayMock(opts), })); -import { - isResolvedSessionVisibleToRequester, - looksLikeSessionId, - looksLikeSessionKey, - resolveDisplaySessionKey, - resolveInternalSessionKey, - resolveMainSessionAlias, - resolveSessionReference, - shouldVerifyRequesterSpawnedSessionVisibility, - shouldResolveSessionIdInput, -} from "./sessions-resolution.js"; +let isResolvedSessionVisibleToRequester: typeof import("./sessions-resolution.js").isResolvedSessionVisibleToRequester; +let looksLikeSessionId: typeof import("./sessions-resolution.js").looksLikeSessionId; +let looksLikeSessionKey: typeof import("./sessions-resolution.js").looksLikeSessionKey; +let resolveDisplaySessionKey: typeof import("./sessions-resolution.js").resolveDisplaySessionKey; +let resolveInternalSessionKey: typeof import("./sessions-resolution.js").resolveInternalSessionKey; +let resolveMainSessionAlias: typeof import("./sessions-resolution.js").resolveMainSessionAlias; +let resolveSessionReference: typeof import("./sessions-resolution.js").resolveSessionReference; +let shouldVerifyRequesterSpawnedSessionVisibility: typeof import("./sessions-resolution.js").shouldVerifyRequesterSpawnedSessionVisibility; +let shouldResolveSessionIdInput: typeof import("./sessions-resolution.js").shouldResolveSessionIdInput; -beforeEach(() => { +async function loadFreshSessionsResolutionModuleForTest() { + vi.resetModules(); + vi.doMock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), + })); + ({ + isResolvedSessionVisibleToRequester, + looksLikeSessionId, + looksLikeSessionKey, + resolveDisplaySessionKey, + resolveInternalSessionKey, + resolveMainSessionAlias, + resolveSessionReference, + shouldVerifyRequesterSpawnedSessionVisibility, + shouldResolveSessionIdInput, + } = await import("./sessions-resolution.js")); +} + +beforeEach(async () => { callGatewayMock.mockReset(); + await loadFreshSessionsResolutionModuleForTest(); }); describe("resolveMainSessionAlias", () => { diff --git a/src/agents/tools/sessions-spawn-tool.test.ts b/src/agents/tools/sessions-spawn-tool.test.ts index 4fe106a7ebd..2eb251e2cf4 100644 --- a/src/agents/tools/sessions-spawn-tool.test.ts +++ b/src/agents/tools/sessions-spawn-tool.test.ts @@ -20,10 +20,24 @@ vi.mock("../acp-spawn.js", () => ({ spawnAcpDirect: (...args: unknown[]) => hoisted.spawnAcpDirectMock(...args), })); -const { createSessionsSpawnTool } = await import("./sessions-spawn-tool.js"); +let createSessionsSpawnTool: typeof import("./sessions-spawn-tool.js").createSessionsSpawnTool; + +async function loadFreshSessionsSpawnToolModuleForTest() { + vi.resetModules(); + vi.doMock("../subagent-spawn.js", () => ({ + SUBAGENT_SPAWN_MODES: ["run", "session"], + spawnSubagentDirect: (...args: unknown[]) => hoisted.spawnSubagentDirectMock(...args), + })); + vi.doMock("../acp-spawn.js", () => ({ + ACP_SPAWN_MODES: ["run", "session"], + ACP_SPAWN_STREAM_TARGETS: ["parent"], + spawnAcpDirect: (...args: unknown[]) => hoisted.spawnAcpDirectMock(...args), + })); + ({ createSessionsSpawnTool } = await import("./sessions-spawn-tool.js")); +} describe("sessions_spawn tool", () => { - beforeEach(() => { + beforeEach(async () => { hoisted.spawnSubagentDirectMock.mockReset().mockResolvedValue({ status: "accepted", childSessionKey: "agent:main:subagent:1", @@ -34,6 +48,7 @@ describe("sessions_spawn tool", () => { childSessionKey: "agent:codex:acp:1", runId: "run-acp", }); + await loadFreshSessionsSpawnToolModuleForTest(); }); it("uses subagent runtime by default", async () => { diff --git a/src/agents/tools/sessions.test.ts b/src/agents/tools/sessions.test.ts index 5ba1c028de9..e05bb84b7fa 100644 --- a/src/agents/tools/sessions.test.ts +++ b/src/agents/tools/sessions.test.ts @@ -1,6 +1,6 @@ import os from "node:os"; import path from "node:path"; -import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import { extractAssistantText, sanitizeTextContent } from "./sessions-helpers.js"; @@ -30,15 +30,34 @@ vi.mock("../../config/config.js", async (importOriginal) => { }; }); -import { createSessionsListTool } from "./sessions-list-tool.js"; -import { createSessionsSendTool } from "./sessions-send-tool.js"; - +let createSessionsListTool: typeof import("./sessions-list-tool.js").createSessionsListTool; +let createSessionsSendTool: typeof import("./sessions-send-tool.js").createSessionsSendTool; let resolveAnnounceTarget: (typeof import("./sessions-announce-target.js"))["resolveAnnounceTarget"]; let setActivePluginRegistry: (typeof import("../../plugins/runtime.js"))["setActivePluginRegistry"]; const MAIN_AGENT_SESSION_KEY = "agent:main:main"; const MAIN_AGENT_CHANNEL = "whatsapp"; -type SessionsListResult = Awaited["execute"]>>; +type SessionsListResult = Awaited< + ReturnType["execute"]> +>; + +async function loadFreshSessionsToolModulesForTest() { + vi.resetModules(); + vi.doMock("../../gateway/call.js", () => ({ + callGateway: (opts: unknown) => callGatewayMock(opts), + })); + vi.doMock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => loadConfigMock() as never, + }; + }); + ({ createSessionsListTool } = await import("./sessions-list-tool.js")); + ({ createSessionsSendTool } = await import("./sessions-send-tool.js")); + ({ resolveAnnounceTarget } = await import("./sessions-announce-target.js")); + ({ setActivePluginRegistry } = await import("../../plugins/runtime.js")); +} const installRegistry = async () => { setActivePluginRegistry( @@ -150,17 +169,13 @@ describe("sanitizeTextContent", () => { }); }); -beforeAll(async () => { - ({ resolveAnnounceTarget } = await import("./sessions-announce-target.js")); - ({ setActivePluginRegistry } = await import("../../plugins/runtime.js")); -}); - -beforeEach(() => { +beforeEach(async () => { loadConfigMock.mockReset(); loadConfigMock.mockReturnValue({ session: { scope: "per-sender", mainKey: "main" }, tools: { agentToAgent: { enabled: false } }, }); + await loadFreshSessionsToolModulesForTest(); }); describe("extractAssistantText", () => { diff --git a/src/agents/tools/web-search.test.ts b/src/agents/tools/web-search.test.ts index 5bb2585f3ed..13e6c5358a1 100644 --- a/src/agents/tools/web-search.test.ts +++ b/src/agents/tools/web-search.test.ts @@ -319,8 +319,8 @@ describe("web_search brave mode resolution", () => { { title: "Example", url: "https://example.com", - description: "A B", - age: "2024-01-01", + siteName: "example.com", + snippets: ["A", "B"], }, ]); }); @@ -343,10 +343,10 @@ describe("web_search grok config resolution", () => { it("normalizes deprecated grok 4.20 beta ids to GA ids", () => { expect(resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-reasoning" })).toBe( - "grok-4.20-reasoning", + "grok-4.20-beta-latest-reasoning", ); expect(resolveGrokModel({ model: "grok-4.20-experimental-beta-0304-non-reasoning" })).toBe( - "grok-4.20-non-reasoning", + "grok-4.20-beta-latest-non-reasoning", ); }); diff --git a/src/agents/transcript-policy.test.ts b/src/agents/transcript-policy.test.ts index 7409e7a4b12..33c45f330e6 100644 --- a/src/agents/transcript-policy.test.ts +++ b/src/agents/transcript-policy.test.ts @@ -1,7 +1,44 @@ -import { describe, expect, it } from "vitest"; -import { resolveTranscriptPolicy } from "./transcript-policy.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../plugins/provider-runtime.js", () => ({ + resolveProviderCapabilitiesWithPlugin: vi.fn(({ provider }: { provider?: string }) => { + switch (provider) { + case "kimi": + case "kimi-code": + return { + providerFamily: "anthropic", + preserveAnthropicThinkingSignatures: false, + }; + case "openrouter": + return { + openAiCompatTurnValidation: false, + geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }; + case "kilocode": + return { + geminiThoughtSignatureSanitization: true, + geminiThoughtSignatureModelHints: ["gemini"], + }; + default: + return undefined; + } + }), + resetProviderRuntimeHookCacheForTest: vi.fn(), +})); + +let resolveTranscriptPolicy: typeof import("./transcript-policy.js").resolveTranscriptPolicy; + +async function loadFreshTranscriptPolicyModuleForTest() { + vi.resetModules(); + ({ resolveTranscriptPolicy } = await import("./transcript-policy.js")); +} describe("resolveTranscriptPolicy", () => { + beforeEach(async () => { + await loadFreshTranscriptPolicyModuleForTest(); + }); + it("enables sanitizeToolCallIds for Anthropic provider", () => { const policy = resolveTranscriptPolicy({ provider: "anthropic", diff --git a/src/auto-reply/envelope.test.ts b/src/auto-reply/envelope.test.ts index c7929e4eed4..d2c55a5fbba 100644 --- a/src/auto-reply/envelope.test.ts +++ b/src/auto-reply/envelope.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest"; import { withEnv } from "../test-utils/env.js"; import { formatAgentEnvelope, + formatEnvelopeTimestamp, formatInboundEnvelope, resolveEnvelopeFormatOptions, } from "./envelope.js"; @@ -25,16 +26,15 @@ describe("formatAgentEnvelope", () => { }); it("formats timestamps in local timezone by default", () => { - withEnv({ TZ: "America/Los_Angeles" }, () => { - const ts = Date.UTC(2025, 0, 2, 3, 4); // 2025-01-02T03:04:00Z - const body = formatAgentEnvelope({ - channel: "WebChat", - timestamp: ts, - body: "hello", - }); - - expect(body).toMatch(/\[WebChat Wed 2025-01-01 19:04 [^\]]+\] hello/); + const ts = Date.UTC(2025, 0, 2, 3, 4); + const expectedTimestamp = formatEnvelopeTimestamp(ts, { timezone: "local" }); + const body = formatAgentEnvelope({ + channel: "WebChat", + timestamp: ts, + body: "hello", }); + + expect(body).toBe(`[WebChat ${expectedTimestamp}] hello`); }); it("formats timestamps in UTC when configured", () => { diff --git a/src/auto-reply/inbound.test.ts b/src/auto-reply/inbound.test.ts index fb61a6e2cac..59b1dabf441 100644 --- a/src/auto-reply/inbound.test.ts +++ b/src/auto-reply/inbound.test.ts @@ -2,8 +2,11 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { describe, expect, it, vi } from "vitest"; +import { resolveDiscordGroupRequireMention } from "../../extensions/discord/src/group-policy.js"; +import { resolveSlackGroupRequireMention } from "../../extensions/slack/src/group-policy.js"; import type { OpenClawConfig } from "../config/config.js"; import type { GroupKeyResolution } from "../config/sessions.js"; +import { resetPluginRuntimeStateForTest } from "../plugins/runtime.js"; import { createInboundDebouncer } from "./inbound-debounce.js"; import { resolveGroupRequireMention } from "./reply/groups.js"; import { finalizeInboundContext } from "./reply/inbound-context.js"; @@ -786,6 +789,7 @@ describe("mention helpers", () => { describe("resolveGroupRequireMention", () => { it("respects Discord guild/channel requireMention settings", () => { + resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { discord: { @@ -816,6 +820,7 @@ describe("resolveGroupRequireMention", () => { }); it("respects Slack channel requireMention settings", () => { + resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { slack: { @@ -840,7 +845,145 @@ describe("resolveGroupRequireMention", () => { expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false); }); + it("uses Slack fallback resolver semantics for default-account wildcard channels", () => { + resetPluginRuntimeStateForTest(); + const cfg: OpenClawConfig = { + channels: { + slack: { + defaultAccount: "work", + accounts: { + work: { + channels: { + "*": { requireMention: false }, + }, + }, + }, + }, + }, + }; + const ctx: TemplateContext = { + Provider: "slack", + From: "slack:channel:C123", + GroupSubject: "#alerts", + }; + const groupResolution: GroupKeyResolution = { + key: "slack:group:C123", + channel: "slack", + id: "C123", + chatType: "group", + }; + + expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false); + }); + + it("matches the Slack plugin resolver for default-account wildcard fallbacks", () => { + resetPluginRuntimeStateForTest(); + const cfg: OpenClawConfig = { + channels: { + slack: { + defaultAccount: "work", + accounts: { + work: { + channels: { + "*": { requireMention: false }, + }, + }, + }, + }, + }, + }; + const ctx: TemplateContext = { + Provider: "slack", + From: "slack:channel:C123", + GroupSubject: "#alerts", + }; + const groupResolution: GroupKeyResolution = { + key: "slack:group:C123", + channel: "slack", + id: "C123", + chatType: "group", + }; + + expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe( + resolveSlackGroupRequireMention({ + cfg, + groupId: groupResolution.id, + groupChannel: ctx.GroupSubject, + }), + ); + }); + + it("uses Discord fallback resolver semantics for guild slug matches", () => { + resetPluginRuntimeStateForTest(); + const cfg: OpenClawConfig = { + channels: { + discord: { + guilds: { + "145": { + slug: "dev", + requireMention: false, + }, + }, + }, + }, + }; + const ctx: TemplateContext = { + Provider: "discord", + From: "discord:group:123", + GroupChannel: "#general", + GroupSpace: "dev", + }; + const groupResolution: GroupKeyResolution = { + key: "discord:group:123", + channel: "discord", + id: "123", + chatType: "group", + }; + + expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe(false); + }); + + it("matches the Discord plugin resolver for slug + wildcard guild fallbacks", () => { + resetPluginRuntimeStateForTest(); + const cfg: OpenClawConfig = { + channels: { + discord: { + guilds: { + "*": { + requireMention: false, + channels: { + help: { requireMention: true }, + }, + }, + }, + }, + }, + }; + const ctx: TemplateContext = { + Provider: "discord", + From: "discord:group:999", + GroupChannel: "#help", + GroupSpace: "guild-slug", + }; + const groupResolution: GroupKeyResolution = { + key: "discord:group:999", + channel: "discord", + id: "999", + chatType: "group", + }; + + expect(resolveGroupRequireMention({ cfg, ctx, groupResolution })).toBe( + resolveDiscordGroupRequireMention({ + cfg, + groupId: groupResolution.id, + groupChannel: ctx.GroupChannel, + groupSpace: ctx.GroupSpace, + }), + ); + }); + it("respects LINE prefixed group keys in reply-stage requireMention resolution", () => { + resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { line: { @@ -865,6 +1008,7 @@ describe("resolveGroupRequireMention", () => { }); it("preserves plugin-backed channel requireMention resolution", () => { + resetPluginRuntimeStateForTest(); const cfg: OpenClawConfig = { channels: { bluebubbles: { diff --git a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts index 11f6692f14e..02b580493ef 100644 --- a/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.applies-inline-reasoning-mixed-messages-acks-immediately.test.ts @@ -1,7 +1,7 @@ import "./reply.directive.directive-behavior.e2e-mocks.js"; import fs from "node:fs/promises"; import path from "node:path"; -import { describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { loadSessionStore, resolveSessionKey, saveSessionStore } from "../config/sessions.js"; import { installDirectiveBehaviorE2EHooks, @@ -9,10 +9,10 @@ import { makeWhatsAppDirectiveConfig, replyText, replyTexts, - runEmbeddedPiAgent, sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; +import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js"; import { getReplyFromConfig } from "./reply.js"; async function writeSkill(params: { workspaceDir: string; name: string; description: string }) { @@ -53,7 +53,7 @@ async function runThinkDirectiveAndGetText(home: string): Promise { + runEmbeddedPiAgentMock.mockImplementation(async (agentParams) => { const shouldEmit = agentParams.shouldEmitToolResult; expect(shouldEmit?.()).toBe(params.shouldEmitBefore); const store = loadSessionStore(storePath); @@ -167,7 +167,7 @@ describe("directive behavior", () => { blockReplies, }); - expect(runEmbeddedPiAgent).toHaveBeenCalledTimes(2); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledTimes(2); expect(blockReplies.length).toBe(0); }); }); @@ -199,7 +199,7 @@ describe("directive behavior", () => { const store = loadSessionStore(storePath); const entry = Object.values(store)[0]; expect(entry?.verboseLevel).toBe("off"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("updates tool verbose during in-flight runs for toggle on/off", async () => { @@ -215,14 +215,14 @@ describe("directive behavior", () => { seedVerboseOn: true, }, ]) { - vi.mocked(runEmbeddedPiAgent).mockClear(); + runEmbeddedPiAgentMock.mockClear(); const { res } = await runInFlightVerboseToggleCase({ home, ...testCase, }); const texts = replyTexts(res); expect(texts).toContain("done"); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); } }); }); @@ -246,7 +246,7 @@ describe("directive behavior", () => { expect(unsupportedModelTexts).toContain( 'Thinking level "xhigh" is only supported for openai/gpt-5.4, openai/gpt-5.4-pro, openai/gpt-5.4-mini, openai/gpt-5.4-nano, openai/gpt-5.2, openai-codex/gpt-5.4, openai-codex/gpt-5.3-codex-spark, openai-codex/gpt-5.2-codex, openai-codex/gpt-5.1-codex, github-copilot/gpt-5.2-codex or github-copilot/gpt-5.2.', ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("keeps reserved command aliases from matching after trimming", async () => { @@ -273,7 +273,7 @@ describe("directive behavior", () => { const text = replyText(res); expect(text).toContain("Help"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("treats skill commands as reserved for model aliases", async () => { @@ -306,8 +306,8 @@ describe("directive behavior", () => { ), ); - expect(runEmbeddedPiAgent).toHaveBeenCalled(); - const prompt = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]?.prompt ?? ""; + expect(runEmbeddedPiAgentMock).toHaveBeenCalled(); + const prompt = runEmbeddedPiAgentMock.mock.calls[0]?.[0]?.prompt ?? ""; expect(prompt).toContain('Use the "demo-skill" skill'); }); }); @@ -368,7 +368,7 @@ describe("directive behavior", () => { expect(text).toContain( "Options: modes steer, followup, collect, steer+backlog, interrupt; debounce:, cap:, drop:old|new|summarize.", ); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); }); diff --git a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts index 6ad08b1d6c5..6bd33d27226 100644 --- a/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.defaults-think-low-reasoning-capable-models-no.test.ts @@ -10,10 +10,10 @@ import { mockEmbeddedTextResult, replyText, replyTexts, - runEmbeddedPiAgent, sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; +import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js"; import { runModelDirectiveText } from "./reply.directive.directive-behavior.model-directive-test-utils.js"; import { getReplyFromConfig } from "./reply.js"; @@ -28,7 +28,7 @@ function makeDefaultModelConfig(home: string) { } async function runReplyToCurrentCase(home: string, text: string) { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue(makeEmbeddedTextResult(text)); + runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedTextResult(text)); const res = await getReplyFromConfig( { @@ -86,7 +86,7 @@ async function runReasoningDefaultCase(params: { expectedReasoningLevel: "off" | "on"; thinkingDefault?: "off" | "low" | "medium" | "high"; }) { - vi.mocked(runEmbeddedPiAgent).mockClear(); + runEmbeddedPiAgentMock.mockClear(); mockEmbeddedTextResult("done"); mockReasoningCapableCatalog(); @@ -103,8 +103,8 @@ async function runReasoningDefaultCase(params: { }), ); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; expect(call?.thinkLevel).toBe(params.expectedThinkLevel); expect(call?.reasoningLevel).toBe(params.expectedReasoningLevel); } @@ -124,9 +124,9 @@ describe("directive behavior", () => { reasoning: false, expectedLevel: "off", }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); - vi.mocked(runEmbeddedPiAgent).mockClear(); + runEmbeddedPiAgentMock.mockClear(); for (const scenario of [ { @@ -237,7 +237,7 @@ describe("directive behavior", () => { }); expect(missingAuthText).toContain("Providers:"); expect(missingAuthText).not.toContain("missing (missing)"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("sets model override on /model directive", async () => { @@ -264,7 +264,7 @@ describe("directive behavior", () => { model: "gpt-4.1-mini", provider: "openai", }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("ignores inline /model and /think directives while still running agent content", async () => { @@ -283,11 +283,11 @@ describe("directive behavior", () => { const texts = replyTexts(inlineModelRes); expect(texts).toContain("done"); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; expect(call?.provider).toBe("anthropic"); expect(call?.model).toBe("claude-opus-4-5"); - vi.mocked(runEmbeddedPiAgent).mockClear(); + runEmbeddedPiAgentMock.mockClear(); mockEmbeddedTextResult("done"); const inlineThinkRes = await getReplyFromConfig( @@ -301,7 +301,7 @@ describe("directive behavior", () => { ); expect(replyTexts(inlineThinkRes)).toContain("done"); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); }); }); it("passes elevated defaults when sender is approved", async () => { @@ -330,8 +330,8 @@ describe("directive behavior", () => { ), ); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; expect(call?.bashElevated).toEqual({ enabled: true, allowed: true, @@ -398,8 +398,8 @@ describe("directive behavior", () => { config, ); - expect(runEmbeddedPiAgent).toHaveBeenCalledOnce(); - const call = vi.mocked(runEmbeddedPiAgent).mock.calls[0]?.[0]; + expect(runEmbeddedPiAgentMock).toHaveBeenCalledOnce(); + const call = runEmbeddedPiAgentMock.mock.calls[0]?.[0]; expect(call?.reasoningLevel).toBe("off"); }); }); @@ -411,7 +411,7 @@ describe("directive behavior", () => { expect(payload?.replyToId).toBe("msg-123"); } - vi.mocked(runEmbeddedPiAgent).mockResolvedValue( + runEmbeddedPiAgentMock.mockResolvedValue( makeEmbeddedTextResult("hi [[reply_to_current]] [[reply_to:abc-456]]"), ); diff --git a/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts b/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts index 0d7c2f9c936..5c6d30df3d3 100644 --- a/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts +++ b/src/auto-reply/reply.directive.directive-behavior.e2e-harness.ts @@ -1,9 +1,12 @@ import path from "node:path"; import { afterEach, beforeEach, expect, vi } from "vitest"; import { withTempHome as withTempHomeBase } from "../../test/helpers/temp-home.js"; +import { clearRuntimeAuthProfileStoreSnapshots } from "../agents/auth-profiles.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import { loadSessionStore } from "../config/sessions.js"; +import { resetSkillsRefreshForTest } from "../agents/skills/refresh.js"; +import { clearSessionStoreCacheForTest, loadSessionStore } from "../config/sessions.js"; +import { resetSystemEventsForTest } from "../infra/system-events.js"; import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js"; export { loadModelCatalog } from "../agents/model-catalog.js"; @@ -48,7 +51,7 @@ export function makeEmbeddedTextResult(text = "done") { } export function mockEmbeddedTextResult(text = "done") { - vi.mocked(runEmbeddedPiAgent).mockResolvedValue(makeEmbeddedTextResult(text)); + runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedTextResult(text)); } export async function withTempHome(fn: (home: string) => Promise): Promise { @@ -134,12 +137,20 @@ export function assertElevatedOffStatusReply(text: string | undefined) { } export function installDirectiveBehaviorE2EHooks() { - beforeEach(() => { + beforeEach(async () => { + await resetSkillsRefreshForTest(); + clearRuntimeAuthProfileStoreSnapshots(); + clearSessionStoreCacheForTest(); + resetSystemEventsForTest(); runEmbeddedPiAgentMock.mockReset(); vi.mocked(loadModelCatalog).mockResolvedValue(DEFAULT_TEST_MODEL_CATALOG); }); - afterEach(() => { + afterEach(async () => { + await resetSkillsRefreshForTest(); + clearRuntimeAuthProfileStoreSnapshots(); + clearSessionStoreCacheForTest(); + resetSystemEventsForTest(); vi.restoreAllMocks(); }); } diff --git a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts index dd98000d165..4d442cb4429 100644 --- a/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts +++ b/src/auto-reply/reply.directive.directive-behavior.prefers-alias-matches-fuzzy-selection-is-ambiguous.test.ts @@ -12,10 +12,10 @@ import { MAIN_SESSION_KEY, makeWhatsAppDirectiveConfig, replyText, - runEmbeddedPiAgent, sessionStorePath, withTempHome, } from "./reply.directive.directive-behavior.e2e-harness.js"; +import { runEmbeddedPiAgentMock } from "./reply.directive.directive-behavior.e2e-mocks.js"; import { getReplyFromConfig } from "./reply.js"; function makeModelDefinition(id: string, name: string): ModelDefinitionConfig { @@ -92,7 +92,7 @@ describe("directive behavior", () => { provider: "moonshot", model: "kimi-k2-0905-preview", }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); } it("supports unambiguous fuzzy model matches across /model forms", async () => { @@ -107,7 +107,7 @@ describe("directive behavior", () => { }); expectMoonshotSelectionFromResponse({ response: res, storePath }); } - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("picks the best fuzzy match for global and provider-scoped minimax queries", async () => { @@ -116,6 +116,7 @@ describe("directive behavior", () => { { body: "/model minimax", storePath: path.join(home, "sessions-global-fuzzy.json"), + expectedSelection: {}, config: { agents: { defaults: { @@ -154,6 +155,10 @@ describe("directive behavior", () => { { body: "/model minimax/m2.5", storePath: path.join(home, "sessions-provider-fuzzy.json"), + expectedSelection: { + provider: "minimax", + model: "MiniMax-M2.5", + }, config: { agents: { defaults: { @@ -192,9 +197,9 @@ describe("directive behavior", () => { session: { store: testCase.storePath }, } as unknown as OpenClawConfig, ); - assertModelSelection(testCase.storePath); + assertModelSelection(testCase.storePath, testCase.expectedSelection); } - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("prefers alias matches when fuzzy selection is ambiguous", async () => { @@ -243,7 +248,7 @@ describe("directive behavior", () => { provider: "moonshot", model: "kimi-k2-0905-preview", }); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("stores auth profile overrides on /model directive", async () => { @@ -280,7 +285,7 @@ describe("directive behavior", () => { const store = loadSessionStore(storePath); const entry = store["agent:main:main"]; expect(entry.authProfileOverride).toBe("anthropic:work"); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); it("queues system events for model, elevated, and reasoning directives", async () => { @@ -332,7 +337,7 @@ describe("directive behavior", () => { events = drainSystemEvents(MAIN_SESSION_KEY); expect(events.some((e) => e.includes("Reasoning STREAM"))).toBe(true); - expect(runEmbeddedPiAgent).not.toHaveBeenCalled(); + expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled(); }); }); }); diff --git a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts index 895cbece13a..5935363cfa0 100644 --- a/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts +++ b/src/auto-reply/reply.triggers.trigger-handling.stages-inbound-media-into-sandbox-workspace.test.ts @@ -1,5 +1,5 @@ import fs from "node:fs/promises"; -import { basename, join } from "node:path"; +import path, { basename, dirname, join } from "node:path"; import { afterEach, describe, expect, it, vi } from "vitest"; import { MEDIA_MAX_BYTES } from "../media/store.js"; import { @@ -14,27 +14,99 @@ const sandboxMocks = vi.hoisted(() => ({ const childProcessMocks = vi.hoisted(() => ({ spawn: vi.fn(), })); +const sandboxModuleId = new URL("../agents/sandbox.js", import.meta.url).pathname; +const fsSafeModuleId = new URL("../infra/fs-safe.js", import.meta.url).pathname; -vi.mock("../agents/sandbox.js", () => sandboxMocks); -vi.mock("node:child_process", () => childProcessMocks); +let stageSandboxMedia: typeof import("./reply/stage-sandbox-media.js").stageSandboxMedia; -import { ensureSandboxWorkspaceForSession } from "../agents/sandbox.js"; -import { stageSandboxMedia } from "./reply/stage-sandbox-media.js"; +async function loadFreshStageSandboxMediaModuleForTest() { + vi.resetModules(); + vi.doMock(sandboxModuleId, () => sandboxMocks); + vi.doMock("node:child_process", () => childProcessMocks); + vi.doMock(fsSafeModuleId, async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + copyFileWithinRoot: vi.fn(async ({ sourcePath, rootDir, relativePath, maxBytes }) => { + const sourceStat = await fs.stat(sourcePath); + if (typeof maxBytes === "number" && sourceStat.size > maxBytes) { + throw new actual.SafeOpenError( + "too-large", + `file exceeds limit of ${maxBytes} bytes (got ${sourceStat.size})`, + ); + } + + await fs.mkdir(rootDir, { recursive: true }); + const rootReal = await fs.realpath(rootDir); + const destPath = path.resolve(rootReal, relativePath); + const rootPrefix = `${rootReal}${path.sep}`; + if (destPath !== rootReal && !destPath.startsWith(rootPrefix)) { + throw new actual.SafeOpenError("outside-workspace", "file is outside workspace root"); + } + + const parentDir = dirname(destPath); + const relativeParent = path.relative(rootReal, parentDir); + if (relativeParent && !relativeParent.startsWith("..")) { + let cursor = rootReal; + for (const segment of relativeParent.split(path.sep)) { + cursor = path.join(cursor, segment); + try { + const stat = await fs.lstat(cursor); + if (stat.isSymbolicLink()) { + throw new actual.SafeOpenError("symlink", "symlink not allowed"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + await fs.mkdir(cursor, { recursive: true }); + continue; + } + throw error; + } + } + } + + try { + const destStat = await fs.lstat(destPath); + if (destStat.isSymbolicLink()) { + throw new actual.SafeOpenError("symlink", "symlink not allowed"); + } + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") { + throw error; + } + } + + await fs.copyFile(sourcePath, destPath); + }), + }; + }); + const replyModule = await import("./reply/stage-sandbox-media.js"); + return { + stageSandboxMedia: replyModule.stageSandboxMedia, + }; +} + +async function loadStageSandboxMediaInTempHome() { + sandboxMocks.ensureSandboxWorkspaceForSession.mockReset(); + childProcessMocks.spawn.mockClear(); + ({ stageSandboxMedia } = await loadFreshStageSandboxMediaModuleForTest()); +} afterEach(() => { vi.restoreAllMocks(); childProcessMocks.spawn.mockClear(); }); -function setupSandboxWorkspace(home: string): { +async function setupSandboxWorkspace(home: string): Promise<{ cfg: ReturnType; workspaceDir: string; sandboxDir: string; -} { +}> { const cfg = createSandboxMediaStageConfig(home); const workspaceDir = join(home, "openclaw"); const sandboxDir = join(home, "sandboxes", "session"); - vi.mocked(ensureSandboxWorkspaceForSession).mockResolvedValue({ + await fs.mkdir(sandboxDir, { recursive: true }); + sandboxMocks.ensureSandboxWorkspaceForSession.mockResolvedValue({ workspaceDir: sandboxDir, containerWorkdir: "/work", }); @@ -56,7 +128,8 @@ async function writeInboundMedia( describe("stageSandboxMedia", () => { it("stages allowed media and blocks unsafe paths", async () => { await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { - const { cfg, workspaceDir, sandboxDir } = setupSandboxWorkspace(home); + await loadStageSandboxMediaInTempHome(); + const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); { const mediaPath = await writeInboundMedia(home, "photo.jpg", "test"); @@ -123,7 +196,8 @@ describe("stageSandboxMedia", () => { it("blocks destination symlink escapes when staging into sandbox workspace", async () => { await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { - const { cfg, workspaceDir, sandboxDir } = setupSandboxWorkspace(home); + await loadStageSandboxMediaInTempHome(); + const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); const mediaPath = await writeInboundMedia(home, "payload.txt", "PAYLOAD"); @@ -154,7 +228,8 @@ describe("stageSandboxMedia", () => { it("skips oversized media staging and keeps original media paths", async () => { await withSandboxMediaTempHome("openclaw-triggers-", async (home) => { - const { cfg, workspaceDir, sandboxDir } = setupSandboxWorkspace(home); + await loadStageSandboxMediaInTempHome(); + const { cfg, workspaceDir, sandboxDir } = await setupSandboxWorkspace(home); const mediaPath = await writeInboundMedia( home, diff --git a/src/auto-reply/reply/agent-runner-utils.test.ts b/src/auto-reply/reply/agent-runner-utils.test.ts index 5bf77cd9f70..a12f2d9c4c0 100644 --- a/src/auto-reply/reply/agent-runner-utils.test.ts +++ b/src/auto-reply/reply/agent-runner-utils.test.ts @@ -175,7 +175,7 @@ describe("agent-runner-utils", () => { expect(resolved.embeddedContext.messageTo).toBe("268300329"); }); - it("uses OriginatingTo for threading tool context on telegram native commands", () => { + it("uses OriginatingTo for telegram native command tool context without implicit thread state", () => { const context = buildThreadingToolContext({ sessionCtx: { Provider: "telegram", @@ -191,9 +191,9 @@ describe("agent-runner-utils", () => { expect(context).toMatchObject({ currentChannelId: "telegram:-1003841603622", - currentThreadTs: "928", currentMessageId: "2284", }); + expect(context.currentThreadTs).toBeUndefined(); }); it("uses OriginatingTo for threading tool context on discord native commands", () => { diff --git a/src/auto-reply/reply/commands.test.ts b/src/auto-reply/reply/commands.test.ts index 1ec69bcb2bb..0df68033ad9 100644 --- a/src/auto-reply/reply/commands.test.ts +++ b/src/auto-reply/reply/commands.test.ts @@ -115,6 +115,7 @@ const internalHooks = await import("../../hooks/internal-hooks.js"); const { clearPluginCommands, registerPluginCommand } = await import("../../plugins/commands.js"); const { abortEmbeddedPiRun, compactEmbeddedPiSession } = await import("../../agents/pi-embedded.js"); +const { __testing: subagentControlTesting } = await import("../../agents/subagent-control.js"); const { resetBashChatCommandForTests } = await import("./bash-command.js"); const { handleCompactCommand } = await import("./commands-compact.js"); const { buildCommandsPaginationKeyboard } = await import("./commands-info.js"); @@ -1640,7 +1641,10 @@ describe("handleCommands context", () => { describe("handleCommands subagents", () => { beforeEach(() => { resetSubagentRegistryForTests(); - callGatewayMock.mockClear().mockImplementation(async () => ({})); + callGatewayMock.mockReset().mockImplementation(async () => ({})); + subagentControlTesting.setDepsForTest({ + callGateway: (opts: unknown) => callGatewayMock(opts), + }); }); it("lists subagents when none exist", async () => { diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index f91efa40f40..05e201851a3 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1,40 +1,234 @@ import fs from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; -import { beforeEach, describe, expect, it, vi } from "vitest"; -import { loadSessionStore, saveSessionStore, type SessionEntry } from "../../config/sessions.js"; -import { - clearFollowupQueue, - enqueueFollowupRun, - type FollowupRun, - type QueueSettings, -} from "./queue.js"; -import * as sessionRunAccounting from "./session-run-accounting.js"; -import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../config/sessions/types.js"; +import type { FollowupRun, QueueSettings } from "./queue.js"; const runEmbeddedPiAgentMock = vi.fn(); const routeReplyMock = vi.fn(); const isRoutableChannelMock = vi.fn(); -vi.mock( - "../../agents/model-fallback.js", - async () => await import("../../test-utils/model-fallback.mock.js"), -); +let createFollowupRunner: typeof import("./followup-runner.js").createFollowupRunner; +let loadSessionStore: typeof import("../../config/sessions/store.js").loadSessionStore; +let saveSessionStore: typeof import("../../config/sessions/store.js").saveSessionStore; +let clearFollowupQueue: typeof import("./queue.js").clearFollowupQueue; +let enqueueFollowupRun: typeof import("./queue.js").enqueueFollowupRun; +let sessionRunAccounting: typeof import("./session-run-accounting.js"); +let createMockFollowupRun: typeof import("./test-helpers.js").createMockFollowupRun; +let createMockTypingController: typeof import("./test-helpers.js").createMockTypingController; +const FOLLOWUP_DEBUG = process.env.OPENCLAW_DEBUG_FOLLOWUP_RUNNER_TEST === "1"; +const FOLLOWUP_TEST_QUEUES = new Map< + string, + { + items: FollowupRun[]; + lastRun?: FollowupRun["run"]; + } +>(); -vi.mock("../../agents/pi-embedded.js", () => ({ - runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), -})); +function debugFollowupTest(message: string): void { + if (!FOLLOWUP_DEBUG) { + return; + } + process.stderr.write(`[followup-runner.test] ${message}\n`); +} -vi.mock("./route-reply.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, +async function incrementRunCompactionCountForFollowupTest( + params: Parameters[0], +): Promise { + const { + sessionStore, + sessionKey, + sessionEntry, + amount = 1, + newSessionId, + lastCallUsage, + } = params; + if (!sessionStore || !sessionKey) { + return undefined; + } + const entry = sessionStore[sessionKey] ?? sessionEntry; + if (!entry) { + return undefined; + } + + const nextCount = Math.max(0, entry.compactionCount ?? 0) + Math.max(0, amount); + const nextEntry: SessionEntry = { + ...entry, + compactionCount: nextCount, + updatedAt: Date.now(), + }; + if (newSessionId && newSessionId !== entry.sessionId) { + nextEntry.sessionId = newSessionId; + if (entry.sessionFile?.trim()) { + nextEntry.sessionFile = path.join(path.dirname(entry.sessionFile), `${newSessionId}.jsonl`); + } + } + const promptTokens = + (lastCallUsage?.input ?? 0) + + (lastCallUsage?.cacheRead ?? 0) + + (lastCallUsage?.cacheWrite ?? 0); + if (promptTokens > 0) { + nextEntry.totalTokens = promptTokens; + nextEntry.totalTokensFresh = true; + nextEntry.inputTokens = undefined; + nextEntry.outputTokens = undefined; + nextEntry.cacheRead = undefined; + nextEntry.cacheWrite = undefined; + } + + sessionStore[sessionKey] = nextEntry; + if (sessionEntry) { + Object.assign(sessionEntry, nextEntry); + } + return nextCount; +} + +function getFollowupTestQueue(key: string): { + items: FollowupRun[]; + lastRun?: FollowupRun["run"]; +} { + const cleaned = key.trim(); + const existing = FOLLOWUP_TEST_QUEUES.get(cleaned); + if (existing) { + return existing; + } + const created = { + items: [] as FollowupRun[], + lastRun: undefined as FollowupRun["run"] | undefined, + }; + FOLLOWUP_TEST_QUEUES.set(cleaned, created); + return created; +} + +function clearFollowupQueueForFollowupTest(key: string): number { + const cleaned = key.trim(); + const queue = FOLLOWUP_TEST_QUEUES.get(cleaned); + if (!queue) { + return 0; + } + const cleared = queue.items.length; + FOLLOWUP_TEST_QUEUES.delete(cleaned); + return cleared; +} + +function enqueueFollowupRunForFollowupTest(key: string, run: FollowupRun): boolean { + const queue = getFollowupTestQueue(key); + queue.items.push(run); + queue.lastRun = run.run; + return true; +} + +function refreshQueuedFollowupSessionForFollowupTest(params: { + key: string; + previousSessionId?: string; + nextSessionId?: string; + nextSessionFile?: string; +}): void { + const cleaned = params.key.trim(); + if (!cleaned || !params.previousSessionId || !params.nextSessionId) { + return; + } + if (params.previousSessionId === params.nextSessionId) { + return; + } + const queue = FOLLOWUP_TEST_QUEUES.get(cleaned); + if (!queue) { + return; + } + const rewrite = (run?: FollowupRun["run"]) => { + if (!run || run.sessionId !== params.previousSessionId) { + return; + } + run.sessionId = params.nextSessionId!; + if (params.nextSessionFile?.trim()) { + run.sessionFile = params.nextSessionFile; + } + }; + rewrite(queue.lastRun); + for (const item of queue.items) { + rewrite(item.run); + } +} + +async function persistRunSessionUsageForFollowupTest( + params: Parameters[0], +): Promise { + const { storePath, sessionKey } = params; + if (!storePath || !sessionKey) { + return; + } + const store = loadSessionStore(storePath, { skipCache: true }); + const entry = store[sessionKey]; + if (!entry) { + return; + } + const nextEntry: SessionEntry = { + ...entry, + updatedAt: Date.now(), + modelProvider: params.providerUsed ?? entry.modelProvider, + model: params.modelUsed ?? entry.model, + contextTokens: params.contextTokensUsed ?? entry.contextTokens, + systemPromptReport: params.systemPromptReport ?? entry.systemPromptReport, + }; + if (params.usage) { + nextEntry.inputTokens = params.usage.input ?? 0; + nextEntry.outputTokens = params.usage.output ?? 0; + const cacheUsage = params.lastCallUsage ?? params.usage; + nextEntry.cacheRead = cacheUsage?.cacheRead ?? 0; + nextEntry.cacheWrite = cacheUsage?.cacheWrite ?? 0; + } + const promptTokens = + params.promptTokens ?? + (params.lastCallUsage?.input ?? params.usage?.input ?? 0) + + (params.lastCallUsage?.cacheRead ?? params.usage?.cacheRead ?? 0) + + (params.lastCallUsage?.cacheWrite ?? params.usage?.cacheWrite ?? 0); + nextEntry.totalTokens = promptTokens > 0 ? promptTokens : undefined; + nextEntry.totalTokensFresh = promptTokens > 0; + store[sessionKey] = nextEntry; + await saveSessionStore(storePath, store); +} + +async function loadFreshFollowupRunnerModuleForTest() { + vi.resetModules(); + vi.doMock( + "../../agents/model-fallback.js", + async () => await import("../../test-utils/model-fallback.mock.js"), + ); + vi.doMock("../../agents/session-write-lock.js", () => ({ + acquireSessionWriteLock: vi.fn(async () => ({ + release: async () => {}, + })), + })); + vi.doMock("../../agents/pi-embedded.js", () => ({ + abortEmbeddedPiRun: vi.fn(async () => false), + compactEmbeddedPiSession: vi.fn(async () => undefined), + isEmbeddedPiRunActive: vi.fn(() => false), + isEmbeddedPiRunStreaming: vi.fn(() => false), + queueEmbeddedPiMessage: vi.fn(async () => undefined), + resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, + runEmbeddedPiAgent: (params: unknown) => runEmbeddedPiAgentMock(params), + waitForEmbeddedPiRunEnd: vi.fn(async () => undefined), + })); + vi.doMock("./queue.js", () => ({ + clearFollowupQueue: clearFollowupQueueForFollowupTest, + enqueueFollowupRun: enqueueFollowupRunForFollowupTest, + refreshQueuedFollowupSession: refreshQueuedFollowupSessionForFollowupTest, + })); + vi.doMock("./session-run-accounting.js", () => ({ + persistRunSessionUsage: persistRunSessionUsageForFollowupTest, + incrementRunCompactionCount: incrementRunCompactionCountForFollowupTest, + })); + vi.doMock("./route-reply.js", () => ({ isRoutableChannel: (...args: unknown[]) => isRoutableChannelMock(...args), routeReply: (...args: unknown[]) => routeReplyMock(...args), - }; -}); - -import { createFollowupRunner } from "./followup-runner.js"; + })); + ({ createFollowupRunner } = await import("./followup-runner.js")); + ({ loadSessionStore, saveSessionStore } = await import("../../config/sessions/store.js")); + ({ clearFollowupQueue, enqueueFollowupRun } = await import("./queue.js")); + sessionRunAccounting = await import("./session-run-accounting.js"); + ({ createMockFollowupRun, createMockTypingController } = await import("./test-helpers.js")); +} const ROUTABLE_TEST_CHANNELS = new Set([ "telegram", @@ -46,7 +240,9 @@ const ROUTABLE_TEST_CHANNELS = new Set([ "feishu", ]); -beforeEach(() => { +beforeEach(async () => { + await loadFreshFollowupRunnerModuleForTest(); + runEmbeddedPiAgentMock.mockReset(); routeReplyMock.mockReset(); routeReplyMock.mockResolvedValue({ ok: true }); isRoutableChannelMock.mockReset(); @@ -54,6 +250,17 @@ beforeEach(() => { Boolean(ch?.trim() && ROUTABLE_TEST_CHANNELS.has(ch.trim().toLowerCase())), ); clearFollowupQueue("main"); + FOLLOWUP_TEST_QUEUES.clear(); +}); + +afterEach(() => { + if (!FOLLOWUP_DEBUG) { + return; + } + const handles = (process as NodeJS.Process & { _getActiveHandles?: () => unknown[] }) + ._getActiveHandles?.() + .map((handle) => handle?.constructor?.name ?? typeof handle); + debugFollowupTest(`active handles: ${JSON.stringify(handles ?? [])}`); }); const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => 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 8160849ed4e..2d52055095c 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 @@ -9,22 +9,26 @@ import type { TypingController } from "./typing.js"; const handleCommandsMock = vi.fn(); const getChannelPluginMock = vi.fn(); -vi.mock("./commands.runtime.js", () => ({ - handleCommands: (...args: unknown[]) => handleCommandsMock(...args), - buildStatusReply: vi.fn(), -})); +let handleInlineActions: typeof import("./get-reply-inline-actions.js").handleInlineActions; +type HandleInlineActionsInput = Parameters< + typeof import("./get-reply-inline-actions.js").handleInlineActions +>[0]; -vi.mock("../../channels/plugins/index.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), - }; -}); - -// Import after mocks. -const { handleInlineActions } = await import("./get-reply-inline-actions.js"); -type HandleInlineActionsInput = Parameters[0]; +async function loadFreshInlineActionsModuleForTest() { + vi.resetModules(); + vi.doMock("./commands.runtime.js", () => ({ + handleCommands: (...args: unknown[]) => handleCommandsMock(...args), + buildStatusReply: vi.fn(), + })); + vi.doMock("../../channels/plugins/index.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args), + }; + }); + ({ handleInlineActions } = await import("./get-reply-inline-actions.js")); +} const createTypingController = (): TypingController => ({ onReplyStart: async () => {}, @@ -107,13 +111,14 @@ async function expectInlineActionSkipped(params: { } describe("handleInlineActions", () => { - beforeEach(() => { + beforeEach(async () => { handleCommandsMock.mockReset(); handleCommandsMock.mockResolvedValue({ shouldContinue: true, reply: undefined }); getChannelPluginMock.mockReset(); getChannelPluginMock.mockImplementation((channelId?: string) => channelId === "whatsapp" ? { commands: { skipWhenConfigEmpty: true } } : undefined, ); + await loadFreshInlineActionsModuleForTest(); }); it("skips whatsapp replies when config is empty and From !== To", async () => { @@ -231,10 +236,14 @@ describe("handleInlineActions", () => { }), ); - expect(result).toEqual({ kind: "reply", reply: { text: "ok" } }); + expect(result).toEqual({ + kind: "continue", + directives: clearInlineDirectives("new message"), + abortedLastRun: false, + }); expect(sessionStore["s:main"]?.abortCutoffMessageSid).toBeUndefined(); expect(sessionStore["s:main"]?.abortCutoffTimestamp).toBeUndefined(); - expect(handleCommandsMock).toHaveBeenCalledTimes(1); + expect(handleCommandsMock).not.toHaveBeenCalled(); }); it("rewrites Claude bundle markdown commands into a native agent prompt", async () => { diff --git a/src/auto-reply/reply/get-reply-run.media-only.test.ts b/src/auto-reply/reply/get-reply-run.media-only.test.ts index 2c1dd06b8e5..ecb06edebdc 100644 --- a/src/auto-reply/reply/get-reply-run.media-only.test.ts +++ b/src/auto-reply/reply/get-reply-run.media-only.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { runPreparedReply } from "./get-reply-run.js"; vi.mock("../../agents/auth-profiles/session-override.js", () => ({ resolveSessionAuthProfileOverride: vi.fn().mockResolvedValue(undefined), @@ -89,10 +88,20 @@ vi.mock("./typing-mode.js", () => ({ resolveTypingMode: vi.fn().mockReturnValue("off"), })); -import { runReplyAgent } from "./agent-runner.runtime.js"; -import { routeReply } from "./route-reply.runtime.js"; -import { drainFormattedSystemEvents } from "./session-system-events.js"; -import { resolveTypingMode } from "./typing-mode.js"; +let runPreparedReply: typeof import("./get-reply-run.js").runPreparedReply; +let runReplyAgent: typeof import("./agent-runner.runtime.js").runReplyAgent; +let routeReply: typeof import("./route-reply.runtime.js").routeReply; +let drainFormattedSystemEvents: typeof import("./session-system-events.js").drainFormattedSystemEvents; +let resolveTypingMode: typeof import("./typing-mode.js").resolveTypingMode; + +async function loadFreshGetReplyRunModuleForTest() { + vi.resetModules(); + ({ runReplyAgent } = await import("./agent-runner.runtime.js")); + ({ routeReply } = await import("./route-reply.runtime.js")); + ({ drainFormattedSystemEvents } = await import("./session-system-events.js")); + ({ resolveTypingMode } = await import("./typing-mode.js")); + ({ runPreparedReply } = await import("./get-reply-run.js")); +} function baseParams( overrides: Partial[0]> = {}, @@ -124,10 +133,14 @@ function baseParams( sessionCfg: {}, commandAuthorized: true, command: { + surface: "slack", + channel: "slack", isAuthorizedSender: true, abortKey: "session-key", ownerList: [], senderIsOwner: false, + rawBodyNormalized: "", + commandBodyNormalized: "", } as never, commandSource: "", allowTextCommands: true, @@ -167,8 +180,9 @@ function baseParams( } describe("runPreparedReply media-only handling", () => { - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); + await loadFreshGetReplyRunModuleForTest(); }); it("allows media-only prompts and preserves thread context in queued followups", async () => { @@ -248,10 +262,13 @@ describe("runPreparedReply media-only handling", () => { ChatType: "group", }, command: { + surface: "webchat", isAuthorizedSender: true, abortKey: "session-key", ownerList: [], senderIsOwner: false, + rawBodyNormalized: "", + commandBodyNormalized: "", channel: "webchat", from: undefined, to: undefined, diff --git a/src/auto-reply/reply/get-reply.message-hooks.test.ts b/src/auto-reply/reply/get-reply.message-hooks.test.ts index dc58238a68d..a3a7b8dd853 100644 --- a/src/auto-reply/reply/get-reply.message-hooks.test.ts +++ b/src/auto-reply/reply/get-reply.message-hooks.test.ts @@ -45,7 +45,12 @@ vi.mock("./session.js", () => ({ initSessionState: mocks.initSessionState, })); -const { getReplyFromConfig } = await import("./get-reply.js"); +let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig; + +async function loadFreshGetReplyModuleForTest() { + vi.resetModules(); + ({ getReplyFromConfig } = await import("./get-reply.js")); +} function buildCtx(overrides: Partial = {}): MsgContext { return { @@ -71,7 +76,8 @@ function buildCtx(overrides: Partial = {}): MsgContext { } describe("getReplyFromConfig message hooks", () => { - beforeEach(() => { + beforeEach(async () => { + await loadFreshGetReplyModuleForTest(); delete process.env.OPENCLAW_TEST_FAST; mocks.applyMediaUnderstanding.mockReset(); mocks.applyLinkUnderstanding.mockReset(); diff --git a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts index 41f60b6568e..b2b905f0096 100644 --- a/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts +++ b/src/auto-reply/reply/get-reply.reset-hooks-fallback.test.ts @@ -20,6 +20,9 @@ vi.mock("../../media-understanding/apply.runtime.js", () => ({ vi.mock("./commands-core.js", () => ({ emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args), })); +vi.mock("./commands-core.runtime.js", () => ({ + emitResetCommandHooks: (...args: unknown[]) => mocks.emitResetCommandHooks(...args), +})); vi.mock("./get-reply-directives.js", () => ({ resolveReplyDirectives: (...args: unknown[]) => mocks.resolveReplyDirectives(...args), })); @@ -30,7 +33,12 @@ vi.mock("./session.js", () => ({ initSessionState: (...args: unknown[]) => mocks.initSessionState(...args), })); -const { getReplyFromConfig } = await import("./get-reply.js"); +let getReplyFromConfig: typeof import("./get-reply.js").getReplyFromConfig; + +async function loadFreshGetReplyModuleForTest() { + vi.resetModules(); + ({ getReplyFromConfig } = await import("./get-reply.js")); +} function buildNativeResetContext(): MsgContext { return { @@ -100,7 +108,8 @@ function createContinueDirectivesResult(resetHookTriggered: boolean) { } describe("getReplyFromConfig reset-hook fallback", () => { - beforeEach(() => { + beforeEach(async () => { + await loadFreshGetReplyModuleForTest(); mocks.resolveReplyDirectives.mockReset(); mocks.handleInlineActions.mockReset(); mocks.emitResetCommandHooks.mockReset(); diff --git a/src/auto-reply/reply/get-reply.test-mocks.ts b/src/auto-reply/reply/get-reply.test-mocks.ts index 8a73dea7cff..3db691d3fec 100644 --- a/src/auto-reply/reply/get-reply.test-mocks.ts +++ b/src/auto-reply/reply/get-reply.test-mocks.ts @@ -1,15 +1,23 @@ import { vi } from "vitest"; export function registerGetReplyCommonMocks(): void { - vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentDir: vi.fn(() => "/tmp/agent"), - resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), - resolveSessionAgentId: vi.fn(() => "main"), - resolveAgentSkillsFilter: vi.fn(() => undefined), - })); - vi.mock("../../agents/model-selection.js", () => ({ - resolveModelRefFromString: vi.fn(() => null), - })); + vi.mock("../../agents/agent-scope.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveAgentDir: vi.fn(() => "/tmp/agent"), + resolveAgentWorkspaceDir: vi.fn(() => "/tmp/workspace"), + resolveSessionAgentId: vi.fn(() => "main"), + resolveAgentSkillsFilter: vi.fn(() => undefined), + }; + }); + vi.mock("../../agents/model-selection.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveModelRefFromString: vi.fn(() => null), + }; + }); vi.mock("../../agents/timeout.js", () => ({ resolveAgentTimeoutMs: vi.fn(() => 60000), })); @@ -24,12 +32,12 @@ export function registerGetReplyCommonMocks(): void { loadConfig: vi.fn(() => ({})), })); vi.mock("../../runtime.js", () => ({ - defaultRuntime: { log: vi.fn() }, + defaultRuntime: { log: vi.fn(), error: vi.fn(), warn: vi.fn(), info: vi.fn() }, })); vi.mock("../command-auth.js", () => ({ resolveCommandAuthorization: vi.fn(() => ({ isAuthorizedSender: true })), })); - vi.mock("./directive-handling.js", () => ({ + vi.mock("./directive-handling.defaults.js", () => ({ resolveDefaultModel: vi.fn(() => ({ defaultProvider: "openai", defaultModel: "gpt-4o-mini", diff --git a/src/auto-reply/reply/groups.ts b/src/auto-reply/reply/groups.ts index cc9226f0cf4..b6dfe0a835e 100644 --- a/src/auto-reply/reply/groups.ts +++ b/src/auto-reply/reply/groups.ts @@ -1,3 +1,5 @@ +import { resolveDiscordGroupRequireMention } from "../../../extensions/discord/api.js"; +import { resolveSlackGroupRequireMention } from "../../../extensions/slack/api.js"; import { getChannelPlugin, normalizeChannelId as normalizePluginChannelId, @@ -52,6 +54,24 @@ function resolveDockChannelId(raw?: string | null): ChannelId | null { } } +function resolveBuiltInRequireMentionFromConfig(params: { + cfg: OpenClawConfig; + channel: ChannelId; + groupChannel?: string; + groupId?: string; + groupSpace?: string; + accountId?: string | null; +}): boolean | undefined { + switch (params.channel) { + case "discord": + return resolveDiscordGroupRequireMention(params); + case "slack": + return resolveSlackGroupRequireMention(params); + default: + return undefined; + } +} + export function resolveGroupRequireMention(params: { cfg: OpenClawConfig; ctx: TemplateContext; @@ -81,6 +101,17 @@ export function resolveGroupRequireMention(params: { if (typeof requireMention === "boolean") { return requireMention; } + const builtInRequireMention = resolveBuiltInRequireMentionFromConfig({ + cfg, + channel, + groupChannel, + groupId, + groupSpace, + accountId: ctx.AccountId, + }); + if (typeof builtInRequireMention === "boolean") { + return builtInRequireMention; + } return resolveChannelGroupRequireMention({ cfg, channel, diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts index f0599cecc66..d3409e2cdc3 100644 --- a/src/auto-reply/reply/model-selection.test.ts +++ b/src/auto-reply/reply/model-selection.test.ts @@ -413,7 +413,7 @@ describe("createModelSelectionState respects session model override", () => { }); expect(state.provider).toBe("xai"); - expect(state.model).toBe("grok-4.20-reasoning"); + expect(state.model).toBe("grok-4.20-beta-latest-reasoning"); expect(state.resetModelOverride).toBe(false); }); diff --git a/src/auto-reply/reply/reply-flow.test.ts b/src/auto-reply/reply/reply-flow.test.ts index b065d9dfd40..3b4bd06ba5a 100644 --- a/src/auto-reply/reply/reply-flow.test.ts +++ b/src/auto-reply/reply/reply-flow.test.ts @@ -1454,7 +1454,7 @@ describe("followup queue drain restart after idle window", () => { expect(freshCalls[0]?.prompt).toBe("after-empty-schedule"); }); - it("processes a message enqueued after the drain empties and deletes the queue", async () => { + it("processes a message enqueued after the drain empties when enqueue refreshes the callback", async () => { const key = `test-idle-window-race-${Date.now()}`; const calls: FollowupRun[] = []; const settings: QueueSettings = { mode: "followup", debounceMs: 0, cap: 50 }; @@ -1485,10 +1485,16 @@ describe("followup queue drain restart after idle window", () => { await new Promise((resolve) => setImmediate(resolve)); // Simulate the race: a new message arrives AFTER the drain finished and - // deleted the queue, but WITHOUT calling scheduleFollowupDrain again. - enqueueFollowupRun(key, createRun({ prompt: "after-idle" }), settings); + // deleted the queue. The next enqueue refreshes the callback and should + // kick a new idle drain automatically. + enqueueFollowupRun( + key, + createRun({ prompt: "after-idle" }), + settings, + "message-id", + runFollowup, + ); - // kickFollowupDrainIfIdle should have restarted the drain automatically. await secondProcessed.promise; expect(calls).toHaveLength(2); @@ -1569,7 +1575,7 @@ describe("followup queue drain restart after idle window", () => { expect(freshCalls[0]?.prompt).toBe("queued-while-busy"); }); - it("restarts an idle drain across distinct enqueue and drain module instances", async () => { + it("restarts an idle drain across distinct enqueue and drain module instances when enqueue refreshes the callback", async () => { const drainA = await importFreshModule( import.meta.url, "./queue/drain.js?scope=restart-a", @@ -1600,7 +1606,13 @@ describe("followup queue drain restart after idle window", () => { await new Promise((resolve) => setImmediate(resolve)); - enqueueB.enqueueFollowupRun(key, createRun({ prompt: "after-idle" }), settings); + enqueueB.enqueueFollowupRun( + key, + createRun({ prompt: "after-idle" }), + settings, + "message-id", + runFollowup, + ); await vi.waitFor( () => { diff --git a/src/auto-reply/reply/reply-plumbing.test.ts b/src/auto-reply/reply/reply-plumbing.test.ts index 6e039333c58..2a9d6ec7910 100644 --- a/src/auto-reply/reply/reply-plumbing.test.ts +++ b/src/auto-reply/reply/reply-plumbing.test.ts @@ -1,7 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it } from "vitest"; +import { slackPlugin } from "../../../extensions/slack/src/channel.js"; import type { SubagentRunRecord } from "../../agents/subagent-registry.js"; import type { OpenClawConfig } from "../../config/config.js"; import { formatDurationCompact } from "../../infra/format-time/format-duration.js"; +import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../../plugins/runtime.js"; +import { createTestRegistry } from "../../test-utils/channel-plugins.js"; import type { TemplateContext } from "../templating.js"; import { buildThreadingToolContext } from "./agent-runner-utils.js"; import { applyReplyThreading } from "./reply-payloads.js"; @@ -15,7 +18,11 @@ import { describe("buildThreadingToolContext", () => { const cfg = {} as OpenClawConfig; - it("uses conversation id for WhatsApp", () => { + afterEach(() => { + resetPluginRuntimeStateForTest(); + }); + + it("uses the recipient id for WhatsApp without origin routing metadata", () => { const sessionCtx = { Provider: "whatsapp", From: "123@g.us", @@ -28,7 +35,7 @@ describe("buildThreadingToolContext", () => { hasRepliedRef: undefined, }); - expect(result.currentChannelId).toBe("123@g.us"); + expect(result.currentChannelId).toBe("+15550001"); }); it("falls back to To for WhatsApp when From is missing", () => { @@ -62,7 +69,7 @@ describe("buildThreadingToolContext", () => { expect(result.currentChannelId).toBe("chat:99"); }); - it("normalizes signal direct targets for tool context", () => { + it("uses raw signal direct targets for tool context without provider-specific normalization", () => { const sessionCtx = { Provider: "signal", ChatType: "direct", @@ -76,10 +83,10 @@ describe("buildThreadingToolContext", () => { hasRepliedRef: undefined, }); - expect(result.currentChannelId).toBe("+15550001"); + expect(result.currentChannelId).toBe("signal:+15550002"); }); - it("preserves signal group ids for tool context", () => { + it("keeps raw signal group ids for tool context", () => { const sessionCtx = { Provider: "signal", ChatType: "group", @@ -92,10 +99,12 @@ describe("buildThreadingToolContext", () => { hasRepliedRef: undefined, }); - expect(result.currentChannelId).toBe("group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg="); + expect(result.currentChannelId).toBe( + "signal:group:VWATOdKF2hc8zdOS76q9tb0+5BI522e03QLDAq/9yPg=", + ); }); - it("uses the sender handle for iMessage direct chats", () => { + it("uses chat_id for iMessage direct chats without provider-specific normalization", () => { const sessionCtx = { Provider: "imessage", ChatType: "direct", @@ -109,7 +118,7 @@ describe("buildThreadingToolContext", () => { hasRepliedRef: undefined, }); - expect(result.currentChannelId).toBe("imessage:+15550001"); + expect(result.currentChannelId).toBe("chat_id:12"); }); it("uses chat_id for iMessage groups", () => { @@ -129,7 +138,27 @@ describe("buildThreadingToolContext", () => { expect(result.currentChannelId).toBe("chat_id:7"); }); - it("prefers MessageThreadId for Slack tool threading", () => { + it("uses raw Slack channel ids without implicit thread context", () => { + const sessionCtx = { + Provider: "slack", + To: "channel:C1", + MessageThreadId: "123.456", + } as TemplateContext; + + const result = buildThreadingToolContext({ + sessionCtx, + config: { channels: { slack: { replyToMode: "all" } } } as OpenClawConfig, + hasRepliedRef: undefined, + }); + + expect(result.currentChannelId).toBe("channel:C1"); + expect(result.currentThreadTs).toBeUndefined(); + }); + + it("uses Slack plugin threading context when the plugin registry is active", () => { + setActivePluginRegistry( + createTestRegistry([{ pluginId: "slack", plugin: slackPlugin, source: "test" }]), + ); const sessionCtx = { Provider: "slack", To: "channel:C1", @@ -206,7 +235,7 @@ describe("applyReplyThreading auto-threading", () => { expect(result[0].replyToId).toBeUndefined(); }); - it("strips explicit tags for Slack when off mode disallows tags", () => { + it("keeps explicit tags for Slack when off mode allows explicit tags", () => { const result = applyReplyThreading({ payloads: [{ text: "[[reply_to_current]]A" }], replyToMode: "off", @@ -215,7 +244,7 @@ describe("applyReplyThreading auto-threading", () => { }); expect(result).toHaveLength(1); - expect(result[0].replyToId).toBeUndefined(); + expect(result[0].replyToId).toBe("42"); }); it("keeps explicit tags for Telegram when off mode is enabled", () => { diff --git a/src/auto-reply/reply/session-hooks-context.test.ts b/src/auto-reply/reply/session-hooks-context.test.ts index ee8137d3ddc..ee9c20420ee 100644 --- a/src/auto-reply/reply/session-hooks-context.test.ts +++ b/src/auto-reply/reply/session-hooks-context.test.ts @@ -12,16 +12,7 @@ const hookRunnerMocks = vi.hoisted(() => ({ runSessionEnd: vi.fn(), })); -vi.mock("../../plugins/hook-runner-global.js", () => ({ - getGlobalHookRunner: () => - ({ - hasHooks: hookRunnerMocks.hasHooks, - runSessionStart: hookRunnerMocks.runSessionStart, - runSessionEnd: hookRunnerMocks.runSessionEnd, - }) as unknown as HookRunner, -})); - -const { initSessionState } = await import("./session.js"); +let initSessionState: typeof import("./session.js").initSessionState; async function createStorePath(prefix: string): Promise { const root = await fs.mkdtemp(path.join(os.tmpdir(), `${prefix}-`)); @@ -37,7 +28,16 @@ async function writeStore( } describe("session hook context wiring", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => + ({ + hasHooks: hookRunnerMocks.hasHooks, + runSessionStart: hookRunnerMocks.runSessionStart, + runSessionEnd: hookRunnerMocks.runSessionEnd, + }) as unknown as HookRunner, + })); hookRunnerMocks.hasHooks.mockReset(); hookRunnerMocks.runSessionStart.mockReset(); hookRunnerMocks.runSessionEnd.mockReset(); @@ -46,6 +46,7 @@ describe("session hook context wiring", () => { hookRunnerMocks.hasHooks.mockImplementation( (hookName) => hookName === "session_start" || hookName === "session_end", ); + ({ initSessionState } = await import("./session.js")); }); afterEach(() => { diff --git a/src/auto-reply/skill-commands.test.ts b/src/auto-reply/skill-commands.test.ts index b6f6e8639a2..38a7fedf485 100644 --- a/src/auto-reply/skill-commands.test.ts +++ b/src/auto-reply/skill-commands.test.ts @@ -1,7 +1,7 @@ import fs from "node:fs/promises"; import os from "node:os"; import path from "node:path"; -import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, beforeEach, describe, expect, it, vi } from "vitest"; // Avoid importing the full chat command registry for reserved-name calculation. vi.mock("./commands-registry.js", () => ({ @@ -71,12 +71,76 @@ let listSkillCommandsForAgents: typeof import("./skill-commands.js").listSkillCo let resolveSkillCommandInvocation: typeof import("./skill-commands.js").resolveSkillCommandInvocation; let skillCommandsTesting: typeof import("./skill-commands.js").__testing; -beforeAll(async () => { +async function loadFreshSkillCommandsModuleForTest() { + vi.resetModules(); + vi.doMock("./commands-registry.js", () => ({ + listChatCommands: () => [], + })); + vi.doMock("../infra/skills-remote.js", () => ({ + getRemoteSkillEligibility: () => ({}), + })); + vi.doMock("../agents/skills.js", () => { + function resolveUniqueName(base: string, used: Set): string { + let name = base; + let suffix = 2; + while (used.has(name.toLowerCase())) { + name = `${base}_${suffix}`; + suffix += 1; + } + used.add(name.toLowerCase()); + return name; + } + + function resolveWorkspaceSkills( + workspaceDir: string, + ): Array<{ skillName: string; description: string }> { + const dirName = path.basename(workspaceDir); + if (dirName === "main") { + return [{ skillName: "demo-skill", description: "Demo skill" }]; + } + if (dirName === "research") { + return [ + { skillName: "demo-skill", description: "Demo skill 2" }, + { skillName: "extra-skill", description: "Extra skill" }, + ]; + } + return []; + } + + return { + buildWorkspaceSkillCommandSpecs: ( + workspaceDir: string, + opts?: { reservedNames?: Set; skillFilter?: string[] }, + ) => { + const used = new Set(); + for (const reserved of opts?.reservedNames ?? []) { + used.add(String(reserved).toLowerCase()); + } + const filter = opts?.skillFilter; + const entries = + filter === undefined + ? resolveWorkspaceSkills(workspaceDir) + : resolveWorkspaceSkills(workspaceDir).filter((entry) => + filter.some((skillName) => skillName === entry.skillName), + ); + + return entries.map((entry) => { + const base = entry.skillName.replace(/-/g, "_"); + const name = resolveUniqueName(base, used); + return { name, skillName: entry.skillName, description: entry.description }; + }); + }, + }; + }); ({ listSkillCommandsForAgents, resolveSkillCommandInvocation, __testing: skillCommandsTesting, } = await import("./skill-commands.js")); +} + +beforeEach(async () => { + await loadFreshSkillCommandsModuleForTest(); }); describe("resolveSkillCommandInvocation", () => { diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts index 8fcf4f09ab1..3f642a79f9f 100644 --- a/src/auto-reply/thinking.test.ts +++ b/src/auto-reply/thinking.test.ts @@ -6,26 +6,37 @@ const providerRuntimeMocks = vi.hoisted(() => ({ resolveProviderXHighThinking: vi.fn(), })); -vi.mock("../plugins/provider-thinking.js", () => ({ - resolveProviderBinaryThinking: providerRuntimeMocks.resolveProviderBinaryThinking, - resolveProviderDefaultThinkingLevel: providerRuntimeMocks.resolveProviderDefaultThinkingLevel, - resolveProviderXHighThinking: providerRuntimeMocks.resolveProviderXHighThinking, -})); -import { - listThinkingLevelLabels, - listThinkingLevels, - normalizeReasoningLevel, - normalizeThinkLevel, - resolveThinkingDefaultForModel, -} from "./thinking.js"; +let listThinkingLevelLabels: typeof import("./thinking.js").listThinkingLevelLabels; +let listThinkingLevels: typeof import("./thinking.js").listThinkingLevels; +let normalizeReasoningLevel: typeof import("./thinking.js").normalizeReasoningLevel; +let normalizeThinkLevel: typeof import("./thinking.js").normalizeThinkLevel; +let resolveThinkingDefaultForModel: typeof import("./thinking.js").resolveThinkingDefaultForModel; -beforeEach(() => { +async function loadFreshThinkingModuleForTest() { + vi.resetModules(); + vi.doMock("../plugins/provider-thinking.js", () => ({ + resolveProviderBinaryThinking: providerRuntimeMocks.resolveProviderBinaryThinking, + resolveProviderDefaultThinkingLevel: providerRuntimeMocks.resolveProviderDefaultThinkingLevel, + resolveProviderXHighThinking: providerRuntimeMocks.resolveProviderXHighThinking, + })); + return await import("./thinking.js"); +} + +beforeEach(async () => { providerRuntimeMocks.resolveProviderBinaryThinking.mockReset(); providerRuntimeMocks.resolveProviderBinaryThinking.mockReturnValue(undefined); providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReset(); providerRuntimeMocks.resolveProviderDefaultThinkingLevel.mockReturnValue(undefined); providerRuntimeMocks.resolveProviderXHighThinking.mockReset(); providerRuntimeMocks.resolveProviderXHighThinking.mockReturnValue(undefined); + + ({ + listThinkingLevelLabels, + listThinkingLevels, + normalizeReasoningLevel, + normalizeThinkLevel, + resolveThinkingDefaultForModel, + } = await loadFreshThinkingModuleForTest()); }); describe("normalizeThinkLevel", () => { diff --git a/src/commands/agent.delivery.test.ts b/src/commands/agent.delivery.test.ts index 9e4bc0693e3..3cd6e47c95b 100644 --- a/src/commands/agent.delivery.test.ts +++ b/src/commands/agent.delivery.test.ts @@ -30,7 +30,28 @@ vi.mock("../infra/outbound/targets.js", async () => { }; }); -const { deliverAgentCommandResult } = await import("./agent/delivery.js"); +let deliverAgentCommandResult: typeof import("./agent/delivery.js").deliverAgentCommandResult; + +async function loadFreshAgentDeliveryModuleForTest() { + vi.resetModules(); + vi.doMock("../channels/plugins/index.js", () => ({ + getChannelPlugin: mocks.getChannelPlugin, + normalizeChannelId: (value: string) => value, + })); + vi.doMock("../infra/outbound/deliver.js", () => ({ + deliverOutboundPayloads: mocks.deliverOutboundPayloads, + })); + vi.doMock("../infra/outbound/targets.js", async () => { + const actual = await vi.importActual( + "../infra/outbound/targets.js", + ); + return { + ...actual, + resolveOutboundTarget: mocks.resolveOutboundTarget, + }; + }); + return await import("./agent/delivery.js"); +} describe("deliverAgentCommandResult", () => { function createRuntime(): RuntimeEnv { @@ -79,9 +100,11 @@ describe("deliverAgentCommandResult", () => { return { runtime }; } - beforeEach(() => { + beforeEach(async () => { mocks.deliverOutboundPayloads.mockClear(); mocks.resolveOutboundTarget.mockClear(); + + ({ deliverAgentCommandResult } = await loadFreshAgentDeliveryModuleForTest()); }); it("prefers explicit accountId for outbound delivery", async () => { diff --git a/src/commands/agent.test.ts b/src/commands/agent.test.ts index cc2718184bc..acb3100a94b 100644 --- a/src/commands/agent.test.ts +++ b/src/commands/agent.test.ts @@ -12,7 +12,6 @@ import { FailoverError } from "../agents/failover-error.js"; import { loadModelCatalog } from "../agents/model-catalog.js"; import * as modelSelectionModule from "../agents/model-selection.js"; import { runEmbeddedPiAgent } from "../agents/pi-embedded.js"; -import * as commandSecretGatewayModule from "../cli/command-secret-gateway.js"; import type { OpenClawConfig } from "../config/config.js"; import * as configModule from "../config/config.js"; import { clearSessionStoreCacheForTest } from "../config/sessions.js"; @@ -76,6 +75,7 @@ vi.mock("../agents/command/session-store.js", async (importOriginal) => { vi.mock("../agents/skills.js", () => ({ buildWorkspaceSkillSnapshot: vi.fn(() => undefined), + loadWorkspaceSkillEntries: vi.fn(() => []), })); vi.mock("../agents/skills/refresh.js", () => ({ @@ -92,13 +92,47 @@ const runtime: RuntimeEnv = { const configSpy = vi.spyOn(configModule, "loadConfig"); const readConfigFileSnapshotForWriteSpy = vi.spyOn(configModule, "readConfigFileSnapshotForWrite"); -const setRuntimeConfigSnapshotSpy = vi.spyOn(configModule, "setRuntimeConfigSnapshot"); const runCliAgentSpy = vi.spyOn(cliRunnerModule, "runCliAgent"); async function withTempHome(fn: (home: string) => Promise): Promise { return withTempHomeBase(fn, { prefix: "openclaw-agent-" }); } +async function loadFreshAgentCommandModulesForTest() { + vi.resetModules(); + const runEmbeddedPiAgentMock = vi.fn(); + const loadModelCatalogMock = vi.fn(); + const isCliProviderMock = vi.fn(() => false); + vi.doMock("../agents/pi-embedded.js", () => ({ + abortEmbeddedPiRun: vi.fn().mockReturnValue(false), + runEmbeddedPiAgent: runEmbeddedPiAgentMock, + resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, + })); + vi.doMock("../agents/model-catalog.js", () => ({ + loadModelCatalog: loadModelCatalogMock, + })); + vi.doMock("../agents/model-selection.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + isCliProvider: isCliProviderMock, + }; + }); + const [agentModule, configModuleFresh, commandSecretGatewayModuleFresh] = await Promise.all([ + import("./agent.js"), + import("../config/config.js"), + import("../cli/command-secret-gateway.js"), + ]); + return { + agentCommand: agentModule.agentCommand, + configModuleFresh, + commandSecretGatewayModuleFresh, + runEmbeddedPiAgentMock, + loadModelCatalogMock, + isCliProviderMock, + }; +} + function mockConfig( home: string, storePath: string, @@ -309,6 +343,27 @@ beforeEach(() => { describe("agentCommand", () => { it("sets runtime snapshots from source config before embedded agent run", async () => { await withTempHome(async (home) => { + const { + agentCommand: freshAgentCommand, + configModuleFresh, + commandSecretGatewayModuleFresh, + runEmbeddedPiAgentMock, + loadModelCatalogMock, + isCliProviderMock, + } = await loadFreshAgentCommandModulesForTest(); + const freshConfigSpy = vi.spyOn(configModuleFresh, "loadConfig"); + const freshReadConfigFileSnapshotForWriteSpy = vi.spyOn( + configModuleFresh, + "readConfigFileSnapshotForWrite", + ); + const freshSetRuntimeConfigSnapshotSpy = vi.spyOn( + configModuleFresh, + "setRuntimeConfigSnapshot", + ); + runEmbeddedPiAgentMock.mockResolvedValue(createDefaultAgentResult()); + loadModelCatalogMock.mockResolvedValue([]); + isCliProviderMock.mockImplementation(() => false); + const store = path.join(home, "sessions.json"); const loadedConfig = { agents: { @@ -354,13 +409,13 @@ describe("agentCommand", () => { }, } as unknown as OpenClawConfig; - configSpy.mockReturnValue(loadedConfig); - readConfigFileSnapshotForWriteSpy.mockResolvedValue({ + freshConfigSpy.mockReturnValue(loadedConfig); + freshReadConfigFileSnapshotForWriteSpy.mockResolvedValue({ snapshot: { valid: true, resolved: sourceConfig }, writeOptions: {}, } as Awaited>); const resolveSecretsSpy = vi - .spyOn(commandSecretGatewayModule, "resolveCommandSecretRefsViaGateway") + .spyOn(commandSecretGatewayModuleFresh, "resolveCommandSecretRefsViaGateway") .mockResolvedValueOnce({ resolvedConfig, diagnostics: [], @@ -368,15 +423,15 @@ describe("agentCommand", () => { hadUnresolvedTargets: false, }); - await agentCommand({ message: "hello", to: "+1555" }, runtime); + await freshAgentCommand({ message: "hello", to: "+1555" }, runtime); expect(resolveSecretsSpy).toHaveBeenCalledWith({ config: loadedConfig, commandName: "agent", targetIds: expect.any(Set), }); - expect(setRuntimeConfigSnapshotSpy).toHaveBeenCalledWith(resolvedConfig, sourceConfig); - expect(vi.mocked(runEmbeddedPiAgent).mock.calls.at(-1)?.[0]?.config).toBe(resolvedConfig); + expect(freshSetRuntimeConfigSnapshotSpy).toHaveBeenCalledWith(resolvedConfig, sourceConfig); + expect(runEmbeddedPiAgentMock.mock.calls.at(-1)?.[0]?.config).toBe(resolvedConfig); }); }); diff --git a/src/commands/agent/session.test.ts b/src/commands/agent/session.test.ts index ecced34dac0..7d4a2479b6d 100644 --- a/src/commands/agent/session.test.ts +++ b/src/commands/agent/session.test.ts @@ -22,7 +22,12 @@ vi.mock("../../agents/agent-scope.js", () => ({ listAgentIds: mocks.listAgentIds, })); -const { resolveSessionKeyForRequest } = await import("./session.js"); +let resolveSessionKeyForRequest: typeof import("./session.js").resolveSessionKeyForRequest; + +async function loadFreshSessionModuleForTest() { + vi.resetModules(); + ({ resolveSessionKeyForRequest } = await import("./session.js")); +} describe("resolveSessionKeyForRequest", () => { const MAIN_STORE_PATH = "/tmp/main-store.json"; @@ -46,7 +51,8 @@ describe("resolveSessionKeyForRequest", () => { mocks.loadSessionStore.mockImplementation((storePath: string) => stores[storePath] ?? {}); }; - beforeEach(() => { + beforeEach(async () => { + await loadFreshSessionModuleForTest(); vi.clearAllMocks(); mocks.listAgentIds.mockReturnValue(["main"]); }); diff --git a/src/commands/channels.mock-harness.ts b/src/commands/channels.mock-harness.ts index 6a448a9750e..ba92b117672 100644 --- a/src/commands/channels.mock-harness.ts +++ b/src/commands/channels.mock-harness.ts @@ -31,3 +31,12 @@ vi.mock("../../extensions/telegram/api.js", async (importOriginal) => { deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset, }; }); + +vi.mock("../../extensions/telegram/src/update-offset-store.js", async (importOriginal) => { + const actual = + await importOriginal(); + return { + ...actual, + deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset, + }; +}); diff --git a/src/commands/dashboard.links.test.ts b/src/commands/dashboard.links.test.ts index 1b21f9fd5d2..58b165493e6 100644 --- a/src/commands/dashboard.links.test.ts +++ b/src/commands/dashboard.links.test.ts @@ -1,5 +1,4 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { dashboardCommand } from "./dashboard.js"; const readConfigFileSnapshotMock = vi.hoisted(() => vi.fn()); const resolveGatewayPortMock = vi.hoisted(() => vi.fn()); @@ -30,6 +29,29 @@ vi.mock("../secrets/resolve.js", () => ({ resolveSecretRefValues: resolveSecretRefValuesMock, })); +let dashboardCommand: typeof import("./dashboard.js").dashboardCommand; + +async function loadFreshDashboardModuleForTest() { + vi.resetModules(); + vi.doMock("../config/config.js", () => ({ + readConfigFileSnapshot: readConfigFileSnapshotMock, + resolveGatewayPort: resolveGatewayPortMock, + })); + vi.doMock("./onboard-helpers.js", () => ({ + resolveControlUiLinks: resolveControlUiLinksMock, + detectBrowserOpenSupport: detectBrowserOpenSupportMock, + openUrl: openUrlMock, + formatControlUiSshHint: formatControlUiSshHintMock, + })); + vi.doMock("../infra/clipboard.js", () => ({ + copyToClipboard: copyToClipboardMock, + })); + vi.doMock("../secrets/resolve.js", () => ({ + resolveSecretRefValues: resolveSecretRefValuesMock, + })); + return await import("./dashboard.js"); +} + const runtime = { log: vi.fn(), error: vi.fn(), @@ -62,7 +84,7 @@ function mockSnapshot(token: unknown = "abc") { } describe("dashboardCommand", () => { - beforeEach(() => { + beforeEach(async () => { resetRuntime(); readConfigFileSnapshotMock.mockClear(); resolveGatewayPortMock.mockClear(); @@ -73,6 +95,7 @@ describe("dashboardCommand", () => { copyToClipboardMock.mockClear(); delete process.env.OPENCLAW_GATEWAY_TOKEN; delete process.env.CUSTOM_GATEWAY_TOKEN; + ({ dashboardCommand } = await loadFreshDashboardModuleForTest()); }); it("opens and copies the dashboard link by default", async () => { @@ -173,9 +196,8 @@ describe("dashboardCommand", () => { ); }); - it("resolves env-template gateway.auth.token before building dashboard URL", async () => { + it("keeps URL non-tokenized when env-template gateway.auth.token is unresolved", async () => { mockSnapshot("${CUSTOM_GATEWAY_TOKEN}"); - process.env.CUSTOM_GATEWAY_TOKEN = "resolved-secret-token"; copyToClipboardMock.mockResolvedValue(true); detectBrowserOpenSupportMock.mockResolvedValue({ ok: true }); openUrlMock.mockResolvedValue(true); @@ -185,6 +207,11 @@ describe("dashboardCommand", () => { expect(copyToClipboardMock).toHaveBeenCalledWith("http://127.0.0.1:18789/"); expect(openUrlMock).toHaveBeenCalledWith("http://127.0.0.1:18789/"); expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining( + "Token auto-auth unavailable: gateway.auth.token SecretRef is unresolved (env:default:CUSTOM_GATEWAY_TOKEN).", + ), + ); + expect(runtime.log).not.toHaveBeenCalledWith( expect.stringContaining("Token auto-auth is disabled for SecretRef-managed"), ); }); diff --git a/src/commands/health.snapshot.test.ts b/src/commands/health.snapshot.test.ts index 610dd1388c7..833c15bde3f 100644 --- a/src/commands/health.snapshot.test.ts +++ b/src/commands/health.snapshot.test.ts @@ -2,57 +2,83 @@ 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 { - buildTokenChannelStatusSummary, - probeTelegram, - type ChannelPlugin as TelegramChannelPlugin, -} from "../../extensions/telegram/runtime-api.js"; -import { - listTelegramAccountIds, - resolveTelegramAccount, -} from "../../extensions/telegram/src/accounts.js"; -import { adaptScopedAccountAccessor } from "../plugin-sdk/channel-config-helpers.js"; -import { setActivePluginRegistry } from "../plugins/runtime.js"; -import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js"; +import type { ChannelPlugin as TelegramChannelPlugin } from "../../extensions/telegram/runtime-api.js"; import type { HealthSummary } from "./health.js"; -import { getHealthSnapshot } from "./health.js"; let testConfig: Record = {}; let testStore: Record = {}; -vi.mock("../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); +let buildTokenChannelStatusSummary: typeof import("../../extensions/telegram/runtime-api.js").buildTokenChannelStatusSummary; +let probeTelegram: typeof import("../../extensions/telegram/runtime-api.js").probeTelegram; +let listTelegramAccountIds: typeof import("../../extensions/telegram/src/accounts.js").listTelegramAccountIds; +let resolveTelegramAccount: typeof import("../../extensions/telegram/src/accounts.js").resolveTelegramAccount; +let adaptScopedAccountAccessor: typeof import("../plugin-sdk/channel-config-helpers.js").adaptScopedAccountAccessor; +let setActivePluginRegistry: typeof import("../plugins/runtime.js").setActivePluginRegistry; +let createChannelTestPluginBase: typeof import("../test-utils/channel-plugins.js").createChannelTestPluginBase; +let createTestRegistry: typeof import("../test-utils/channel-plugins.js").createTestRegistry; +let getHealthSnapshot: typeof import("./health.js").getHealthSnapshot; + +async function loadFreshHealthModulesForTest() { + vi.resetModules(); + vi.doMock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => testConfig, + }; + }); + vi.doMock("../config/sessions.js", () => ({ + resolveStorePath: () => "/tmp/sessions.json", + resolveSessionFilePath: vi.fn(() => "/tmp/sessions.json"), + loadSessionStore: () => testStore, + saveSessionStore: vi.fn().mockResolvedValue(undefined), + readSessionUpdatedAt: vi.fn(() => undefined), + recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), + updateLastRoute: vi.fn().mockResolvedValue(undefined), + })); + vi.doMock("../../extensions/telegram/src/fetch.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + resolveTelegramFetch: () => fetch, + }; + }); + vi.doMock("../../extensions/whatsapp/src/auth-store.js", () => ({ + webAuthExists: vi.fn(async () => true), + getWebAuthAgeMs: vi.fn(() => 1234), + readWebSelfId: vi.fn(() => ({ e164: null, jid: null })), + logWebSelfId: vi.fn(), + logoutWeb: vi.fn(), + })); + + const [ + telegramRuntime, + telegramAccounts, + channelHelpers, + pluginsRuntime, + channelTestUtils, + health, + ] = await Promise.all([ + import("../../extensions/telegram/runtime-api.js"), + import("../../extensions/telegram/src/accounts.js"), + import("../plugin-sdk/channel-config-helpers.js"), + import("../plugins/runtime.js"), + import("../test-utils/channel-plugins.js"), + import("./health.js"), + ]); + return { - ...actual, - loadConfig: () => testConfig, + buildTokenChannelStatusSummary: telegramRuntime.buildTokenChannelStatusSummary, + probeTelegram: telegramRuntime.probeTelegram, + listTelegramAccountIds: telegramAccounts.listTelegramAccountIds, + resolveTelegramAccount: telegramAccounts.resolveTelegramAccount, + adaptScopedAccountAccessor: channelHelpers.adaptScopedAccountAccessor, + setActivePluginRegistry: pluginsRuntime.setActivePluginRegistry, + createChannelTestPluginBase: channelTestUtils.createChannelTestPluginBase, + createTestRegistry: channelTestUtils.createTestRegistry, + getHealthSnapshot: health.getHealthSnapshot, }; -}); - -vi.mock("../config/sessions.js", () => ({ - resolveStorePath: () => "/tmp/sessions.json", - resolveSessionFilePath: vi.fn(() => "/tmp/sessions.json"), - loadSessionStore: () => testStore, - saveSessionStore: vi.fn().mockResolvedValue(undefined), - readSessionUpdatedAt: vi.fn(() => undefined), - recordSessionMetaFromInbound: vi.fn().mockResolvedValue(undefined), - updateLastRoute: vi.fn().mockResolvedValue(undefined), -})); - -vi.mock("../../extensions/telegram/src/fetch.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - resolveTelegramFetch: () => fetch, - }; -}); - -vi.mock("../../extensions/whatsapp/src/auth-store.js", () => ({ - webAuthExists: vi.fn(async () => true), - getWebAuthAgeMs: vi.fn(() => 1234), - readWebSelfId: vi.fn(() => ({ e164: null, jid: null })), - logWebSelfId: vi.fn(), - logoutWeb: vi.fn(), -})); +} function stubTelegramFetchOk(calls: string[]) { vi.stubGlobal( @@ -118,31 +144,46 @@ async function runSuccessfulTelegramProbe( return { calls, telegram }; } -const telegramHealthPlugin: Pick< +function createTelegramHealthPlugin(): Pick< TelegramChannelPlugin, "id" | "meta" | "capabilities" | "config" | "status" -> = { - ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), - config: { - listAccountIds: (cfg) => listTelegramAccountIds(cfg), - resolveAccount: adaptScopedAccountAccessor(resolveTelegramAccount), - isConfigured: (account) => Boolean(account.token?.trim()), - }, - status: { - buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), - probeAccount: async ({ account, timeoutMs }) => - await probeTelegram(account.token, timeoutMs, { - proxyUrl: account.config.proxy, - network: account.config.network, - accountId: account.accountId, - }), - }, -}; +> { + return { + ...createChannelTestPluginBase({ id: "telegram", label: "Telegram" }), + config: { + listAccountIds: (cfg) => listTelegramAccountIds(cfg), + resolveAccount: adaptScopedAccountAccessor(resolveTelegramAccount), + isConfigured: (account) => Boolean(account.token?.trim()), + }, + status: { + buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), + probeAccount: async ({ account, timeoutMs }) => + await probeTelegram(account.token, timeoutMs, { + proxyUrl: account.config.proxy, + network: account.config.network, + accountId: account.accountId, + }), + }, + }; +} describe("getHealthSnapshot", () => { - beforeEach(() => { + beforeEach(async () => { + ({ + buildTokenChannelStatusSummary, + probeTelegram, + listTelegramAccountIds, + resolveTelegramAccount, + adaptScopedAccountAccessor, + setActivePluginRegistry, + createChannelTestPluginBase, + createTestRegistry, + getHealthSnapshot, + } = await loadFreshHealthModulesForTest()); setActivePluginRegistry( - createTestRegistry([{ pluginId: "telegram", plugin: telegramHealthPlugin, source: "test" }]), + createTestRegistry([ + { pluginId: "telegram", plugin: createTelegramHealthPlugin(), source: "test" }, + ]), ); }); diff --git a/src/commands/message.test.ts b/src/commands/message.test.ts index daeb4e95893..21ee00d2d15 100644 --- a/src/commands/message.test.ts +++ b/src/commands/message.test.ts @@ -70,12 +70,47 @@ vi.mock("../../extensions/whatsapp/runtime-api.js", () => ({ handleWhatsAppAction, })); -import { messageCommand } from "./message.js"; +let messageCommand: typeof import("./message.js").messageCommand; + +async function loadFreshMessageCommandModuleForTest() { + vi.resetModules(); + vi.doMock("../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadConfig: () => testConfig, + }; + }); + vi.doMock("../cli/command-secret-gateway.js", () => ({ + resolveCommandSecretRefsViaGateway, + })); + vi.doMock("../gateway/call.js", () => ({ + callGateway: callGatewayMock, + callGatewayLeastPrivilege: callGatewayMock, + randomIdempotencyKey: () => "idem-1", + })); + vi.doMock("../../extensions/whatsapp/src/session.js", () => ({ + webAuthExists, + })); + vi.doMock("../../extensions/discord/src/actions/runtime.js", () => ({ + handleDiscordAction, + })); + vi.doMock("../../extensions/slack/runtime-api.js", () => ({ + handleSlackAction, + })); + vi.doMock("../../extensions/telegram/src/action-runtime.js", () => ({ + handleTelegramAction, + })); + vi.doMock("../../extensions/whatsapp/runtime-api.js", () => ({ + handleWhatsAppAction, + })); + ({ messageCommand } = await import("./message.js")); +} let envSnapshot: ReturnType; const EMPTY_TEST_REGISTRY = createTestRegistry([]); -beforeEach(() => { +beforeEach(async () => { envSnapshot = captureEnv(["TELEGRAM_BOT_TOKEN", "DISCORD_BOT_TOKEN"]); process.env.TELEGRAM_BOT_TOKEN = ""; process.env.DISCORD_BOT_TOKEN = ""; @@ -88,6 +123,7 @@ beforeEach(() => { handleTelegramAction.mockClear(); handleWhatsAppAction.mockClear(); resolveCommandSecretRefsViaGateway.mockClear(); + await loadFreshMessageCommandModuleForTest(); }); afterEach(() => { diff --git a/src/commands/models/list.list-command.forward-compat.test.ts b/src/commands/models/list.list-command.forward-compat.test.ts index 07f2afedc4e..84c6a425567 100644 --- a/src/commands/models/list.list-command.forward-compat.test.ts +++ b/src/commands/models/list.list-command.forward-compat.test.ts @@ -287,13 +287,6 @@ describe("modelsListCommand forward-compat", () => { input: ["text"], contextWindow: 272000, }, - { - provider: "openai-codex", - id: "gpt-5.4", - name: "GPT-5.4", - input: ["text"], - contextWindow: 272000, - }, ]); mocks.listProfilesForProvider.mockImplementation((_: unknown, provider: string) => provider === "openai-codex" @@ -308,17 +301,11 @@ describe("modelsListCommand forward-compat", () => { if (modelId === "gpt-5.4") { return { ...OPENAI_CODEX_53_MODEL }; } - if (modelId === "gpt-5.4") { - return { ...OPENAI_CODEX_MODEL }; - } return undefined; }, ); await runAllOpenAiCodexCommand(); expect(lastPrintedRows<{ key: string; available: boolean }>()).toEqual([ - expect.objectContaining({ - key: "openai-codex/gpt-5.4", - }), expect.objectContaining({ key: "openai-codex/gpt-5.4", available: true, diff --git a/src/commands/models/list.status.test.ts b/src/commands/models/list.status.test.ts index 9b408f50d93..fc770f6677a 100644 --- a/src/commands/models/list.status.test.ts +++ b/src/commands/models/list.status.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, type Mock, vi } from "vitest"; +import { beforeEach, describe, expect, it, type Mock, vi } from "vitest"; const mocks = vi.hoisted(() => { type MockAuthProfile = { provider: string; [key: string]: unknown }; @@ -83,59 +83,57 @@ const mocks = vi.hoisted(() => { }; }); -vi.mock("../../agents/agent-paths.js", () => ({ - resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir, -})); +let modelsStatusCommand: typeof import("./list.status-command.js").modelsStatusCommand; -vi.mock("../../agents/agent-scope.js", () => ({ - resolveAgentDir: mocks.resolveAgentDir, - resolveAgentExplicitModelPrimary: mocks.resolveAgentExplicitModelPrimary, - resolveAgentEffectiveModelPrimary: mocks.resolveAgentEffectiveModelPrimary, - resolveAgentModelFallbacksOverride: mocks.resolveAgentModelFallbacksOverride, - listAgentIds: mocks.listAgentIds, -})); - -vi.mock("../../agents/auth-profiles.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - ensureAuthProfileStore: mocks.ensureAuthProfileStore, - listProfilesForProvider: mocks.listProfilesForProvider, - resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, - resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay, - }; -}); - -vi.mock("../../agents/model-auth.js", () => ({ - resolveEnvApiKey: mocks.resolveEnvApiKey, - hasUsableCustomProviderApiKey: mocks.hasUsableCustomProviderApiKey, - resolveUsableCustomProviderApiKey: mocks.resolveUsableCustomProviderApiKey, - getCustomProviderApiKey: mocks.getCustomProviderApiKey, -})); - -vi.mock("../../infra/shell-env.js", () => ({ - getShellEnvAppliedKeys: mocks.getShellEnvAppliedKeys, - shouldEnableShellEnvFallback: mocks.shouldEnableShellEnvFallback, -})); - -vi.mock("../../config/config.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - createConfigIO: mocks.createConfigIO, - loadConfig: mocks.loadConfig, - }; -}); - -vi.mock("../../infra/provider-usage.js", async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - loadProviderUsageSummary: mocks.loadProviderUsageSummary, - }; -}); - -import { modelsStatusCommand } from "./list.status-command.js"; +async function loadFreshModelsStatusCommandModuleForTest() { + vi.resetModules(); + vi.doMock("../../agents/agent-paths.js", () => ({ + resolveOpenClawAgentDir: mocks.resolveOpenClawAgentDir, + })); + vi.doMock("../../agents/agent-scope.js", () => ({ + resolveAgentDir: mocks.resolveAgentDir, + resolveAgentExplicitModelPrimary: mocks.resolveAgentExplicitModelPrimary, + resolveAgentEffectiveModelPrimary: mocks.resolveAgentEffectiveModelPrimary, + resolveAgentModelFallbacksOverride: mocks.resolveAgentModelFallbacksOverride, + listAgentIds: mocks.listAgentIds, + })); + vi.doMock("../../agents/auth-profiles.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ensureAuthProfileStore: mocks.ensureAuthProfileStore, + listProfilesForProvider: mocks.listProfilesForProvider, + resolveAuthProfileDisplayLabel: mocks.resolveAuthProfileDisplayLabel, + resolveAuthStorePathForDisplay: mocks.resolveAuthStorePathForDisplay, + }; + }); + vi.doMock("../../agents/model-auth.js", () => ({ + resolveEnvApiKey: mocks.resolveEnvApiKey, + hasUsableCustomProviderApiKey: mocks.hasUsableCustomProviderApiKey, + resolveUsableCustomProviderApiKey: mocks.resolveUsableCustomProviderApiKey, + getCustomProviderApiKey: mocks.getCustomProviderApiKey, + })); + vi.doMock("../../infra/shell-env.js", () => ({ + getShellEnvAppliedKeys: mocks.getShellEnvAppliedKeys, + shouldEnableShellEnvFallback: mocks.shouldEnableShellEnvFallback, + })); + vi.doMock("../../config/config.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + createConfigIO: mocks.createConfigIO, + loadConfig: mocks.loadConfig, + }; + }); + vi.doMock("../../infra/provider-usage.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + loadProviderUsageSummary: mocks.loadProviderUsageSummary, + }; + }); + ({ modelsStatusCommand } = await import("./list.status-command.js")); +} const defaultResolveEnvApiKeyImpl: | ((provider: string) => { apiKey: string; source: string } | null) @@ -202,6 +200,10 @@ async function withAgentScopeOverrides( } describe("modelsStatusCommand auth overview", () => { + beforeEach(async () => { + await loadFreshModelsStatusCommandModuleForTest(); + }); + it("includes masked auth sources in JSON output", async () => { await modelsStatusCommand({ json: true }, runtime as never); const payload = JSON.parse(String((runtime.log as Mock).mock.calls[0]?.[0])); diff --git a/src/commands/models/shared.test.ts b/src/commands/models/shared.test.ts index b547a0ad0e5..2317a105295 100644 --- a/src/commands/models/shared.test.ts +++ b/src/commands/models/shared.test.ts @@ -11,12 +11,15 @@ vi.mock("../../config/config.js", () => ({ writeConfigFile: (...args: unknown[]) => mocks.writeConfigFile(...args), })); -import { loadValidConfigOrThrow, updateConfig } from "./shared.js"; +let loadValidConfigOrThrow: typeof import("./shared.js").loadValidConfigOrThrow; +let updateConfig: typeof import("./shared.js").updateConfig; describe("models/shared", () => { - beforeEach(() => { + beforeEach(async () => { + vi.resetModules(); mocks.readConfigFileSnapshot.mockClear(); mocks.writeConfigFile.mockClear(); + ({ loadValidConfigOrThrow, updateConfig } = await import("./shared.js")); }); it("returns config when snapshot is valid", async () => { diff --git a/src/commands/onboard-non-interactive.gateway.test.ts b/src/commands/onboard-non-interactive.gateway.test.ts index 3ea1bf159ca..7bf31d2c494 100644 --- a/src/commands/onboard-non-interactive.gateway.test.ts +++ b/src/commands/onboard-non-interactive.gateway.test.ts @@ -1,6 +1,6 @@ import fs from "node:fs/promises"; import path from "node:path"; -import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../runtime.js"; import { makeTempWorkspace } from "../test-helpers/workspace.js"; import { captureEnv } from "../test-utils/env.js"; @@ -90,10 +90,18 @@ vi.mock("../daemon/diagnostics.js", () => ({ readLastGatewayErrorLine: readLastGatewayErrorLineMock, })); -const { runNonInteractiveSetup } = await import("./onboard-non-interactive.js"); -const { resolveConfigPath: resolveStateConfigPath } = await import("../config/paths.js"); -const { resolveConfigPath } = await import("../config/config.js"); -const { callGateway } = await import("../gateway/call.js"); +let runNonInteractiveSetup: typeof import("./onboard-non-interactive.js").runNonInteractiveSetup; +let resolveStateConfigPath: typeof import("../config/paths.js").resolveConfigPath; +let resolveConfigPath: typeof import("../config/config.js").resolveConfigPath; +let callGateway: typeof import("../gateway/call.js").callGateway; + +async function loadGatewayOnboardModules(): Promise { + vi.resetModules(); + ({ runNonInteractiveSetup } = await import("./onboard-non-interactive.js")); + ({ resolveConfigPath: resolveStateConfigPath } = await import("../config/paths.js")); + ({ resolveConfigPath } = await import("../config/config.js")); + ({ callGateway } = await import("../gateway/call.js")); +} function getPseudoPort(base: number): number { return base + (process.pid % 1000); @@ -150,6 +158,11 @@ describe("onboard (non-interactive): gateway and remote auth", () => { process.env.HOME = tempHome; }); + beforeEach(async () => { + await loadGatewayOnboardModules(); + gatewayClientCalls.length = 0; + }); + afterAll(async () => { if (tempHome) { await fs.rm(tempHome, { recursive: true, force: true }); @@ -163,6 +176,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { gatewayServiceMock.isLoaded.mockClear(); gatewayServiceMock.readRuntime.mockClear(); readLastGatewayErrorLineMock.mockClear(); + gatewayClientCalls.length = 0; }); it("writes gateway token auth into config", async () => { @@ -412,12 +426,20 @@ describe("onboard (non-interactive): gateway and remote auth", () => { skippedReason: "systemd-user-unavailable", }); - let capturedError = ""; + let capturedJson = ""; const runtimeWithCapture: RuntimeEnv = { - log: () => {}, + log: (...args: unknown[]) => { + const firstArg = args[0]; + capturedJson = + typeof firstArg === "string" + ? firstArg + : firstArg instanceof Error + ? firstArg.message + : (JSON.stringify(firstArg) ?? ""); + }, error: (...args: unknown[]) => { const firstArg = args[0]; - capturedError = + const capturedError = typeof firstArg === "string" ? firstArg : firstArg instanceof Error @@ -452,7 +474,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }, runtimeWithCapture, ), - ).rejects.toThrow(/"phase": "daemon-install"/); + ).rejects.toThrow("exit should not be reached after runtime.error"); } finally { Object.defineProperty(process, "platform", { configurable: true, @@ -460,7 +482,7 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }); } - const parsed = JSON.parse(capturedError) as { + const parsed = JSON.parse(capturedJson) as { ok: boolean; phase: string; daemonInstall?: { @@ -490,12 +512,20 @@ describe("onboard (non-interactive): gateway and remote auth", () => { detail: "gateway closed (1006 abnormal closure (no close frame)): no close reason", })); - let capturedError = ""; + let capturedJson = ""; const runtimeWithCapture: RuntimeEnv = { - log: () => {}, + log: (...args: unknown[]) => { + const firstArg = args[0]; + capturedJson = + typeof firstArg === "string" + ? firstArg + : firstArg instanceof Error + ? firstArg.message + : (JSON.stringify(firstArg) ?? ""); + }, error: (...args: unknown[]) => { const firstArg = args[0]; - capturedError = + const capturedError = typeof firstArg === "string" ? firstArg : firstArg instanceof Error @@ -523,9 +553,9 @@ describe("onboard (non-interactive): gateway and remote auth", () => { }, runtimeWithCapture, ), - ).rejects.toThrow(/"phase": "gateway-health"/); + ).rejects.toThrow("exit should not be reached after runtime.error"); - const parsed = JSON.parse(capturedError) as { + const parsed = JSON.parse(capturedJson) as { ok: boolean; phase: string; installDaemon: boolean; diff --git a/src/commands/status.test.ts b/src/commands/status.test.ts index 2ee927ad7e3..3f72752fc78 100644 --- a/src/commands/status.test.ts +++ b/src/commands/status.test.ts @@ -268,6 +268,13 @@ vi.mock("../config/sessions/types.js", async (importOriginal) => { ), }; }); +vi.mock("../channels/config-presence.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + hasPotentialConfiguredChannels: mocks.hasPotentialConfiguredChannels, + }; +}); vi.mock("../channels/plugins/index.js", () => ({ listChannelPlugins: () => [ @@ -461,6 +468,8 @@ describe("statusCommand", () => { }); mocks.buildPluginCompatibilityNotices.mockReset(); mocks.buildPluginCompatibilityNotices.mockReturnValue([]); + mocks.hasPotentialConfiguredChannels.mockReset(); + mocks.hasPotentialConfiguredChannels.mockReturnValue(true); mocks.runSecurityAudit.mockReset(); mocks.runSecurityAudit.mockResolvedValue({ ts: 0, diff --git a/src/gateway/gateway-misc.test.ts b/src/gateway/gateway-misc.test.ts index f25dbd5b4b6..23b590870df 100644 --- a/src/gateway/gateway-misc.test.ts +++ b/src/gateway/gateway-misc.test.ts @@ -2,9 +2,8 @@ import * as fs from "node:fs/promises"; import type { IncomingMessage, ServerResponse } from "node:http"; import * as os from "node:os"; import * as path from "node:path"; -import { describe, expect, it, test, vi } from "vitest"; +import { beforeEach, describe, expect, it, test, vi } from "vitest"; import { defaultVoiceWakeTriggers } from "../infra/voicewake.js"; -import { GatewayClient } from "./client.js"; import { handleControlUiHttpRequest } from "./control-ui.js"; import { DEFAULT_DANGEROUS_NODE_COMMANDS, @@ -45,6 +44,29 @@ vi.mock("ws", () => ({ }, })); +let GatewayClient: typeof import("./client.js").GatewayClient; + +async function loadFreshGatewayClientModuleForTest() { + vi.resetModules(); + vi.doMock("ws", () => ({ + WebSocket: class MockWebSocket { + on = vi.fn(); + close = vi.fn(); + send = vi.fn(); + + constructor(url: unknown, opts: unknown) { + wsMockState.last = { url, opts }; + } + }, + })); + ({ GatewayClient } = await import("./client.js")); +} + +beforeEach(async () => { + wsMockState.last = null; + await loadFreshGatewayClientModuleForTest(); +}); + describe("GatewayClient", () => { async function withControlUiRoot( params: { faviconSvg?: string; indexHtml?: string }, @@ -63,7 +85,6 @@ describe("GatewayClient", () => { } test("uses a large maxPayload for node snapshots", () => { - wsMockState.last = null; const client = new GatewayClient({ url: "ws://127.0.0.1:1" }); client.start(); const last = wsMockState.last as { url: unknown; opts: unknown } | null; diff --git a/src/gateway/server-methods/send.test.ts b/src/gateway/server-methods/send.test.ts index 22cf527a46b..1d19094e71f 100644 --- a/src/gateway/server-methods/send.test.ts +++ b/src/gateway/server-methods/send.test.ts @@ -1,8 +1,6 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; -import { resolveOutboundTarget } from "../../infra/outbound/targets.js"; import { setActivePluginRegistry } from "../../plugins/runtime.js"; import { createTestRegistry } from "../../test-utils/channel-plugins.js"; -import { sendHandlers } from "./send.js"; import type { GatewayRequestContext } from "./types.js"; const mocks = vi.hoisted(() => ({ @@ -31,6 +29,7 @@ vi.mock("../../channels/plugins/index.js", () => ({ })); const TEST_AGENT_WORKSPACE = "/tmp/openclaw-test-workspace"; +let sendHandlers: typeof import("./send.js").sendHandlers; function resolveAgentIdFromSessionKeyForTests(params: { sessionKey?: string }): string { if (typeof params.sessionKey === "string") { @@ -89,6 +88,11 @@ vi.mock("../../config/sessions.js", async () => { }; }); +async function loadFreshSendHandlersForTest() { + vi.resetModules(); + ({ sendHandlers } = await import("./send.js")); +} + const makeContext = (): GatewayRequestContext => ({ dedupe: new Map(), @@ -142,7 +146,7 @@ function mockDeliverySuccess(messageId: string) { describe("gateway send mirroring", () => { let registrySeq = 0; - beforeEach(() => { + beforeEach(async () => { vi.clearAllMocks(); registrySeq += 1; setActivePluginRegistry(createTestRegistry([]), `send-test-${registrySeq}`); @@ -153,6 +157,7 @@ describe("gateway send mirroring", () => { }); mocks.sendPoll.mockResolvedValue({ messageId: "poll-1" }); mocks.getChannelPlugin.mockReturnValue({ outbound: { sendPoll: mocks.sendPoll } }); + await loadFreshSendHandlersForTest(); }); it("accepts media-only sends without message", async () => { @@ -508,7 +513,7 @@ describe("gateway send mirroring", () => { }); it("returns invalid request when outbound target resolution fails", async () => { - vi.mocked(resolveOutboundTarget).mockReturnValue({ + mocks.resolveOutboundTarget.mockReturnValue({ ok: false, error: new Error("target not found"), }); diff --git a/src/gateway/server.roles-allowlist-update.test.ts b/src/gateway/server.roles-allowlist-update.test.ts index 87dfc400cc5..a616bbbf6eb 100644 --- a/src/gateway/server.roles-allowlist-update.test.ts +++ b/src/gateway/server.roles-allowlist-update.test.ts @@ -5,6 +5,7 @@ import { describe, expect, test, vi } from "vitest"; import { WebSocket } from "ws"; import { CONFIG_PATH } from "../config/config.js"; import type { DeviceIdentity } from "../infra/device-identity.js"; +import { resolveRestartSentinelPath } from "../infra/restart-sentinel.js"; import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; import type { GatewayClient } from "./client.js"; @@ -151,7 +152,7 @@ describe("gateway update.run", () => { }, FAST_WAIT_OPTS); expect(sigusr1).toHaveBeenCalled(); - const sentinelPath = path.join(os.homedir(), ".openclaw", "restart-sentinel.json"); + const sentinelPath = resolveRestartSentinelPath(); const raw = await fs.readFile(sentinelPath, "utf-8"); const parsed = JSON.parse(raw) as { payload?: { kind?: string; stats?: { mode?: string } }; diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts index cd39783465d..2fcbdd1c4ec 100644 --- a/src/gateway/session-utils.test.ts +++ b/src/gateway/session-utils.test.ts @@ -1318,7 +1318,10 @@ describe("listSessionsFromStore subagent metadata", () => { }); test("uses persisted active subagent runs when the local worker only has terminal snapshots", async () => { - await withStateDirEnv("openclaw-session-utils-subagent-", async ({ stateDir }) => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-session-utils-subagent-")); + const stateDir = path.join(tempRoot, "state"); + fs.mkdirSync(stateDir, { recursive: true }); + try { const now = Date.now(); const childSessionKey = "agent:main:subagent:disk-live"; const registryPath = path.join(stateDir, "subagents", "runs.json"); @@ -1359,30 +1362,38 @@ describe("listSessionsFromStore subagent metadata", () => { "utf-8", ); - const row = withEnv({ VITEST: undefined, NODE_ENV: "development" }, () => { - const result = listSessionsFromStore({ - cfg, - storePath: "/tmp/sessions.json", - store: { - [childSessionKey]: { - sessionId: "sess-disk-live", - updatedAt: now, - spawnedBy: "agent:main:main", - status: "done", - endedAt: now - 1_800, - runtimeMs: 100, - } as SessionEntry, - }, - opts: {}, - }); - return result.sessions.find((session) => session.key === childSessionKey); - }); + const row = withEnv( + { + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_TEST_READ_SUBAGENT_RUNS_FROM_DISK: "1", + }, + () => { + const result = listSessionsFromStore({ + cfg, + storePath: "/tmp/sessions.json", + store: { + [childSessionKey]: { + sessionId: "sess-disk-live", + updatedAt: now, + spawnedBy: "agent:main:main", + status: "done", + endedAt: now - 1_800, + runtimeMs: 100, + } as SessionEntry, + }, + opts: {}, + }); + return result.sessions.find((session) => session.key === childSessionKey); + }, + ); expect(row?.status).toBe("running"); expect(row?.startedAt).toBe(now - 9_000); expect(row?.endedAt).toBeUndefined(); expect(row?.runtimeMs).toBeGreaterThanOrEqual(9_000); - }); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } }); test("includes explicit parentSessionKey relationships for dashboard child sessions", () => { diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts index 52c6f54b1ca..235d608d89f 100644 --- a/src/gateway/session-utils.ts +++ b/src/gateway/session-utils.ts @@ -312,6 +312,8 @@ function resolveTranscriptUsageFallback(params: { cfg: params.cfg, provider: modelProvider, model, + // Gateway/session listing is read-only; don't start async model discovery. + allowAsyncLoad: false, }); const estimatedCostUsd = resolveEstimatedSessionCostUsd({ cfg: params.cfg, @@ -899,7 +901,7 @@ export function getSessionDefaults(cfg: OpenClawConfig): GatewaySessionsDefaults }); const contextTokens = cfg.agents?.defaults?.contextTokens ?? - lookupContextTokens(resolved.model) ?? + lookupContextTokens(resolved.model, { allowAsyncLoad: false }) ?? DEFAULT_CONTEXT_TOKENS; return { modelProvider: resolved.provider ?? null, @@ -1107,6 +1109,8 @@ export function buildGatewaySessionRow(params: { cfg, provider: modelProvider, model, + // Gateway/session listing is read-only; don't start async model discovery. + allowAsyncLoad: false, }), ); diff --git a/src/gateway/startup-auth.test.ts b/src/gateway/startup-auth.test.ts index 67dd4e00524..fce94fea1cf 100644 --- a/src/gateway/startup-auth.test.ts +++ b/src/gateway/startup-auth.test.ts @@ -14,10 +14,14 @@ vi.mock("../config/config.js", async (importOriginal) => { }; }); -import { - assertHooksTokenSeparateFromGatewayAuth, - ensureGatewayStartupAuth, -} from "./startup-auth.js"; +let assertHooksTokenSeparateFromGatewayAuth: typeof import("./startup-auth.js").assertHooksTokenSeparateFromGatewayAuth; +let ensureGatewayStartupAuth: typeof import("./startup-auth.js").ensureGatewayStartupAuth; + +async function loadFreshStartupAuthModuleForTest() { + vi.resetModules(); + ({ assertHooksTokenSeparateFromGatewayAuth, ensureGatewayStartupAuth } = + await import("./startup-auth.js")); +} describe("ensureGatewayStartupAuth", () => { async function expectEphemeralGeneratedTokenWhenOverridden(cfg: OpenClawConfig) { @@ -35,9 +39,10 @@ describe("ensureGatewayStartupAuth", () => { expect(mocks.writeConfigFile).not.toHaveBeenCalled(); } - beforeEach(() => { + beforeEach(async () => { vi.restoreAllMocks(); mocks.writeConfigFile.mockClear(); + await loadFreshStartupAuthModuleForTest(); }); async function expectNoTokenGeneration(cfg: OpenClawConfig, mode: string) { diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 911eec6050a..d08b4dbca0c 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -79,6 +79,7 @@ const GATEWAY_TEST_ENV_KEYS = [ let gatewayEnvSnapshot: ReturnType | undefined; let tempHome: string | undefined; let tempConfigRoot: string | undefined; +let tempControlUiRoot: string | undefined; let suiteConfigRootSeq = 0; let lastSyncedSessionStorePath: string | undefined; let lastSyncedSessionConfigJson: string | undefined; @@ -271,6 +272,19 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { }); await fs.mkdir(tempConfigRoot, { recursive: true }); } + tempControlUiRoot = path.join(tempHome, ".openclaw-test-control-ui"); + await fs.rm(tempControlUiRoot, { + recursive: true, + force: true, + maxRetries: 20, + retryDelay: 25, + }); + await fs.mkdir(tempControlUiRoot, { recursive: true }); + await fs.writeFile( + path.join(tempControlUiRoot, "index.html"), + "openclaw-test-control-ui\n", + "utf-8", + ); setTestConfigRoot(tempConfigRoot); clearRuntimeConfigSnapshot(); clearConfigCache(); @@ -341,6 +355,7 @@ async function cleanupGatewayTestHome(options: { restoreEnv: boolean }) { tempHome = undefined; } tempConfigRoot = undefined; + tempControlUiRoot = undefined; if (options.restoreEnv) { suiteConfigRootSeq = 0; } @@ -468,6 +483,17 @@ export async function startGatewayServer(port: number, opts?: GatewayServerOptio const mod = await getServerModule(); const resolvedOpts = opts?.controlUiEnabled === undefined ? { ...opts, controlUiEnabled: false } : opts; + if ( + resolvedOpts?.controlUiEnabled === true && + process.env.OPENCLAW_TEST_MINIMAL_GATEWAY === "1" && + tempControlUiRoot && + typeof (testState.gatewayControlUi as { root?: unknown } | undefined)?.root !== "string" + ) { + testState.gatewayControlUi = { + ...testState.gatewayControlUi, + root: tempControlUiRoot, + }; + } return await mod.startGatewayServer(port, resolvedOpts); } @@ -814,7 +840,7 @@ export async function connectReq( export async function connectOk(ws: WebSocket, opts?: Parameters[1]) { const res = await connectReq(ws, opts); - expect(res.ok).toBe(true); + expect(res.ok, JSON.stringify(res)).toBe(true); expect((res.payload as { type?: unknown } | undefined)?.type).toBe("hello-ok"); return res.payload as { type: "hello-ok" }; } diff --git a/src/gateway/test-with-server.ts b/src/gateway/test-with-server.ts index 25872770c56..37e52b821b3 100644 --- a/src/gateway/test-with-server.ts +++ b/src/gateway/test-with-server.ts @@ -1,6 +1,5 @@ -import { afterAll, beforeAll } from "vitest"; -import { startServerWithClient } from "./test-helpers.js"; -import { connectOk } from "./test-helpers.js"; +import { afterAll, beforeAll, beforeEach } from "vitest"; +import { connectOk, startServerWithClient, testState } from "./test-helpers.js"; type StartServerWithClient = typeof startServerWithClient; export type GatewayWs = Awaited>["ws"]; @@ -21,9 +20,10 @@ export function installConnectedControlUiServerSuite( onReady: (started: { server: GatewayServer; ws: GatewayWs; port: number }) => void, ): void { let started: Awaited> | null = null; + const token = "secret"; beforeAll(async () => { - started = await startServerWithClient(undefined, { controlUiEnabled: true }); + started = await startServerWithClient(token, { controlUiEnabled: true }); onReady({ server: started.server, ws: started.ws, @@ -32,10 +32,16 @@ export function installConnectedControlUiServerSuite( await connectOk(started.ws); }); + beforeEach(() => { + process.env.OPENCLAW_GATEWAY_TOKEN = token; + testState.gatewayAuth = { mode: "token", token }; + }); + afterAll(async () => { started?.ws.close(); if (started?.server) { await started.server.close(); } + started?.envSnapshot.restore(); }); }