test: continue vitest threads migration

This commit is contained in:
Peter Steinberger
2026-03-24 02:00:22 +00:00
parent d41b92fff2
commit 2833b27f52
110 changed files with 3163 additions and 994 deletions

View File

@@ -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"));
});

View File

@@ -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<typeof import("@mariozechner/pi-ai")>();
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" },

View File

@@ -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();

View File

@@ -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<typeof import("@mariozechner/pi-ai/oauth")>(
"@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/,

View File

@@ -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<typeof import("@mariozechner/pi-ai/oauth")>(
"@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 });

View File

@@ -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;

View File

@@ -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();
});

View File

@@ -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", () => {

View File

@@ -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<typeof import("../infra/shell-env.js")>();
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<typeof import("../infra/shell-env.js")>();
return {
...mod,
getShellPathFromLoginShell: shellEnvMocks.getShellPathFromLoginShell,
resolveShellEnvFallbackTimeoutMs: shellEnvMocks.resolveShellEnvFallbackTimeoutMs,
};
});
vi.doMock("../infra/exec-approvals.js", async (importOriginal) => {
const mod = await importOriginal<typeof import("../infra/exec-approvals.js")>();
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<typeof captureEnv>;
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",

View File

@@ -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();

View File

@@ -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();
});

View File

@@ -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 () => {

View File

@@ -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] ?? {

View File

@@ -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<typeof piCodingAgent>();
@@ -13,7 +12,16 @@ vi.mock("@mariozechner/pi-coding-agent", async (importOriginal) => {
});
const mockGenerateSummary = vi.mocked(piCodingAgent.generateSummary);
type SummarizeInStagesInput = Parameters<typeof summarizeInStages>[0];
type SummarizeInStagesInput = Parameters<typeof import("./compaction.js").summarizeInStages>[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");
});

View File

@@ -241,7 +241,6 @@ describe("loadModelCatalog", () => {
expect.objectContaining({
provider: "openai-codex",
id: "gpt-5.4",
name: "gpt-5.4",
}),
);
});

View File

@@ -599,8 +599,8 @@ describe("models-config", () => {
await expectMoonshotTokenLimits({
contextWindow: 0,
maxTokens: -1,
expectedContextWindow: 256000,
expectedMaxTokens: 8192,
expectedContextWindow: 262144,
expectedMaxTokens: 262144,
});
});
});

View File

@@ -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<typeof import("./models-config.providers.js")>(
"./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<void>,
) {
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<string, { apiKey?: string }>;
}>();
expect(parsed.providers.moonshot?.apiKey).toBe(NON_ENV_SECRETREF_MARKER);
} finally {
clearRuntimeConfigSnapshot();
clearConfigCache();
}
const parsed = await readGeneratedModelsJson<{
providers: Record<string, { apiKey?: string }>;
}>();
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<string, { baseUrl?: string; apiKey?: string }>;
}>();
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<string, { baseUrl?: string; apiKey?: string }>;
}>();
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<string, { baseUrl?: string; apiKey?: string }>;
}>();
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<string, { baseUrl?: string; apiKey?: string }>;
}>();
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();
}
});
});
});
});

View File

@@ -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<string, ProviderConfig> };
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();
});
});
});

View File

@@ -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);

View File

@@ -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<Record<string, unknown>> } | 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<void> {
vi.resetModules();
await import("./test-helpers/fast-core-tools.js");
({ createOpenClawTools } = await import("./openclaw-tools.js"));
}

View File

@@ -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<typeof import("../config/sessions.js")>();
return {
...actual,
loadSessionStore: (storePath: string) => loadSessionStoreMock(storePath),
updateSessionStore: async (
storePath: string,
mutator: (store: Record<string, unknown>) => Promise<void> | void,
) => {
const store = loadSessionStoreMock(storePath) as Record<string, unknown>;
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<typeof import("../gateway/session-utils.js")>();
return {
...actual,
loadCombinedSessionStoreForGateway: (cfg: unknown) =>
loadCombinedSessionStoreForGatewayMock(cfg),
};
});
vi.doMock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
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<string, unknown>) {
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: {

View File

@@ -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<typeof import("../config/config.js")>();
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" },

View File

@@ -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<string, unknown> = {
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<typeof import("../config/config.js")>();
@@ -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<typeof import("../config/config.js")>();
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(

View File

@@ -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<typeof import("./pi-embedded.js")>();
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

View File

@@ -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<typeof makeMockSessionManager>;
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 () => {

View File

@@ -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 () => {

View File

@@ -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<TPayload extends Record<string, unknown>> = {
headers?: Record<string, string>;
payload: TPayload;
};
function applyAndCapture(params: {
provider: string;
modelId: string;
callerHeaders?: Record<string, string>;
cfg?: OpenClawConfig;
}) {
return runExtraParamsCase({
applyModelId: params.modelId,
applyProvider: params.provider,
callerHeaders: params.callerHeaders,
cfg: params.cfg ?? TEST_CFG,
model: {
const captured: ExtraParamsCapture<Record<string, unknown>> = { payload: {} };
const baseStreamFn: StreamFn = (model, _context, options) => {
captured.headers = options?.headers;
options?.onPayload?.(captured.payload, model);
return {} as ReturnType<StreamFn>;
};
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<string, unknown>;
thinkingLevel?: "minimal" | "low" | "medium" | "high";
}) {
return runExtraParamsCase({
applyModelId: params.modelId,
applyProvider: "kilocode",
cfg: params.cfg ?? TEST_CFG,
model: {
const captured: ExtraParamsCapture<Record<string, unknown>> = {
payload: { ...params.initialPayload },
};
const baseStreamFn: StreamFn = (model, _context, options) => {
options?.onPayload?.(captured.payload, model);
return {} as ReturnType<StreamFn>;
};
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<string, unknown>;
});
// 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<string, unknown>;
});
// 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<string, unknown>;
});
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<string, unknown>;
});
// x-ai models reject reasoning.effort — should be skipped
expect(capturedPayload?.reasoning).toBeUndefined();

View File

@@ -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 {

View File

@@ -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<SessionManager["appendMessage"]>[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", () => {

View File

@@ -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"]);

View File

@@ -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];

View File

@@ -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 () => {

View File

@@ -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);

View File

@@ -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<typeof toToolDefinitions>[0][number]) {

View File

@@ -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",

View File

@@ -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<string, unknown>;
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();

View File

@@ -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<typeof import("node:child_process")>();
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 () => {

View File

@@ -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 () => ({

View File

@@ -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<typeof import("../../infra/boundary-file-read.js").openBoundaryFile>
) => ReturnType<typeof import("../../infra/boundary-file-read.js").openBoundaryFile>)
| 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<typeof import("../../infra/boundary-file-read.js")>();
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<typeof import("../../infra/boundary-file-read.js")>();
actualOpenBoundaryFile = actual.openBoundaryFile;
return {
...actual,
openBoundaryFile: (...args: unknown[]) => hoisted.openBoundaryFile(...args),
};
});
({ createSandboxFsBridge: createSandboxFsBridgeImpl } = await import("./fs-bridge.js"));
}
export function createSandboxFsBridge(
...args: Parameters<typeof import("./fs-bridge.js").createSandboxFsBridge>
) {
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();
});
}

View File

@@ -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 () => {

View File

@@ -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<typeof import("./ssh.js")>();
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(() => {

View File

@@ -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);
});
});

View File

@@ -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<typeof import("../shared/pid-alive.js")>();
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<typeof import("../shared/pid-alive.js")>();
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;

View File

@@ -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<typeof import("../security/skill-scanner.js")>()),
scanDirectoryWithSummary: (...args: unknown[]) => scanDirectoryWithSummaryMock(...args),
}));
vi.doMock("../shared/config-eval.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../shared/config-eval.js")>();
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 () => {

View File

@@ -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",

View File

@@ -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(

View File

@@ -205,3 +205,24 @@ export function ensureSkillsWatcher(params: { workspaceDir: string; config?: Ope
watchers.set(workspaceDir, state);
}
export async function resetSkillsRefreshForTest(): Promise<void> {
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.
}
}),
);
}

View File

@@ -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();
});

View File

@@ -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<typeof import("./pi-embedded.js")>();
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<typeof import("./subagent-registry.js")>();
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<typeof runSubagentAnnounceFlow>[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<typeof import("../config/config.js")>();
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<typeof import("./pi-embedded.js")>();
return {
...actual,
isEmbeddedPiRunActive: () => false,
queueEmbeddedPiMessage: () => false,
waitForEmbeddedPiRunEnd: async () => true,
};
});
vi.doMock("./subagent-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./subagent-registry.js")>();
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");

View File

@@ -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",

View File

@@ -38,7 +38,9 @@ export function getSubagentRunsSnapshotForRead(
inMemoryRuns: Map<string, SubagentRunRecord>,
): Map<string, SubagentRunRecord> {
const merged = new Map<string, SubagentRunRecord>();
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.

View File

@@ -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<typeof vi.fn>;
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 () => {

View File

@@ -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<typeof import("./pi-embedded.js")>();
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<typeof import("./pi-embedded.js")>();
return {
...actual,
isEmbeddedPiRunActive: () => false,
queueEmbeddedPiMessage: () => false,
waitForEmbeddedPiRunEnd: async () => true,
};
});
vi.doMock("./subagent-depth.js", () => ({
getSubagentDepthFromSessionStore: () => 0,
}));

View File

@@ -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<void> {
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 });

View File

@@ -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 () => {

View File

@@ -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<string, unknown> = {
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<typeof import("./subagent-registry.js")>();
@@ -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<typeof import("./subagent-registry.js")>();
return {
...actual,
countActiveRunsForSession: () => 0,
registerSubagentRun: () => {},
};
});
vi.doMock("./subagent-announce.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./subagent-announce.js")>();
return {
...actual,
buildSubagentSystemPrompt: () => "system-prompt",
};
});
vi.doMock("./agent-scope.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./agent-scope.js")>();
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();

View File

@@ -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<typeof import("../config/config.js")>();
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<typeof import("../config/sessions.js")>();
return {
...actual,
updateSessionStore: (...args: unknown[]) => updateSessionStoreMock(...args),
};
});
vi.doMock("../gateway/session-utils.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../gateway/session-utils.js")>();
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<typeof import("./subagent-registry.js")>();
return {
...actual,
countActiveRunsForSession: () => 0,
registerSubagentRun: () => {},
};
});
vi.doMock("./subagent-announce.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./subagent-announce.js")>();
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();

View File

@@ -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<typeof import("../config/config.js")>();
return {
...actual,
loadConfig: () => hoisted.configOverride,
};
});
vi.doMock("./subagent-registry.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./subagent-registry.js")>();
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<string, unknown>
@@ -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();

View File

@@ -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 () => {

View File

@@ -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<typeof createCronTool>[0],
): ReturnType<typeof createCronTool> {
return createCronTool(opts, {
callGatewayTool: async (method, _gatewayOpts, params) =>
await callGatewayMock({ method, params }),
});
}
function readGatewayCall(index = 0): { method?: string; params?: Record<string, unknown> } {
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<string | undefined> {
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",

View File

@@ -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(() => {

View File

@@ -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", () => {

View File

@@ -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<NodeListNode> & { nodeId: string }): NodeListNode {
return {
@@ -19,8 +21,10 @@ function node({ nodeId, ...overrides }: Partial<NodeListNode> & { nodeId: string
};
}
beforeEach(() => {
beforeEach(async () => {
vi.resetModules();
gatewayMocks.callGatewayTool.mockReset();
({ listNodes, resolveNodeIdFromList } = await import("./nodes-utils.js"));
});
describe("resolveNodeIdFromList defaults", () => {

View File

@@ -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", () => {

View File

@@ -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 () => {

View File

@@ -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<ReturnType<ReturnType<typeof createSessionsListTool>["execute"]>>;
type SessionsListResult = Awaited<
ReturnType<ReturnType<typeof import("./sessions-list-tool.js").createSessionsListTool>["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<typeof import("../../config/config.js")>();
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", () => {

View File

@@ -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",
);
});

View File

@@ -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",

View File

@@ -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", () => {

View File

@@ -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: {

View File

@@ -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<string | undef
}
function mockEmbeddedResponse(text: string) {
vi.mocked(runEmbeddedPiAgent).mockResolvedValue(makeEmbeddedTextResult(text));
runEmbeddedPiAgentMock.mockResolvedValue(makeEmbeddedTextResult(text));
}
async function runInlineReasoningMessage(params: {
@@ -112,7 +112,7 @@ async function runInFlightVerboseToggleCase(params: {
"main",
);
vi.mocked(runEmbeddedPiAgent).mockImplementation(async (agentParams) => {
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:<ms|s|m>, cap:<n>, drop:old|new|summarize.",
);
expect(runEmbeddedPiAgent).not.toHaveBeenCalled();
expect(runEmbeddedPiAgentMock).not.toHaveBeenCalled();
});
});
});

View File

@@ -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]]"),
);

View File

@@ -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<T>(fn: (home: string) => Promise<T>): Promise<T> {
@@ -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();
});
}

View File

@@ -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();
});
});
});

View File

@@ -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<typeof import("../infra/fs-safe.js")>();
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<typeof createSandboxMediaStageConfig>;
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,

View File

@@ -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", () => {

View File

@@ -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 () => {

View File

@@ -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<typeof import("./route-reply.js")>();
return {
...actual,
async function incrementRunCompactionCountForFollowupTest(
params: Parameters<typeof import("./session-run-accounting.js").incrementRunCompactionCount>[0],
): Promise<number | undefined> {
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<typeof import("./session-run-accounting.js").persistRunSessionUsage>[0],
): Promise<void> {
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 =>

View File

@@ -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<typeof import("../../channels/plugins/index.js")>();
return {
...actual,
getChannelPlugin: (...args: unknown[]) => getChannelPluginMock(...args),
};
});
// Import after mocks.
const { handleInlineActions } = await import("./get-reply-inline-actions.js");
type HandleInlineActionsInput = Parameters<typeof handleInlineActions>[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<typeof import("../../channels/plugins/index.js")>();
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 () => {

View File

@@ -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<Parameters<typeof runPreparedReply>[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,

View File

@@ -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> = {}): MsgContext {
return {
@@ -71,7 +76,8 @@ function buildCtx(overrides: Partial<MsgContext> = {}): MsgContext {
}
describe("getReplyFromConfig message hooks", () => {
beforeEach(() => {
beforeEach(async () => {
await loadFreshGetReplyModuleForTest();
delete process.env.OPENCLAW_TEST_FAST;
mocks.applyMediaUnderstanding.mockReset();
mocks.applyLinkUnderstanding.mockReset();

View File

@@ -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();

View File

@@ -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<typeof import("../../agents/agent-scope.js")>();
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<typeof import("../../agents/model-selection.js")>();
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",

View File

@@ -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,

View File

@@ -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);
});

View File

@@ -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<void>((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<typeof import("./queue/drain.js")>(
import.meta.url,
"./queue/drain.js?scope=restart-a",
@@ -1600,7 +1606,13 @@ describe("followup queue drain restart after idle window", () => {
await new Promise<void>((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(
() => {

View File

@@ -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", () => {

View File

@@ -12,16 +12,7 @@ const hookRunnerMocks = vi.hoisted(() => ({
runSessionEnd: vi.fn<HookRunner["runSessionEnd"]>(),
}));
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<string> {
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(() => {

View File

@@ -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>): 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<string>; skillFilter?: string[] },
) => {
const used = new Set<string>();
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", () => {

View File

@@ -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", () => {

View File

@@ -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<typeof import("../infra/outbound/targets.js")>(
"../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 () => {

View File

@@ -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<T>(fn: (home: string) => Promise<T>): Promise<T> {
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<typeof import("../agents/model-selection.js")>();
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<ReturnType<typeof configModule.readConfigFileSnapshotForWrite>>);
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);
});
});

View File

@@ -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"]);
});

View File

@@ -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<typeof import("../../extensions/telegram/src/update-offset-store.js")>();
return {
...actual,
deleteTelegramUpdateOffset: offsetMocks.deleteTelegramUpdateOffset,
};
});

View File

@@ -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"),
);
});

View File

@@ -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<string, unknown> = {};
let testStore: Record<string, { updatedAt?: number }> = {};
vi.mock("../config/config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../config/config.js")>();
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<typeof import("../config/config.js")>();
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<typeof import("../../extensions/telegram/src/fetch.js")>();
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<typeof import("../../extensions/telegram/src/fetch.js")>();
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" },
]),
);
});

View File

@@ -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<typeof import("../config/config.js")>();
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<typeof captureEnv>;
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(() => {

View File

@@ -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,

View File

@@ -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<typeof import("../../agents/auth-profiles.js")>();
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<typeof import("../../config/config.js")>();
return {
...actual,
createConfigIO: mocks.createConfigIO,
loadConfig: mocks.loadConfig,
};
});
vi.mock("../../infra/provider-usage.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/provider-usage.js")>();
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<typeof import("../../agents/auth-profiles.js")>();
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<typeof import("../../config/config.js")>();
return {
...actual,
createConfigIO: mocks.createConfigIO,
loadConfig: mocks.loadConfig,
};
});
vi.doMock("../../infra/provider-usage.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../infra/provider-usage.js")>();
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<T>(
}
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]));

View File

@@ -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 () => {

Some files were not shown because too many files have changed in this diff Show More