mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-26 09:21:55 +00:00
test: continue vitest threads migration
This commit is contained in:
@@ -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"));
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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/,
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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] ?? {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
|
||||
@@ -241,7 +241,6 @@ describe("loadModelCatalog", () => {
|
||||
expect.objectContaining({
|
||||
provider: "openai-codex",
|
||||
id: "gpt-5.4",
|
||||
name: "gpt-5.4",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -599,8 +599,8 @@ describe("models-config", () => {
|
||||
await expectMoonshotTokenLimits({
|
||||
contextWindow: 0,
|
||||
maxTokens: -1,
|
||||
expectedContextWindow: 256000,
|
||||
expectedMaxTokens: 8192,
|
||||
expectedContextWindow: 262144,
|
||||
expectedMaxTokens: 262144,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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"]);
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]]"),
|
||||
);
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(
|
||||
() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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"]);
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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" },
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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]));
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user