mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
improve: keep isolated tests under one second (#100019)
* test: speed up isolated test suite * test: finish isolated latency cleanup * test: eliminate remaining isolated latency spikes * test: remove final isolated timing outliers * test: bound full-suite tooling processes * test: bound native test process lifetime * test: warm isolated runtime suites * test: eliminate final isolated timing outliers * test: fix isolated timing fixture types * test: make timeout cleanup timing deterministic * test: pin media manifests to source checkout * test: isolate provider manifest contracts * test: eliminate residual isolated timing spikes * test: restore final isolated timing fixes * test: eliminate remaining isolated timing spikes * test: warm Zalo lifecycle imports * test: keep isolated suites below one second * test: use readable browser response fixtures
This commit is contained in:
committed by
GitHub
parent
91f188301d
commit
c757675f34
+18
-14
@@ -146,13 +146,15 @@ describe("pw-session getPageForTargetId", () => {
|
||||
urls: ["https://alpha.example", "https://beta.example"],
|
||||
}).pages;
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: "TARGET_A", url: "https://alpha.example" },
|
||||
{ id: "TARGET_B", url: "https://beta.example" },
|
||||
],
|
||||
} as Response);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{ id: "TARGET_A", url: "https://alpha.example" },
|
||||
{ id: "TARGET_B", url: "https://beta.example" },
|
||||
]),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const resolved = await getPageForTargetId({
|
||||
@@ -180,13 +182,15 @@ describe("pw-session getPageForTargetId", () => {
|
||||
});
|
||||
const [, pageB] = pages;
|
||||
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => [
|
||||
{ id: "TARGET_A", url: "https://alpha.example" },
|
||||
{ id: "TARGET_B", url: "https://beta.example" },
|
||||
],
|
||||
} as Response);
|
||||
const fetchSpy = vi.spyOn(globalThis, "fetch").mockResolvedValue(
|
||||
new Response(
|
||||
JSON.stringify([
|
||||
{ id: "TARGET_A", url: "https://alpha.example" },
|
||||
{ id: "TARGET_B", url: "https://beta.example" },
|
||||
]),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
),
|
||||
);
|
||||
|
||||
try {
|
||||
const resolved = await getPageForTargetId({
|
||||
|
||||
@@ -39,9 +39,8 @@ describe("browser tab routes attachOnly loopback profiles", () => {
|
||||
|
||||
const fetchMock = vi.fn(async (url: unknown) => {
|
||||
expect(String(url)).toBe("http://127.0.0.1:9222/json/list");
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => [
|
||||
return new Response(
|
||||
JSON.stringify([
|
||||
{
|
||||
id: "PAGE-1",
|
||||
title: "WordPress",
|
||||
@@ -49,8 +48,9 @@ describe("browser tab routes attachOnly loopback profiles", () => {
|
||||
webSocketDebuggerUrl: "ws://127.0.0.1:9222/devtools/page/PAGE-1",
|
||||
type: "page",
|
||||
},
|
||||
],
|
||||
} as unknown as Response;
|
||||
]),
|
||||
{ headers: { "content-type": "application/json" } },
|
||||
);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
|
||||
@@ -551,15 +551,12 @@ export function makeResponse(
|
||||
body: unknown,
|
||||
init?: { ok?: boolean; status?: number; text?: string },
|
||||
): Response {
|
||||
const ok = init?.ok ?? true;
|
||||
const status = init?.status ?? 200;
|
||||
const text = init?.text ?? "";
|
||||
return {
|
||||
ok,
|
||||
const status = init?.status ?? (init?.ok === false ? 500 : 200);
|
||||
const responseBody = init?.text ?? JSON.stringify(body);
|
||||
return new Response(responseBody, {
|
||||
status,
|
||||
json: async () => body,
|
||||
text: async () => text,
|
||||
} as unknown as Response;
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function mockClearAll(obj: Record<string, { mockClear: () => unknown }>) {
|
||||
|
||||
@@ -13,14 +13,39 @@ type FetchWithPreconnect = {
|
||||
__openclawAcceptsDispatcher: true;
|
||||
};
|
||||
|
||||
type FetchFunction = (...args: unknown[]) => unknown;
|
||||
|
||||
// Bounded CDP readers consume arrayBuffer(); keep lightweight json-only test
|
||||
// responses compatible without weakening the production response boundary.
|
||||
function addArrayBufferFallback(response: unknown): unknown {
|
||||
if (!response || typeof response !== "object") {
|
||||
return response;
|
||||
}
|
||||
const partial = response as {
|
||||
arrayBuffer?: () => Promise<ArrayBuffer>;
|
||||
json?: () => Promise<unknown>;
|
||||
};
|
||||
if (typeof partial.arrayBuffer === "function" || typeof partial.json !== "function") {
|
||||
return response;
|
||||
}
|
||||
partial.arrayBuffer = async () =>
|
||||
new TextEncoder().encode(JSON.stringify(await partial.json!())).buffer;
|
||||
return response;
|
||||
}
|
||||
|
||||
/** Adds Browser test preconnect metadata to a fetch-like function. */
|
||||
export function withBrowserFetchPreconnect<T extends typeof fetch>(fn: T): T & FetchWithPreconnect;
|
||||
export function withBrowserFetchPreconnect<T extends object>(
|
||||
fn: T,
|
||||
): T & FetchWithPreconnect & typeof fetch;
|
||||
export function withBrowserFetchPreconnect(fn: object) {
|
||||
return Object.assign(fn, {
|
||||
const fetchFn = Object.assign(fn as FetchFunction, {
|
||||
preconnect: (_url: string | URL, _options?: FetchPreconnectOptions) => {},
|
||||
__openclawAcceptsDispatcher: true as const,
|
||||
});
|
||||
return new Proxy(fetchFn, {
|
||||
async apply(target, thisArg, args) {
|
||||
return addArrayBufferFallback(await Reflect.apply(target, thisArg, args));
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -860,43 +860,61 @@ describe("maybeCompactCodexAppServerSession", () => {
|
||||
rejectInterrupt: true,
|
||||
});
|
||||
const sessionFile = await writeTestBinding();
|
||||
const nativeSetTimeout = globalThis.setTimeout;
|
||||
let triggerCompletionTimeout: (() => void) | undefined;
|
||||
const setTimeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockImplementation((callback, delay, ...args) => {
|
||||
if (delay === 1_000 && !triggerCompletionTimeout) {
|
||||
triggerCompletionTimeout = () => callback(...args);
|
||||
return nativeSetTimeout(() => undefined, 60_000);
|
||||
}
|
||||
return nativeSetTimeout(callback, delay, ...args);
|
||||
});
|
||||
|
||||
const pendingResult = maybeCompactCodexAppServerSessionImpl(
|
||||
{
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } },
|
||||
},
|
||||
{
|
||||
clientFactory: async () => fake.client,
|
||||
nativeInterruptGraceMs: 10,
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => expect(fake.request).toHaveBeenCalledOnce());
|
||||
fake.emit({
|
||||
method: "turn/started",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turn: { id: "compact-turn-configured", threadId: "thread-1", status: "inProgress" },
|
||||
},
|
||||
});
|
||||
try {
|
||||
const pendingResult = maybeCompactCodexAppServerSessionImpl(
|
||||
{
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
trigger: "manual",
|
||||
config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } },
|
||||
},
|
||||
{
|
||||
clientFactory: async () => fake.client,
|
||||
nativeInterruptGraceMs: 10,
|
||||
},
|
||||
);
|
||||
await vi.waitFor(() => expect(fake.request).toHaveBeenCalledOnce());
|
||||
fake.emit({
|
||||
method: "turn/started",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turn: { id: "compact-turn-configured", threadId: "thread-1", status: "inProgress" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(pendingResult).resolves.toMatchObject({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "codex app-server compaction did not reach terminal state after interruption",
|
||||
});
|
||||
expect(fake.request).toHaveBeenCalledWith(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: "thread-1",
|
||||
turnId: "compact-turn-configured",
|
||||
},
|
||||
{ timeoutMs: 10 },
|
||||
);
|
||||
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1_000);
|
||||
expect(triggerCompletionTimeout).toBeDefined();
|
||||
triggerCompletionTimeout?.();
|
||||
expect(fake.request).toHaveBeenCalledWith(
|
||||
"turn/interrupt",
|
||||
{
|
||||
threadId: "thread-1",
|
||||
turnId: "compact-turn-configured",
|
||||
},
|
||||
{ timeoutMs: 10 },
|
||||
);
|
||||
await expect(pendingResult).resolves.toMatchObject({
|
||||
ok: false,
|
||||
compacted: false,
|
||||
reason: "codex app-server compaction did not reach terminal state after interruption",
|
||||
});
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("detaches a remote thread when its interrupted turn cannot be confirmed", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import path from "node:path";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { createCodexWebSearchProvider as createContractCodexWebSearchProvider } from "../web-search-contract-api.js";
|
||||
import type { CodexAppServerClient } from "./app-server/client.js";
|
||||
import type { CodexAppServerStartOptions } from "./app-server/config.js";
|
||||
@@ -180,6 +180,12 @@ function createConfig(): OpenClawConfig {
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// Execution cases share this lazy runtime. Import it once so the first case
|
||||
// does not absorb module initialization that every later case reuses.
|
||||
await import("./web-search-provider.runtime.js");
|
||||
});
|
||||
|
||||
describe("codex web search provider", () => {
|
||||
it("registers a selectable keyless provider contract", () => {
|
||||
const provider = createContractCodexWebSearchProvider();
|
||||
|
||||
@@ -2,10 +2,25 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
|
||||
type LegacyStateMigrationsApi = typeof import("./legacy-state-migrations-api.js");
|
||||
|
||||
const migrationDetector =
|
||||
(() => []) satisfies LegacyStateMigrationsApi["detectDiscordLegacyStateMigrations"];
|
||||
const setupEntryLoadOptions = {
|
||||
createLoaderForTest: (() => (specifier: string) => {
|
||||
expect(specifier).toMatch(/[\\/]legacy-state-migrations-api\.[jt]s$/u);
|
||||
return {
|
||||
detectDiscordLegacyStateMigrations: migrationDetector,
|
||||
} satisfies Pick<LegacyStateMigrationsApi, "detectDiscordLegacyStateMigrations">;
|
||||
}) as never,
|
||||
};
|
||||
|
||||
describe("discord setup entry", () => {
|
||||
it("exposes legacy state migration detector through setup entry metadata", () => {
|
||||
it("resolves the legacy state migration detector through the setup entry", () => {
|
||||
expect(setupEntry.kind).toBe("bundled-channel-setup-entry");
|
||||
expect(setupEntry.features).toEqual({ legacyStateMigrations: true });
|
||||
expect(setupEntry.loadLegacyStateMigrationDetector?.()).toBeTypeOf("function");
|
||||
expect(setupEntry.loadLegacyStateMigrationDetector?.(setupEntryLoadOptions)).toBe(
|
||||
migrationDetector,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -307,7 +307,7 @@ describe("Google image-generation provider", () => {
|
||||
|
||||
it("accepts valid multi-image inline JSON responses above the generic provider JSON cap", async () => {
|
||||
mockGoogleApiKeyAuth();
|
||||
const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1);
|
||||
const imageBytes = Buffer.alloc(4 * 1024 * 1024, 1);
|
||||
const imagePayload = imageBytes.toString("base64");
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
|
||||
@@ -6,8 +6,23 @@ import type {
|
||||
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeAntigravityModelId, normalizeGoogleModelId } from "./model-id.js";
|
||||
import {
|
||||
isGoogleGenerativeAiApi,
|
||||
isGoogleVertexBaseUrl,
|
||||
normalizeGoogleApiBaseUrl,
|
||||
normalizeGoogleGenerativeAiBaseUrl,
|
||||
} from "./src/google-api-base-url.js";
|
||||
import { isGoogleGemini3ProModel, isGoogleGemini3ThinkingLevelModel } from "./thinking-api.js";
|
||||
|
||||
export {
|
||||
DEFAULT_GOOGLE_API_BASE_URL,
|
||||
isGoogleGenerativeAiApi,
|
||||
isGoogleVertexBaseUrl,
|
||||
isGoogleVertexHostname,
|
||||
normalizeGoogleApiBaseUrl,
|
||||
normalizeGoogleGenerativeAiBaseUrl,
|
||||
} from "./src/google-api-base-url.js";
|
||||
|
||||
type GoogleApiCarrier = {
|
||||
api?: string | null;
|
||||
};
|
||||
@@ -17,100 +32,8 @@ type GoogleProviderConfigLike = GoogleApiCarrier & {
|
||||
models?: ReadonlyArray<GoogleApiCarrier | null | undefined> | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
||||
const GOOGLE_MODEL_ID_PROVIDERS = new Set(["google", "google-gemini-cli", "google-vertex"]);
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isCanonicalGoogleApiOriginShorthand(value: string): boolean {
|
||||
return /^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(value);
|
||||
}
|
||||
|
||||
function isGoogleGenerativeAiUrl(url: URL): boolean {
|
||||
return (
|
||||
url.protocol === "https:" && url.hostname.toLowerCase() === "generativelanguage.googleapis.com"
|
||||
);
|
||||
}
|
||||
|
||||
function stripUrlUserInfo(url: URL): void {
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
}
|
||||
|
||||
const GOOGLE_VERTEX_HOST = "aiplatform.googleapis.com";
|
||||
const GOOGLE_VERTEX_REGION_HOST_SUFFIX = "-aiplatform.googleapis.com";
|
||||
const GOOGLE_VERTEX_MULTI_REGION_HOSTS = new Set([
|
||||
"aiplatform.eu.rep.googleapis.com",
|
||||
"aiplatform.us.rep.googleapis.com",
|
||||
]);
|
||||
|
||||
export function isGoogleVertexHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase();
|
||||
return (
|
||||
normalized === GOOGLE_VERTEX_HOST ||
|
||||
normalized.endsWith(GOOGLE_VERTEX_REGION_HOST_SUFFIX) ||
|
||||
GOOGLE_VERTEX_MULTI_REGION_HOSTS.has(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function isGoogleVertexBaseUrl(baseUrl?: string | null): boolean {
|
||||
const raw = normalizeOptionalString(baseUrl);
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return isGoogleVertexHostname(new URL(raw).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
|
||||
const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL);
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
stripUrlUserInfo(url);
|
||||
if (isGoogleGenerativeAiUrl(url)) {
|
||||
const normalizedPath = trimTrailingSlashes(url.pathname || "");
|
||||
url.pathname = normalizedPath || "/v1beta";
|
||||
}
|
||||
return trimTrailingSlashes(url.toString());
|
||||
} catch {
|
||||
if (isCanonicalGoogleApiOriginShorthand(raw)) {
|
||||
return DEFAULT_GOOGLE_API_BASE_URL;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function isGoogleGenerativeAiApi(api?: string | null): boolean {
|
||||
return api === "google-generative-ai";
|
||||
}
|
||||
|
||||
export function normalizeGoogleGenerativeAiBaseUrl(baseUrl?: string): string | undefined {
|
||||
if (!baseUrl) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const normalized = normalizeGoogleApiBaseUrl(baseUrl);
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
stripUrlUserInfo(url);
|
||||
if (isGoogleGenerativeAiUrl(url)) {
|
||||
url.pathname = trimTrailingSlashes(url.pathname || "").replace(/\/openai$/i, "") || "/v1beta";
|
||||
return trimTrailingSlashes(url.toString());
|
||||
}
|
||||
} catch {
|
||||
// `normalizeGoogleApiBaseUrl` already returned the best-effort input form.
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
export function resolveGoogleGenerativeAiTransport<TApi extends string | null | undefined>(params: {
|
||||
provider?: string;
|
||||
api: TApi;
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
isRecord,
|
||||
normalizeOptionalString as trimToUndefined,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeGoogleApiBaseUrl } from "../provider-policy.js";
|
||||
import { normalizeGoogleApiBaseUrl } from "./google-api-base-url.js";
|
||||
|
||||
const DEFAULT_GEMINI_WEB_SEARCH_MODEL = "gemini-2.5-flash";
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
// Lightweight Google API URL normalization shared by provider contract surfaces.
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
export const DEFAULT_GOOGLE_API_BASE_URL = "https://generativelanguage.googleapis.com/v1beta";
|
||||
|
||||
function trimTrailingSlashes(value: string): string {
|
||||
return value.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function isCanonicalGoogleApiOriginShorthand(value: string): boolean {
|
||||
return /^https:\/\/generativelanguage\.googleapis\.com\/?$/i.test(value);
|
||||
}
|
||||
|
||||
function isGoogleGenerativeAiUrl(url: URL): boolean {
|
||||
return (
|
||||
url.protocol === "https:" && url.hostname.toLowerCase() === "generativelanguage.googleapis.com"
|
||||
);
|
||||
}
|
||||
|
||||
function stripUrlUserInfo(url: URL): void {
|
||||
url.username = "";
|
||||
url.password = "";
|
||||
}
|
||||
|
||||
const GOOGLE_VERTEX_HOST = "aiplatform.googleapis.com";
|
||||
const GOOGLE_VERTEX_REGION_HOST_SUFFIX = "-aiplatform.googleapis.com";
|
||||
const GOOGLE_VERTEX_MULTI_REGION_HOSTS = new Set([
|
||||
"aiplatform.eu.rep.googleapis.com",
|
||||
"aiplatform.us.rep.googleapis.com",
|
||||
]);
|
||||
|
||||
export function isGoogleVertexHostname(hostname: string): boolean {
|
||||
const normalized = hostname.toLowerCase();
|
||||
return (
|
||||
normalized === GOOGLE_VERTEX_HOST ||
|
||||
normalized.endsWith(GOOGLE_VERTEX_REGION_HOST_SUFFIX) ||
|
||||
GOOGLE_VERTEX_MULTI_REGION_HOSTS.has(normalized)
|
||||
);
|
||||
}
|
||||
|
||||
export function isGoogleVertexBaseUrl(baseUrl?: string | null): boolean {
|
||||
const raw = normalizeOptionalString(baseUrl);
|
||||
if (!raw) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return isGoogleVertexHostname(new URL(raw).hostname);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function normalizeGoogleApiBaseUrl(baseUrl?: string): string {
|
||||
const raw = trimTrailingSlashes(normalizeOptionalString(baseUrl) || DEFAULT_GOOGLE_API_BASE_URL);
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
url.hash = "";
|
||||
url.search = "";
|
||||
stripUrlUserInfo(url);
|
||||
if (isGoogleGenerativeAiUrl(url)) {
|
||||
const normalizedPath = trimTrailingSlashes(url.pathname || "");
|
||||
url.pathname = normalizedPath || "/v1beta";
|
||||
}
|
||||
return trimTrailingSlashes(url.toString());
|
||||
} catch {
|
||||
if (isCanonicalGoogleApiOriginShorthand(raw)) {
|
||||
return DEFAULT_GOOGLE_API_BASE_URL;
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
}
|
||||
|
||||
export function isGoogleGenerativeAiApi(api?: string | null): boolean {
|
||||
return api === "google-generative-ai";
|
||||
}
|
||||
|
||||
export function normalizeGoogleGenerativeAiBaseUrl(baseUrl?: string): string | undefined {
|
||||
if (!baseUrl) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const normalized = normalizeGoogleApiBaseUrl(baseUrl);
|
||||
try {
|
||||
const url = new URL(normalized);
|
||||
stripUrlUserInfo(url);
|
||||
if (isGoogleGenerativeAiUrl(url)) {
|
||||
url.pathname = trimTrailingSlashes(url.pathname || "").replace(/\/openai$/i, "") || "/v1beta";
|
||||
return trimTrailingSlashes(url.toString());
|
||||
}
|
||||
} catch {
|
||||
// `normalizeGoogleApiBaseUrl` already returned the best-effort input form.
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
@@ -23,6 +23,7 @@ const getIMessageSetupStatus = createPluginSetupWizardStatus({
|
||||
const spawnMock = vi.hoisted(() => vi.fn());
|
||||
const setupToolsMocks = vi.hoisted(() => ({
|
||||
detectBinary: vi.fn(async () => false),
|
||||
formatDocsLink: vi.fn((path: string) => path),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/setup-tools", async (importOriginal) => ({
|
||||
|
||||
@@ -3217,45 +3217,31 @@ describe("short-term promotion", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses score tie-breakers when capping stores with invalid timestamps", async () => {
|
||||
await withTempWorkspace(async (workspaceDir) => {
|
||||
const maxEntries = testing.SHORT_TERM_RECALL_MAX_ENTRIES;
|
||||
await testing.writeRawRecallStore(workspaceDir, {
|
||||
version: 1,
|
||||
updatedAt: "2026-04-04T00:00:00.000Z",
|
||||
entries: Object.fromEntries(
|
||||
Array.from({ length: maxEntries + 3 }, (_, index) => [
|
||||
`entry-${index}`,
|
||||
{
|
||||
key: `entry-${index}`,
|
||||
path: "memory/2026-04-01.md",
|
||||
startLine: index + 1,
|
||||
endLine: index + 1,
|
||||
source: "memory",
|
||||
snippet: `Invalid timestamp recall ${index}`,
|
||||
recallCount: 1,
|
||||
dailyCount: 0,
|
||||
groundedCount: 0,
|
||||
totalScore: index,
|
||||
maxScore: 0.75,
|
||||
firstRecalledAt: "not-a-date",
|
||||
lastRecalledAt: "not-a-date",
|
||||
queryHashes: [`q-${index}`],
|
||||
recallDays: ["2026-04-01"],
|
||||
conceptTags: [],
|
||||
},
|
||||
]),
|
||||
),
|
||||
});
|
||||
it("uses score tie-breakers when retention timestamps are invalid", () => {
|
||||
const entry = {
|
||||
key: "lower-score",
|
||||
path: "memory/2026-04-01.md",
|
||||
startLine: 1,
|
||||
endLine: 1,
|
||||
source: "memory" as const,
|
||||
snippet: "Invalid timestamp recall",
|
||||
recallCount: 1,
|
||||
dailyCount: 0,
|
||||
groundedCount: 0,
|
||||
totalScore: 1,
|
||||
maxScore: 0.75,
|
||||
firstRecalledAt: "not-a-date",
|
||||
lastRecalledAt: "not-a-date",
|
||||
queryHashes: ["q"],
|
||||
recallDays: ["2026-04-01"],
|
||||
conceptTags: [],
|
||||
};
|
||||
const higherScoreEntry = { ...entry, key: "higher-score", totalScore: 2 };
|
||||
|
||||
const repair = await repairShortTermPromotionArtifacts({ workspaceDir });
|
||||
|
||||
expect(repair.removedOverflowEntries).toBe(3);
|
||||
const entries = await readRecallStoreEntries(workspaceDir);
|
||||
expect(Object.keys(entries)).toHaveLength(maxEntries);
|
||||
expect(entries["entry-0"]).toBeUndefined();
|
||||
expect(entries[`entry-${maxEntries + 2}`]).toBeDefined();
|
||||
});
|
||||
expect([entry, higherScoreEntry].toSorted(testing.compareShortTermRecallRetention)).toEqual([
|
||||
higherScoreEntry,
|
||||
entry,
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects long contaminated legacy recall entries before truncating snippets", async () => {
|
||||
|
||||
@@ -2913,6 +2913,7 @@ export const testing = {
|
||||
deriveConceptTags,
|
||||
calculateConsolidationComponent,
|
||||
calculatePhaseSignalBoost,
|
||||
compareShortTermRecallRetention,
|
||||
buildClaimHash,
|
||||
totalSignalCountForEntry,
|
||||
isContaminatedDreamingSnippet,
|
||||
|
||||
@@ -350,7 +350,7 @@ describe("OpenAI embedding batch output", () => {
|
||||
],
|
||||
wait: true,
|
||||
concurrency: 1,
|
||||
pollIntervalMs: 1000,
|
||||
pollIntervalMs: 1,
|
||||
timeoutMs: 60_000,
|
||||
}),
|
||||
).rejects.toThrow(/openai\.batch-status/);
|
||||
@@ -630,7 +630,7 @@ describe("OpenAI embedding batch output", () => {
|
||||
],
|
||||
wait: true,
|
||||
concurrency: 1,
|
||||
pollIntervalMs: 1000,
|
||||
pollIntervalMs: 1,
|
||||
timeoutMs: 60_000,
|
||||
}),
|
||||
).rejects.toThrow(/openai batch status failed: 400 batch status unavailable/);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
runQaManualLane,
|
||||
@@ -89,6 +89,7 @@ import {
|
||||
} from "./cli.runtime.js";
|
||||
import { QaSuiteInfraError } from "./errors.js";
|
||||
import { QA_EVIDENCE_FILENAME } from "./evidence-summary.js";
|
||||
import { loadNonYamlScenarioRefs } from "./live-transports/shared/live-transport-scenarios.js";
|
||||
import { runQaTelegramCommand } from "./live-transports/telegram/cli.runtime.js";
|
||||
import { defaultQaModelForMode as defaultQaProviderModelForMode } from "./model-selection.js";
|
||||
import type { QaProviderModeInput } from "./run-config.js";
|
||||
@@ -1636,11 +1637,18 @@ describe("qa cli runtime", () => {
|
||||
).rejects.toThrow("--token-efficiency requires --runtime-axis.");
|
||||
});
|
||||
|
||||
it("prints a markdown coverage report from scenario metadata", async () => {
|
||||
await runQaCoverageReportCommand({ repoRoot: process.cwd() });
|
||||
describe("coverage inventory command", () => {
|
||||
beforeAll(async () => {
|
||||
listTelegramQaScenarioCatalog.mockReturnValue([]);
|
||||
await loadNonYamlScenarioRefs();
|
||||
});
|
||||
|
||||
expectWriteContains(stdoutWrite, "# QA Coverage Inventory");
|
||||
expectWriteContains(stdoutWrite, "memory.recall");
|
||||
it("prints a markdown report from scenario metadata", async () => {
|
||||
await runQaCoverageReportCommand({ repoRoot: process.cwd() });
|
||||
|
||||
expectWriteContains(stdoutWrite, "# QA Coverage Inventory");
|
||||
expectWriteContains(stdoutWrite, "memory.recall");
|
||||
});
|
||||
});
|
||||
|
||||
it("prints a focused scenario match report from coverage metadata", async () => {
|
||||
|
||||
@@ -127,6 +127,7 @@ describe("qa runner model catalog", () => {
|
||||
const runPromise = loadQaRunnerModelOptions({
|
||||
repoRoot,
|
||||
signal: controller.signal,
|
||||
abortKillGraceMs: 100,
|
||||
});
|
||||
|
||||
await waitForFile(pidPath, 2_000);
|
||||
|
||||
@@ -173,7 +173,12 @@ async function waitForProcessTreeExit(pid: number | undefined, timeoutMs: number
|
||||
return !processTreeIsAlive(pid);
|
||||
}
|
||||
|
||||
export async function loadQaRunnerModelOptions(params: { repoRoot: string; signal?: AbortSignal }) {
|
||||
export async function loadQaRunnerModelOptions(params: {
|
||||
repoRoot: string;
|
||||
signal?: AbortSignal;
|
||||
abortKillGraceMs?: number;
|
||||
}) {
|
||||
const abortKillGraceMs = Math.max(1, params.abortKillGraceMs ?? CATALOG_ABORT_KILL_GRACE_MS);
|
||||
const tempRoot = await fs.mkdtemp(
|
||||
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-qa-model-catalog-"),
|
||||
);
|
||||
@@ -240,9 +245,7 @@ export async function loadQaRunnerModelOptions(params: { repoRoot: string; signa
|
||||
const finishAbortedCatalogLoad = async () => {
|
||||
cleanupAbortListener();
|
||||
const graceRemainingMs =
|
||||
forceKillAt === undefined
|
||||
? CATALOG_ABORT_KILL_GRACE_MS
|
||||
: Math.max(0, forceKillAt - Date.now());
|
||||
forceKillAt === undefined ? abortKillGraceMs : Math.max(0, forceKillAt - Date.now());
|
||||
if (graceRemainingMs > 0) {
|
||||
await waitForProcessTreeExit(child.pid, graceRemainingMs);
|
||||
}
|
||||
@@ -252,18 +255,18 @@ export async function loadQaRunnerModelOptions(params: { repoRoot: string; signa
|
||||
}
|
||||
if (processTreeIsAlive(child.pid)) {
|
||||
killProcessTree(child.pid, "SIGKILL");
|
||||
await waitForProcessTreeExit(child.pid, CATALOG_ABORT_KILL_GRACE_MS);
|
||||
await waitForProcessTreeExit(child.pid, abortKillGraceMs);
|
||||
}
|
||||
forceKillAt = undefined;
|
||||
};
|
||||
const abortCatalogLoad = () => {
|
||||
aborted = true;
|
||||
killProcessTree(child.pid, "SIGTERM");
|
||||
forceKillAt = Date.now() + CATALOG_ABORT_KILL_GRACE_MS;
|
||||
forceKillAt = Date.now() + abortKillGraceMs;
|
||||
forceKillTimer ??= setTimeout(() => {
|
||||
forceKillAt = undefined;
|
||||
killProcessTree(child.pid, "SIGKILL");
|
||||
}, CATALOG_ABORT_KILL_GRACE_MS);
|
||||
}, abortKillGraceMs);
|
||||
forceKillTimer.unref();
|
||||
};
|
||||
if (aborted) {
|
||||
|
||||
@@ -28,10 +28,11 @@ afterEach(async () => {
|
||||
}
|
||||
});
|
||||
|
||||
async function startMockServer() {
|
||||
async function startMockServer(params?: { finalOnlyMarkerPauseMs?: number }) {
|
||||
const server = await startQaMockOpenAiServer({
|
||||
host: "127.0.0.1",
|
||||
port: 0,
|
||||
...params,
|
||||
});
|
||||
cleanups.push(async () => {
|
||||
await server.stop();
|
||||
@@ -336,6 +337,37 @@ describe("qa mock openai server", () => {
|
||||
expect(text.match(/[.!?]+(?:\s|$)/g)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("keeps final-only marker preview deltas separate from the final answer", async () => {
|
||||
const server = await startMockServer({ finalOnlyMarkerPauseMs: 1 });
|
||||
const response = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
input: [
|
||||
makeUserInput(
|
||||
"Final-only marker streaming QA check. Reply exactly: QA-FINAL-ONLY-STREAMING-OK",
|
||||
),
|
||||
],
|
||||
}),
|
||||
});
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
const responseBody = await response.text();
|
||||
const deltaText = responseBody
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("data: {"))
|
||||
.map((line) => JSON.parse(line.slice("data: ".length)) as { type?: string; delta?: string })
|
||||
.filter((event) => event.type === "response.output_text.delta")
|
||||
.map((event) => event.delta ?? "")
|
||||
.join("");
|
||||
expect(deltaText).toBe("QA streaming preview in progress");
|
||||
expect(deltaText).not.toContain("QA-FINAL-ONLY-STREAMING-OK");
|
||||
expect(responseBody).toContain('"text":"QA-FINAL-ONLY-STREAMING-OK"');
|
||||
});
|
||||
|
||||
it("emits deterministic text deltas for generic streaming QA prompts", async () => {
|
||||
const server = await startMockServer();
|
||||
|
||||
@@ -355,33 +387,6 @@ describe("qa mock openai server", () => {
|
||||
expect(quietBody).toContain('"phase":"final_answer"');
|
||||
expect(quietBody).toContain("QA_STREAMING_OK");
|
||||
|
||||
const finalOnlyMarkerResponse = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
stream: true,
|
||||
input: [
|
||||
makeUserInput(
|
||||
"Final-only marker streaming QA check. Reply exactly: QA-FINAL-ONLY-STREAMING-OK",
|
||||
),
|
||||
],
|
||||
}),
|
||||
});
|
||||
expect(finalOnlyMarkerResponse.status).toBe(200);
|
||||
const finalOnlyMarkerBody = await finalOnlyMarkerResponse.text();
|
||||
const finalOnlyMarkerDeltaText = finalOnlyMarkerBody
|
||||
.split("\n")
|
||||
.filter((line) => line.startsWith("data: {"))
|
||||
.map((line) => JSON.parse(line.slice("data: ".length)) as { type?: string; delta?: string })
|
||||
.filter((event) => event.type === "response.output_text.delta")
|
||||
.map((event) => event.delta ?? "")
|
||||
.join("");
|
||||
expect(finalOnlyMarkerDeltaText).toBe("QA streaming preview in progress");
|
||||
expect(finalOnlyMarkerDeltaText).not.toContain("QA-FINAL-ONLY-STREAMING-OK");
|
||||
expect(finalOnlyMarkerBody).toContain('"text":"QA-FINAL-ONLY-STREAMING-OK"');
|
||||
|
||||
const partialResponse = await fetch(`${server.baseUrl}/v1/responses`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
|
||||
@@ -3639,8 +3639,13 @@ async function buildMessagesPayload(
|
||||
return { events, input, extracted, responseBody, streamEvents, model: normalizedModel };
|
||||
}
|
||||
|
||||
export async function startQaMockOpenAiServer(params?: { host?: string; port?: number }) {
|
||||
export async function startQaMockOpenAiServer(params?: {
|
||||
host?: string;
|
||||
port?: number;
|
||||
finalOnlyMarkerPauseMs?: number;
|
||||
}) {
|
||||
const host = params?.host ?? "127.0.0.1";
|
||||
const finalOnlyMarkerPauseMs = params?.finalOnlyMarkerPauseMs ?? 1_500;
|
||||
const scenarioState: MockScenarioState = {
|
||||
anthropicThinkingErrorPhase: 0,
|
||||
subagentFanoutPhase: 0,
|
||||
@@ -3791,7 +3796,7 @@ export async function startQaMockOpenAiServer(params?: { host?: string; port?: n
|
||||
return;
|
||||
}
|
||||
if (QA_FINAL_ONLY_MARKER_STREAMING_PROMPT_RE.test(allInputText)) {
|
||||
await writeSseWithPreviewPause(res, events, 1_500);
|
||||
await writeSseWithPreviewPause(res, events, finalOnlyMarkerPauseMs);
|
||||
} else {
|
||||
writeSse(res, events);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// QA Lab web_search metadata shared by runtime and contract-only loading.
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
|
||||
export const QA_LAB_WEB_SEARCH_PROVIDER_ID = "qa-lab-search";
|
||||
export const QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY = "OPENCLAW_QA_WEB_SEARCH_DENIED_INPUT";
|
||||
|
||||
export function createQaLabWebSearchProviderBase(): Omit<WebSearchProviderPlugin, "createTool"> {
|
||||
return {
|
||||
id: QA_LAB_WEB_SEARCH_PROVIDER_ID,
|
||||
label: "QA Lab Search",
|
||||
hint: "Deterministic QA-only web search fixture",
|
||||
requiresCredential: false,
|
||||
envVars: [],
|
||||
placeholder: "(no key needed)",
|
||||
signupUrl: "https://docs.openclaw.ai/concepts/qa-e2e-automation",
|
||||
docsUrl: "https://docs.openclaw.ai/concepts/qa-e2e-automation",
|
||||
credentialPath: "",
|
||||
inactiveSecretPaths: [],
|
||||
getCredentialValue: () => undefined,
|
||||
setCredentialValue: (searchConfigTarget, value) => {
|
||||
void searchConfigTarget;
|
||||
void value;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -7,9 +7,15 @@ import {
|
||||
wrapWebContent,
|
||||
type WebSearchProviderPlugin,
|
||||
} from "openclaw/plugin-sdk/provider-web-search";
|
||||
import {
|
||||
createQaLabWebSearchProviderBase,
|
||||
QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY,
|
||||
} from "./qa-web-search-provider.shared.js";
|
||||
|
||||
export const QA_LAB_WEB_SEARCH_PROVIDER_ID = "qa-lab-search";
|
||||
export const QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY = "OPENCLAW_QA_WEB_SEARCH_DENIED_INPUT";
|
||||
export {
|
||||
QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY,
|
||||
QA_LAB_WEB_SEARCH_PROVIDER_ID,
|
||||
} from "./qa-web-search-provider.shared.js";
|
||||
|
||||
const QaLabWebSearchSchema = {
|
||||
type: "object",
|
||||
@@ -44,21 +50,7 @@ function buildQaLabSearchResult(query: string, index: number) {
|
||||
|
||||
export function createQaLabWebSearchProvider(): WebSearchProviderPlugin {
|
||||
return {
|
||||
id: QA_LAB_WEB_SEARCH_PROVIDER_ID,
|
||||
label: "QA Lab Search",
|
||||
hint: "Deterministic QA-only web search fixture",
|
||||
requiresCredential: false,
|
||||
envVars: [],
|
||||
placeholder: "(no key needed)",
|
||||
signupUrl: "https://docs.openclaw.ai/concepts/qa-e2e-automation",
|
||||
docsUrl: "https://docs.openclaw.ai/concepts/qa-e2e-automation",
|
||||
credentialPath: "",
|
||||
inactiveSecretPaths: [],
|
||||
getCredentialValue: () => undefined,
|
||||
setCredentialValue: (searchConfigTarget, value) => {
|
||||
void searchConfigTarget;
|
||||
void value;
|
||||
},
|
||||
...createQaLabWebSearchProviderBase(),
|
||||
createTool: () => ({
|
||||
description:
|
||||
"Search a deterministic QA Lab fixture corpus. This provider is for QA runtime parity only and never calls the public web.",
|
||||
|
||||
@@ -290,14 +290,15 @@ describe("qa suite gateway helpers", () => {
|
||||
});
|
||||
const { env } = createConfigMutationEnv(gatewayCall);
|
||||
|
||||
await expect(
|
||||
patchConfig({
|
||||
env,
|
||||
patch: { tools: { deny: ["read"] } },
|
||||
replacePaths: ["tools.deny"],
|
||||
restartDelayMs: 0,
|
||||
}),
|
||||
).resolves.toEqual({ ok: true });
|
||||
const mutation = patchConfig({
|
||||
env,
|
||||
patch: { tools: { deny: ["read"] } },
|
||||
replacePaths: ["tools.deny"],
|
||||
restartDelayMs: 0,
|
||||
restartSettleBufferMs: 1,
|
||||
});
|
||||
|
||||
await expect(mutation).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(gatewayCall).toHaveBeenCalledWith(
|
||||
"config.patch",
|
||||
|
||||
@@ -76,10 +76,11 @@ async function waitForConfigRestartSettle(
|
||||
env: Pick<QaSuiteRuntimeEnv, "gateway" | "transport">,
|
||||
restartDelayMs = 1_000,
|
||||
timeoutMs = 60_000,
|
||||
settleBufferMs = 750,
|
||||
) {
|
||||
const startedAt = Date.now();
|
||||
const deadline = startedAt + timeoutMs;
|
||||
const readyAfterMs = restartDelayMs + 750;
|
||||
const readyAfterMs = restartDelayMs + settleBufferMs;
|
||||
let lastHealthError: unknown = null;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
@@ -244,6 +245,7 @@ async function runConfigMutation(params: {
|
||||
};
|
||||
note?: string;
|
||||
restartDelayMs?: number;
|
||||
restartSettleBufferMs?: number;
|
||||
replacePaths?: readonly string[];
|
||||
}) {
|
||||
const restartDelayMs = params.restartDelayMs ?? 1_000;
|
||||
@@ -271,7 +273,12 @@ async function runConfigMutation(params: {
|
||||
},
|
||||
{ timeoutMs },
|
||||
);
|
||||
await waitForConfigRestartSettle(params.env, restartDelayMs, timeoutMs);
|
||||
await waitForConfigRestartSettle(
|
||||
params.env,
|
||||
restartDelayMs,
|
||||
timeoutMs,
|
||||
params.restartSettleBufferMs,
|
||||
);
|
||||
return result;
|
||||
} catch (error) {
|
||||
if (isConfigHashConflict(error)) {
|
||||
@@ -292,7 +299,12 @@ async function runConfigMutation(params: {
|
||||
if (!isGatewayRestartRace(error)) {
|
||||
throw error;
|
||||
}
|
||||
await waitForConfigRestartSettle(params.env, restartDelayMs, timeoutMs);
|
||||
await waitForConfigRestartSettle(
|
||||
params.env,
|
||||
restartDelayMs,
|
||||
timeoutMs,
|
||||
params.restartSettleBufferMs,
|
||||
);
|
||||
const postRestartSnapshot = await readConfigSnapshot(params.env);
|
||||
if (isConfigMutationNoopForSnapshot(params.action, postRestartSnapshot.config, params.raw)) {
|
||||
return { ok: true, restarted: true };
|
||||
@@ -321,6 +333,7 @@ async function patchConfig(params: {
|
||||
};
|
||||
note?: string;
|
||||
restartDelayMs?: number;
|
||||
restartSettleBufferMs?: number;
|
||||
replacePaths?: readonly string[];
|
||||
}) {
|
||||
return await runConfigMutation({
|
||||
@@ -331,6 +344,7 @@ async function patchConfig(params: {
|
||||
deliveryContext: params.deliveryContext,
|
||||
note: params.note,
|
||||
restartDelayMs: params.restartDelayMs,
|
||||
restartSettleBufferMs: params.restartSettleBufferMs,
|
||||
replacePaths: params.replacePaths,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
// Qa Lab API module exposes the deterministic QA web_search contract.
|
||||
import type { WebSearchProviderPlugin } from "openclaw/plugin-sdk/provider-web-search-contract";
|
||||
import { createQaLabWebSearchProvider as createQaLabRuntimeWebSearchProvider } from "./src/qa-web-search-provider.js";
|
||||
import { createQaLabWebSearchProviderBase } from "./src/qa-web-search-provider.shared.js";
|
||||
|
||||
export function createQaLabWebSearchProvider(): WebSearchProviderPlugin {
|
||||
const { createTool: _createTool, ...provider } = createQaLabRuntimeWebSearchProvider();
|
||||
void _createTool;
|
||||
return {
|
||||
...provider,
|
||||
...createQaLabWebSearchProviderBase(),
|
||||
createTool: () => null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
// Telegram tests cover bot native commands.session meta plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
|
||||
import { resolveChunkMode } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
|
||||
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { TelegramNativeCommandDeps } from "./bot-native-command-deps.runtime.js";
|
||||
@@ -26,9 +29,13 @@ type DispatchReplyWithBufferedBlockDispatcherParams =
|
||||
type DispatchReplyWithBufferedBlockDispatcherResult = Awaited<
|
||||
ReturnType<DispatchReplyWithBufferedBlockDispatcherFn>
|
||||
>;
|
||||
type ResolveCommandArgMenuFn =
|
||||
typeof import("openclaw/plugin-sdk/command-auth-native").resolveCommandArgMenu;
|
||||
type DeliverRepliesFn = typeof import("./bot/delivery.js").deliverReplies;
|
||||
type DeliverRepliesParams = Parameters<DeliverRepliesFn>[0];
|
||||
type LoadModelCatalogFn = typeof import("openclaw/plugin-sdk/agent-runtime").loadModelCatalog;
|
||||
type ResolveDefaultModelForAgentFn =
|
||||
typeof import("openclaw/plugin-sdk/agent-runtime").resolveDefaultModelForAgent;
|
||||
type MatchPluginCommandFn = typeof import("./bot-native-commands.runtime.js").matchPluginCommand;
|
||||
|
||||
const dispatchReplyResult: DispatchReplyWithBufferedBlockDispatcherResult = {
|
||||
@@ -53,7 +60,7 @@ const sessionMocks = vi.hoisted(() => ({
|
||||
resolveStorePath: vi.fn(),
|
||||
}));
|
||||
const commandAuthMocks = vi.hoisted(() => ({
|
||||
resolveCommandArgMenu: vi.fn(),
|
||||
resolveCommandArgMenu: vi.fn<ResolveCommandArgMenuFn>(),
|
||||
}));
|
||||
const agentRuntimeMocks = vi.hoisted(() => ({
|
||||
loadModelCatalog: vi.fn<LoadModelCatalogFn>(async () => [
|
||||
@@ -64,6 +71,7 @@ const agentRuntimeMocks = vi.hoisted(() => ({
|
||||
reasoning: true,
|
||||
},
|
||||
]),
|
||||
resolveDefaultModelForAgent: vi.fn<ResolveDefaultModelForAgentFn>(),
|
||||
}));
|
||||
const pluginRuntimeMocks = vi.hoisted(() => ({
|
||||
executePluginCommand: vi.fn(async () => ({ text: "ok" })),
|
||||
@@ -194,18 +202,48 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", async () => {
|
||||
const actual = await vi.importActual<typeof import("openclaw/plugin-sdk/agent-runtime")>(
|
||||
"openclaw/plugin-sdk/agent-runtime",
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadModelCatalog: agentRuntimeMocks.loadModelCatalog,
|
||||
};
|
||||
});
|
||||
vi.mock("./bot-native-commands.runtime.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./bot-native-commands.runtime.js")>(
|
||||
"./bot-native-commands.runtime.js",
|
||||
agentRuntimeMocks.resolveDefaultModelForAgent.mockImplementation(
|
||||
actual.resolveDefaultModelForAgent,
|
||||
);
|
||||
return {
|
||||
...actual,
|
||||
loadModelCatalog: agentRuntimeMocks.loadModelCatalog,
|
||||
resolveDefaultModelForAgent: agentRuntimeMocks.resolveDefaultModelForAgent,
|
||||
};
|
||||
});
|
||||
vi.mock("./bot-native-commands.runtime.js", () => {
|
||||
return {
|
||||
ensureConfiguredBindingRouteReady: persistentBindingMocks.ensureConfiguredBindingRouteReady,
|
||||
executePluginCommand: pluginRuntimeMocks.executePluginCommand,
|
||||
finalizeInboundContext: vi.fn((ctx: unknown) => ctx),
|
||||
getAgentScopedMediaLocalRoots,
|
||||
getPluginCommandSpecs: vi.fn(() => []),
|
||||
getSessionEntry: sessionMocks.getSessionEntry,
|
||||
matchPluginCommand: pluginRuntimeMocks.matchPluginCommand,
|
||||
recordInboundSessionMetaSafe: vi.fn(
|
||||
async (params: {
|
||||
cfg: OpenClawConfig;
|
||||
agentId: string;
|
||||
sessionKey: string;
|
||||
ctx: unknown;
|
||||
onError?: (error: unknown) => void;
|
||||
}) => {
|
||||
const storePath = sessionMocks.resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.agentId,
|
||||
});
|
||||
try {
|
||||
await sessionMocks.recordSessionMetaFromInbound({
|
||||
storePath,
|
||||
sessionKey: params.sessionKey,
|
||||
ctx: params.ctx,
|
||||
});
|
||||
} catch (error) {
|
||||
params.onError?.(error);
|
||||
}
|
||||
},
|
||||
),
|
||||
resolveChunkMode,
|
||||
resolveThreadSessionKeys,
|
||||
dispatchReplyWithBufferedBlockDispatcher: replyMocks.dispatchReplyWithBufferedBlockDispatcher,
|
||||
};
|
||||
});
|
||||
@@ -556,52 +594,79 @@ function expectUnauthorizedNewCommandBlocked(sendMessage: ReturnType<typeof vi.f
|
||||
});
|
||||
}
|
||||
|
||||
function resetSessionMetaMocks() {
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockClear();
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(route, null),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear();
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
commandAuthMocks.resolveCommandArgMenu.mockClear().mockImplementation(({ command, args }) => {
|
||||
if (args?.raw || (args?.values && Object.keys(args.values).length > 0)) {
|
||||
return null;
|
||||
}
|
||||
const arg = command.args?.[0];
|
||||
if (!arg) {
|
||||
return null;
|
||||
}
|
||||
if (command.key === "think") {
|
||||
return {
|
||||
arg,
|
||||
choices: ["low", "medium", "high"].map((value) => ({ label: value, value })),
|
||||
};
|
||||
}
|
||||
if (command.key === "fast") {
|
||||
const choices = ["on", "off", "auto (30 sec)", "default", "status"];
|
||||
return {
|
||||
arg,
|
||||
choices: choices.map((value) => ({ label: value, value })),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([
|
||||
{
|
||||
provider: "openai",
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
reasoning: true,
|
||||
},
|
||||
]);
|
||||
sessionMocks.getSessionEntry.mockClear().mockReturnValue(undefined);
|
||||
sessionMocks.loadSessionStore.mockClear().mockReturnValue({});
|
||||
sessionMocks.getSessionEntry.mockImplementation(
|
||||
({ storePath, sessionKey }: { storePath: string; sessionKey: string }) =>
|
||||
sessionMocks.loadSessionStore(storePath)[sessionKey],
|
||||
);
|
||||
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
|
||||
sessionMocks.resolveSessionTranscriptLegacyFileTarget.mockClear().mockResolvedValue({
|
||||
agentId: "main",
|
||||
memoryKey: "transcript:main:sess-topic",
|
||||
sessionId: "sess-topic",
|
||||
sessionKey: "agent:main:telegram:group:-1001234567890:topic:42",
|
||||
sessionFile: "/tmp/openclaw-sessions/sess-topic-topic-42.jsonl",
|
||||
targetKind: "runtime-session",
|
||||
});
|
||||
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
|
||||
pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" });
|
||||
pluginRuntimeMocks.matchPluginCommand.mockClear().mockReturnValue(null);
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher
|
||||
.mockClear()
|
||||
.mockResolvedValue(dispatchReplyResult);
|
||||
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
|
||||
sessionBindingMocks.touch.mockReset();
|
||||
deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true });
|
||||
}
|
||||
|
||||
describe("registerTelegramNativeCommands — session metadata", () => {
|
||||
beforeAll(async () => {
|
||||
({ registerTelegramNativeCommands } = await import("./bot-native-commands.js"));
|
||||
const commandModule = await import("./bot-native-commands.js");
|
||||
registerTelegramNativeCommands = commandModule.registerTelegramNativeCommands;
|
||||
await commandModule.testing.loadNativeCommandRuntime();
|
||||
agentRuntimeMocks.resolveDefaultModelForAgent({ cfg: {}, agentId: "main" });
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockClear();
|
||||
persistentBindingMocks.resolveConfiguredBindingRoute.mockImplementation(({ route }) =>
|
||||
createConfiguredBindingRoute(route, null),
|
||||
);
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockClear();
|
||||
persistentBindingMocks.ensureConfiguredBindingRouteReady.mockResolvedValue({ ok: true });
|
||||
commandAuthMocks.resolveCommandArgMenu.mockClear();
|
||||
agentRuntimeMocks.loadModelCatalog.mockClear().mockResolvedValue([
|
||||
{
|
||||
provider: "openai",
|
||||
id: "gpt-5.5",
|
||||
name: "GPT-5.5",
|
||||
reasoning: true,
|
||||
},
|
||||
]);
|
||||
sessionMocks.getSessionEntry.mockClear().mockReturnValue(undefined);
|
||||
sessionMocks.loadSessionStore.mockClear().mockReturnValue({});
|
||||
sessionMocks.getSessionEntry.mockImplementation(
|
||||
({ storePath, sessionKey }: { storePath: string; sessionKey: string }) =>
|
||||
sessionMocks.loadSessionStore(storePath)[sessionKey],
|
||||
);
|
||||
sessionMocks.recordSessionMetaFromInbound.mockClear().mockResolvedValue(undefined);
|
||||
sessionMocks.resolveSessionTranscriptLegacyFileTarget.mockClear().mockResolvedValue({
|
||||
agentId: "main",
|
||||
memoryKey: "transcript:main:sess-topic",
|
||||
sessionId: "sess-topic",
|
||||
sessionKey: "agent:main:telegram:group:-1001234567890:topic:42",
|
||||
sessionFile: "/tmp/openclaw-sessions/sess-topic-topic-42.jsonl",
|
||||
targetKind: "runtime-session",
|
||||
});
|
||||
sessionMocks.resolveStorePath.mockClear().mockReturnValue("/tmp/openclaw-sessions.json");
|
||||
pluginRuntimeMocks.executePluginCommand.mockClear().mockResolvedValue({ text: "ok" });
|
||||
pluginRuntimeMocks.matchPluginCommand.mockClear().mockReturnValue(null);
|
||||
replyMocks.dispatchReplyWithBufferedBlockDispatcher
|
||||
.mockClear()
|
||||
.mockResolvedValue(dispatchReplyResult);
|
||||
sessionBindingMocks.resolveByConversation.mockReset().mockReturnValue(null);
|
||||
sessionBindingMocks.touch.mockReset();
|
||||
deliveryMocks.deliverReplies.mockClear().mockResolvedValue({ delivered: true });
|
||||
});
|
||||
beforeEach(resetSessionMetaMocks);
|
||||
|
||||
it("calls recordSessionMetaFromInbound after a native slash command", async () => {
|
||||
const cfg: OpenClawConfig = {};
|
||||
|
||||
@@ -208,6 +208,11 @@ const loadTelegramNativeCommandRuntime = createLazyRuntimeModule(
|
||||
() => import("./bot-native-commands.runtime.js"),
|
||||
);
|
||||
|
||||
export const testing = {
|
||||
loadNativeCommandRuntime: loadTelegramNativeCommandRuntime,
|
||||
};
|
||||
export { testing as __testing };
|
||||
|
||||
type TelegramNativeCommandRuntime = Awaited<ReturnType<typeof loadTelegramNativeCommandRuntime>>;
|
||||
|
||||
function resolveTelegramProgressPlaceholder(command: {
|
||||
|
||||
@@ -391,6 +391,8 @@ describe("monitorTelegramProvider (grammY)", () => {
|
||||
beforeAll(async () => {
|
||||
({ monitorTelegramProvider } = await import("./monitor.js"));
|
||||
({ resetTelegramPollingLeasesForTests } = await import("./polling-lease.js"));
|
||||
resetTelegramPollingLeasesForTests();
|
||||
await monitorWithAutoAbort();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -11,7 +11,7 @@ import type {
|
||||
OpenKeyedStoreOptions,
|
||||
PluginDoctorStateMigrationContext,
|
||||
} from "openclaw/plugin-sdk/runtime-doctor";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import { resolveSessionStoreAgentIds, stateMigrations } from "./doctor-contract-api.js";
|
||||
import {
|
||||
createTestStorePath,
|
||||
@@ -52,6 +52,64 @@ describe("voice-call doctor state migration", () => {
|
||||
let stateDir = "";
|
||||
let storePath = "";
|
||||
let env: NodeJS.ProcessEnv;
|
||||
let overCapacityMigration: {
|
||||
warnings: string[];
|
||||
changes: string[];
|
||||
activeCallIds: Set<string>;
|
||||
latestProviderCallId: string | undefined;
|
||||
historyCallIds: string[];
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
resetPluginStateStoreForTests();
|
||||
const warmStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-voice-call-doctor-"));
|
||||
const warmStorePath = createTestStorePath();
|
||||
const warmEnv = {
|
||||
...process.env,
|
||||
HOME: warmStateDir,
|
||||
OPENCLAW_STATE_DIR: warmStateDir,
|
||||
};
|
||||
try {
|
||||
installStateRuntime();
|
||||
const calls = Array.from({ length: 1002 }, (_, index) =>
|
||||
makePersistedCall({
|
||||
callId: `call-${index}`,
|
||||
providerCallId: `provider-${index}`,
|
||||
}),
|
||||
);
|
||||
writeLegacyCallsJsonl(warmStorePath, calls);
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"@openclaw/voice-call": {
|
||||
config: { store: warmStorePath },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await stateMigrations[0].migrateLegacyState({
|
||||
config,
|
||||
env: warmEnv,
|
||||
stateDir: warmStateDir,
|
||||
oauthDir: path.join(warmStateDir, "oauth"),
|
||||
context: createDoctorContext(warmEnv),
|
||||
});
|
||||
const restored = loadActiveCallsFromStore(warmStorePath);
|
||||
const history = await getCallHistoryFromStore(warmStorePath, 1000);
|
||||
overCapacityMigration = {
|
||||
warnings: result.warnings,
|
||||
changes: result.changes,
|
||||
activeCallIds: new Set(restored.activeCalls.keys()),
|
||||
latestProviderCallId: restored.activeCalls.get("call-1001")?.providerCallId,
|
||||
historyCallIds: history.map((entry) => entry.callId),
|
||||
};
|
||||
} finally {
|
||||
clearVoiceCallStateRuntime();
|
||||
resetPluginStateStoreForTests();
|
||||
await fs.rm(warmStateDir, { recursive: true, force: true });
|
||||
await fs.rm(warmStorePath, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
resetPluginStateStoreForTests();
|
||||
@@ -167,49 +225,20 @@ describe("voice-call doctor state migration", () => {
|
||||
expect(history[0]?.callId).toBe("call-doctor");
|
||||
});
|
||||
|
||||
it("imports the newest legacy call records when the JSONL log is over capacity", async () => {
|
||||
const calls = Array.from({ length: 1002 }, (_, index) =>
|
||||
makePersistedCall({
|
||||
callId: `call-${index}`,
|
||||
providerCallId: `provider-${index}`,
|
||||
}),
|
||||
);
|
||||
writeLegacyCallsJsonl(storePath, calls);
|
||||
|
||||
const config = {
|
||||
plugins: {
|
||||
entries: {
|
||||
"@openclaw/voice-call": {
|
||||
config: { store: storePath },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const result = await stateMigrations[0].migrateLegacyState({
|
||||
config,
|
||||
env,
|
||||
stateDir,
|
||||
oauthDir: path.join(stateDir, "oauth"),
|
||||
context: createDoctorContext(env),
|
||||
});
|
||||
|
||||
expect(result.warnings).toEqual([
|
||||
it("imports the newest legacy call records when the JSONL log is over capacity", () => {
|
||||
expect(overCapacityMigration.warnings).toEqual([
|
||||
expect.stringContaining("Pruned 2 older Voice Call call-log records"),
|
||||
]);
|
||||
expect(result.changes).toEqual([
|
||||
expect(overCapacityMigration.changes).toEqual([
|
||||
expect.stringContaining("Migrated 1000 Voice Call call-log records"),
|
||||
expect.stringContaining("Archived Voice Call call-log legacy source"),
|
||||
]);
|
||||
|
||||
const restored = loadActiveCallsFromStore(storePath);
|
||||
expect(restored.activeCalls.has("call-0")).toBe(false);
|
||||
expect(restored.activeCalls.has("call-1")).toBe(false);
|
||||
expect(restored.activeCalls.get("call-1001")?.providerCallId).toBe("provider-1001");
|
||||
|
||||
const history = await getCallHistoryFromStore(storePath, 1000);
|
||||
expect(history).toHaveLength(1000);
|
||||
expect(history[0]?.callId).toBe("call-2");
|
||||
expect(history.at(-1)?.callId).toBe("call-1001");
|
||||
expect(overCapacityMigration.activeCallIds.has("call-0")).toBe(false);
|
||||
expect(overCapacityMigration.activeCallIds.has("call-1")).toBe(false);
|
||||
expect(overCapacityMigration.latestProviderCallId).toBe("provider-1001");
|
||||
expect(overCapacityMigration.historyCallIds).toHaveLength(1000);
|
||||
expect(overCapacityMigration.historyCallIds[0]).toBe("call-2");
|
||||
expect(overCapacityMigration.historyCallIds.at(-1)).toBe("call-1001");
|
||||
});
|
||||
|
||||
it("leaves malformed mixed legacy logs in place after importing valid records", async () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Zalo tests cover monitor.pairing.lifecycle plugin behavior.
|
||||
import { withServer } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createLifecycleMonitorSetup,
|
||||
createTextUpdate,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
settleAsyncWork,
|
||||
} from "./test-support/lifecycle-test-support.js";
|
||||
import {
|
||||
loadCachedLifecycleMonitorModule,
|
||||
resetLifecycleTestState,
|
||||
sendMessageMock,
|
||||
setLifecycleRuntimeCore,
|
||||
@@ -18,6 +19,10 @@ describe("Zalo pairing lifecycle", () => {
|
||||
const readAllowFromStoreMock = vi.fn(async () => [] as string[]);
|
||||
const upsertPairingRequestMock = vi.fn(async () => ({ code: "PAIRCODE", created: true }));
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadCachedLifecycleMonitorModule("zalo-pairing-lifecycle");
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetLifecycleTestState();
|
||||
setLifecycleRuntimeCore({
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
createRuntimeEnv,
|
||||
setActivePluginRegistry,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import { setZaloRuntime } from "./runtime.js";
|
||||
import {
|
||||
@@ -117,6 +117,10 @@ describe("Zalo polling media replies", () => {
|
||||
}));
|
||||
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadCachedLifecycleMonitorModule("zalo-polling-media-reply");
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetLifecycleTestState();
|
||||
await clearHostedZaloMediaForTest();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Zalo tests cover monitor.reply once.lifecycle plugin behavior.
|
||||
import { withServer } from "openclaw/plugin-sdk/test-env";
|
||||
import { afterAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../runtime-api.js";
|
||||
import {
|
||||
createLifecycleMonitorSetup,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
settleAsyncWork,
|
||||
} from "./test-support/lifecycle-test-support.js";
|
||||
import {
|
||||
loadCachedLifecycleMonitorModule,
|
||||
resetLifecycleTestState,
|
||||
sendMessageMock,
|
||||
setLifecycleRuntimeCore,
|
||||
@@ -30,6 +31,10 @@ describe("Zalo reply-once lifecycle", () => {
|
||||
}));
|
||||
const dispatchReplyWithBufferedBlockDispatcherMock = vi.fn();
|
||||
|
||||
beforeAll(async () => {
|
||||
await loadCachedLifecycleMonitorModule("zalo-reply-once-lifecycle");
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await resetLifecycleTestState();
|
||||
setLifecycleRuntimeCore({
|
||||
|
||||
@@ -14,13 +14,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { resolveDefaultAgentDir } from "../src/agents/agent-scope.js";
|
||||
import { ensureAuthProfileStore, type AuthProfileCredential } from "../src/agents/auth-profiles.js";
|
||||
import { normalizeProviderId } from "../src/agents/model-selection.js";
|
||||
import { validateAnthropicSetupToken } from "../src/commands/auth-token.js";
|
||||
import { callGateway } from "../src/gateway/call.js";
|
||||
import { extractPayloadText } from "../src/gateway/test-helpers.agent-results.js";
|
||||
import { getFreePortBlockWithPermissionFallback } from "../src/test-utils/ports.js";
|
||||
import type { AuthProfileCredential } from "../src/agents/auth-profiles.js";
|
||||
import {
|
||||
parseBooleanEnv,
|
||||
parseStrictIntegerOption,
|
||||
@@ -214,9 +208,10 @@ function isSetupToken(value: string): boolean {
|
||||
return value.startsWith("sk-ant-oat01-");
|
||||
}
|
||||
|
||||
function listSetupTokenProfiles(store: {
|
||||
profiles: Record<string, AuthProfileCredential>;
|
||||
}): Array<{ id: string; token: string }> {
|
||||
function listSetupTokenProfiles(
|
||||
store: { profiles: Record<string, AuthProfileCredential> },
|
||||
normalizeProviderId: (provider: string) => string,
|
||||
): Array<{ id: string; token: string }> {
|
||||
return Object.entries(store.profiles)
|
||||
.filter(([, cred]) => {
|
||||
if (cred.type !== "token") {
|
||||
@@ -244,15 +239,25 @@ function pickSetupTokenProfile(candidates: Array<{ id: string; token: string }>)
|
||||
return candidates[0] ?? null;
|
||||
}
|
||||
|
||||
function validateSetupToken(value: string): string {
|
||||
const error = validateAnthropicSetupToken(value);
|
||||
if (error) {
|
||||
throw new Error(`invalid setup-token: ${error}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function resolveSetupTokenSource(): TokenSource {
|
||||
async function resolveSetupTokenSource(): Promise<TokenSource> {
|
||||
const [
|
||||
{ resolveDefaultAgentDir },
|
||||
{ ensureAuthProfileStore },
|
||||
{ normalizeProviderId },
|
||||
tokenApi,
|
||||
] = await Promise.all([
|
||||
import("../src/agents/agent-scope.js"),
|
||||
import("../src/agents/auth-profiles.js"),
|
||||
import("../src/agents/model-selection.js"),
|
||||
import("../src/commands/auth-token.js"),
|
||||
]);
|
||||
const validateSetupToken = (value: string): string => {
|
||||
const error = tokenApi.validateAnthropicSetupToken(value);
|
||||
if (error) {
|
||||
throw new Error(`invalid setup-token: ${error}`);
|
||||
}
|
||||
return value;
|
||||
};
|
||||
const explicitToken =
|
||||
(SETUP_TOKEN_RAW && isSetupToken(SETUP_TOKEN_RAW) ? SETUP_TOKEN_RAW : "") || SETUP_TOKEN_VALUE;
|
||||
if (explicitToken) {
|
||||
@@ -266,7 +271,7 @@ function resolveSetupTokenSource(): TokenSource {
|
||||
const store = ensureAuthProfileStore(agentDir, {
|
||||
allowKeychainPrompt: false,
|
||||
});
|
||||
const candidates = listSetupTokenProfiles(store);
|
||||
const candidates = listSetupTokenProfiles(store, normalizeProviderId);
|
||||
if (SETUP_TOKEN_PROFILE) {
|
||||
const match = candidates.find((entry) => entry.id === SETUP_TOKEN_PROFILE);
|
||||
if (!match) {
|
||||
@@ -449,6 +454,7 @@ async function startAnthropicProxy(params: { port: number; upstreamBaseUrl: stri
|
||||
}
|
||||
|
||||
async function getFreePort(): Promise<number> {
|
||||
const { getFreePortBlockWithPermissionFallback } = await import("../src/test-utils/ports.js");
|
||||
return await getFreePortBlockWithPermissionFallback({
|
||||
offsets: [0, 1, 2, 4],
|
||||
fallbackBase: 44_000,
|
||||
@@ -747,6 +753,7 @@ function isMissingProcessError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
async function waitForGatewayReady(url: string, token: string): Promise<void> {
|
||||
const { callGateway } = await import("../src/gateway/call.js");
|
||||
const deadline = Date.now() + 45_000;
|
||||
let lastError = "gateway start timeout";
|
||||
while (Date.now() < deadline) {
|
||||
@@ -787,7 +794,11 @@ async function readLogTail(logPath: string, maxBytes = GATEWAY_LOG_TAIL_BYTES):
|
||||
}
|
||||
|
||||
async function runGatewayPrompt(prompt: string): Promise<PromptResult> {
|
||||
const tokenSource = resolveSetupTokenSource();
|
||||
const tokenSource = await resolveSetupTokenSource();
|
||||
const [{ callGateway }, { extractPayloadText }] = await Promise.all([
|
||||
import("../src/gateway/call.js"),
|
||||
import("../src/gateway/test-helpers.agent-results.js"),
|
||||
]);
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gateway-prompt-probe-"));
|
||||
const stateDir = path.join(tmpDir, "state");
|
||||
const agentDir = path.join(stateDir, "agents", "main", "agent");
|
||||
|
||||
@@ -1017,9 +1017,18 @@ export async function runReportPlans(params) {
|
||||
);
|
||||
includeEntry = false;
|
||||
} else {
|
||||
console.error(
|
||||
`[test-group-report] config failed; keeping partial report from ${run.reportPath}`,
|
||||
);
|
||||
try {
|
||||
readReportInput({ config: plan.label, reportPath: run.reportPath, run });
|
||||
console.error(
|
||||
`[test-group-report] config failed; keeping partial report from ${run.reportPath}`,
|
||||
);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
console.error(
|
||||
`[test-group-report] config failed; skipping unusable JSON report from ${run.reportPath} (${reason})`,
|
||||
);
|
||||
includeEntry = false;
|
||||
}
|
||||
}
|
||||
if (!params.args.allowFailures) {
|
||||
exitCode = run.status || 1;
|
||||
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
import { fullSuiteVitestShards } from "../test/vitest/vitest.test-shards.mjs";
|
||||
import {
|
||||
getUnitFastTestFiles,
|
||||
getUnitFastTimerTestFiles,
|
||||
resolveUnitFastTestIncludePattern,
|
||||
resolveUnitFastTimerTestIncludePattern,
|
||||
} from "../test/vitest/vitest.unit-fast-paths.mjs";
|
||||
@@ -289,6 +290,8 @@ const BROAD_TOOLING_SCRIPT_TEST_PATTERNS = new Set([
|
||||
"test/scripts/*.test.ts",
|
||||
]);
|
||||
const BROAD_TOOLING_SCRIPT_TEST_TARGET_CHUNK_SIZE = 60;
|
||||
const FULL_SUITE_TOOLING_TEST_TARGET_CHUNK_SIZE = 2;
|
||||
const FULL_SUITE_UNIT_FAST_TEST_TARGET_CHUNK_SIZE = 70;
|
||||
const TUI_VITEST_CONFIG = "test/vitest/vitest.tui.config.ts";
|
||||
const TUI_PTY_VITEST_CONFIG = "test/vitest/vitest.tui-pty.config.ts";
|
||||
const UI_VITEST_CONFIG = "test/vitest/vitest.ui.config.ts";
|
||||
@@ -2353,6 +2356,21 @@ function listBroadToolingScriptTestTargets(pattern, cwd) {
|
||||
);
|
||||
}
|
||||
|
||||
function listToolingFullSuiteTestTargets(cwd) {
|
||||
return uniqueOrdered(
|
||||
[path.join(cwd, "test"), path.join(cwd, "src", "scripts")].flatMap((root) =>
|
||||
fs.existsSync(root) ? listRepoFilesRecursive(root, cwd) : [],
|
||||
),
|
||||
)
|
||||
.filter((file) => file.endsWith(".test.ts") && classifyTarget(file, cwd) === "tooling")
|
||||
.toSorted((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function listUnitFastFullSuiteTestTargets() {
|
||||
const timerTargets = new Set(getUnitFastTimerTestFiles());
|
||||
return getUnitFastTestFiles().filter((file) => !timerTargets.has(file));
|
||||
}
|
||||
|
||||
function createBroadToolingScriptPlans({ config, forwardedArgs, includePatterns, watchMode, cwd }) {
|
||||
if (watchMode || config !== TOOLING_VITEST_CONFIG || !includePatterns) {
|
||||
return null;
|
||||
@@ -4018,11 +4036,26 @@ export function buildFullSuiteVitestRunPlans(args, cwd = process.cwd()) {
|
||||
const expandShard = expandToProjectConfigs;
|
||||
const configs = expandShard ? shard.projects : [shard.config];
|
||||
return configs.flatMap((config) => {
|
||||
if (expandShard && targetArgs.length === 0 && config === GATEWAY_SERVER_VITEST_CONFIG) {
|
||||
const chunks = splitTargetChunks(
|
||||
resolveGatewayServerFullSuiteTargets(cwd),
|
||||
GATEWAY_SERVER_FULL_SUITE_TARGET_CHUNK_COUNT,
|
||||
);
|
||||
if (expandShard && targetArgs.length === 0) {
|
||||
let chunks = [];
|
||||
if (config === UNIT_FAST_VITEST_CONFIG) {
|
||||
const targets = listUnitFastFullSuiteTestTargets();
|
||||
const chunkCount = Math.ceil(
|
||||
targets.length / FULL_SUITE_UNIT_FAST_TEST_TARGET_CHUNK_SIZE,
|
||||
);
|
||||
chunks = splitTargetChunks(targets, chunkCount);
|
||||
} else if (config === TOOLING_VITEST_CONFIG) {
|
||||
// Tooling tests spawn package managers and native helpers. Keep native
|
||||
// process lifetime short enough that unrelated files cannot crash together.
|
||||
const targets = listToolingFullSuiteTestTargets(cwd);
|
||||
const chunkCount = Math.ceil(targets.length / FULL_SUITE_TOOLING_TEST_TARGET_CHUNK_SIZE);
|
||||
chunks = splitTargetChunks(targets, chunkCount);
|
||||
} else if (config === GATEWAY_SERVER_VITEST_CONFIG) {
|
||||
chunks = splitTargetChunks(
|
||||
resolveGatewayServerFullSuiteTargets(cwd),
|
||||
GATEWAY_SERVER_FULL_SUITE_TARGET_CHUNK_COUNT,
|
||||
);
|
||||
}
|
||||
if (chunks.length > 0) {
|
||||
return chunks.map((targets) => ({
|
||||
config,
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
/** Tests internal model discovery imports avoid public SDK facade coupling. */
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
let modelDiscovery: typeof import("./agent-model-discovery.js");
|
||||
|
||||
describe("agent-model-discovery internal runtime", () => {
|
||||
it("loads without the public agent-sessions SDK facade", async () => {
|
||||
const module = await import("./agent-model-discovery.js");
|
||||
expect(typeof module.discoverAuthStorage).toBe("function");
|
||||
expect(typeof module.discoverModels).toBe("function");
|
||||
beforeAll(async () => {
|
||||
modelDiscovery = await import("./agent-model-discovery.js");
|
||||
});
|
||||
|
||||
it("loads without the public agent-sessions SDK facade", () => {
|
||||
expect(typeof modelDiscovery.discoverAuthStorage).toBe("function");
|
||||
expect(typeof modelDiscovery.discoverModels).toBe("function");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Covers compaction sanitization for toolResult details and runtime context.
|
||||
import type { AgentMessage } from "openclaw/plugin-sdk/agent-core";
|
||||
import type { AssistantMessage, ToolResultMessage } from "openclaw/plugin-sdk/llm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { makeAgentAssistantMessage } from "./test-helpers/agent-message-fixtures.js";
|
||||
|
||||
const agentSessionMocks = vi.hoisted(() => ({
|
||||
@@ -21,13 +21,6 @@ vi.mock("./sessions/index.js", async () => {
|
||||
let isOversizedForSummary: typeof import("./compaction.js").isOversizedForSummary;
|
||||
let summarizeWithFallback: typeof import("./compaction.js").summarizeWithFallback;
|
||||
|
||||
async function loadFreshCompactionModuleForTest() {
|
||||
// Reset modules so each test observes the mocked token/summary helpers from a
|
||||
// fresh compaction import.
|
||||
vi.resetModules();
|
||||
({ isOversizedForSummary, summarizeWithFallback } = await import("./compaction.js"));
|
||||
}
|
||||
|
||||
function makeAssistantToolCall(timestamp: number): AssistantMessage {
|
||||
return makeAgentAssistantMessage({
|
||||
content: [{ type: "toolCall", id: "call_1", name: "browser", arguments: { action: "tabs" } }],
|
||||
@@ -52,8 +45,11 @@ function makeToolResultWithDetails(timestamp: number): ToolResultMessage<{ raw:
|
||||
}
|
||||
|
||||
describe("compaction toolResult details stripping", () => {
|
||||
beforeEach(async () => {
|
||||
await loadFreshCompactionModuleForTest();
|
||||
beforeAll(async () => {
|
||||
({ isOversizedForSummary, summarizeWithFallback } = await import("./compaction.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
agentSessionMocks.generateSummary.mockReset();
|
||||
agentSessionMocks.generateSummary.mockResolvedValue("summary");
|
||||
agentSessionMocks.estimateTokens.mockReset();
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
mockedRunEmbeddedAttempt,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
|
||||
@@ -50,6 +51,7 @@ function attemptCall(index: number): {
|
||||
describe("runEmbeddedAgent before_agent_finalize", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mockedRunEmbeddedAttempt,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
|
||||
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
|
||||
@@ -49,6 +50,7 @@ function firstAttemptParams(): {
|
||||
describe("runEmbeddedAgent cron before_agent_reply seam", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mockedRunEmbeddedAttempt,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import { hasCodexAppServerRecoveryRetryBudget } from "./run/codex-app-server-recovery.js";
|
||||
import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
@@ -86,6 +87,7 @@ function asAttemptParams(value: unknown): EmbeddedRunAttemptParams {
|
||||
describe("runEmbeddedAgent Codex app-server recovery", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
mockedRunEmbeddedAttempt,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
|
||||
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
|
||||
@@ -20,6 +21,7 @@ let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
|
||||
describe("runEmbeddedAgent Codex server_error fallback handoff", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
mockedIsLikelyContextOverflowError,
|
||||
mockedRunEmbeddedAttempt,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
|
||||
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
|
||||
@@ -99,6 +100,7 @@ async function executeWrappedToolOutcome(
|
||||
describe("post-compaction loop guard wired into runEmbeddedAgent", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
// Re-import after the harness reset so we share module instances with
|
||||
// the runner. The runner imports both modules through its own graph.
|
||||
({ diagnosticSessionStates, getDiagnosticSessionState } =
|
||||
|
||||
+51
-35
@@ -6,6 +6,7 @@ import { makeAttemptResult } from "./run.overflow-compaction.fixture.js";
|
||||
import {
|
||||
loadRunOverflowCompactionHarness,
|
||||
MockedFailoverError,
|
||||
mockedClassifyFailoverReason,
|
||||
mockedFormatAssistantErrorText,
|
||||
mockedGlobalHookRunner,
|
||||
mockedIsFailoverAssistantError,
|
||||
@@ -13,11 +14,13 @@ import {
|
||||
mockedRunEmbeddedAttempt,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
|
||||
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
|
||||
const DEEPSEEK_ERROR_MESSAGE = "429 deepseek rate limit";
|
||||
const COMPACTION_REMOVED_ERROR_MESSAGE = "current candidate model unavailable";
|
||||
type CurrentAttemptAssistantWithError = NonNullable<
|
||||
EmbeddedRunAttemptResult["currentAttemptAssistant"]
|
||||
> & { errorMessage: string };
|
||||
@@ -82,6 +85,39 @@ function makeCrossProviderFallbackConfig() {
|
||||
});
|
||||
}
|
||||
|
||||
function setupCompactionRemovedFallbackAttempt() {
|
||||
mockedIsFailoverAssistantError.mockImplementation((...args: unknown[]) => {
|
||||
const assistant = args[0];
|
||||
return isCurrentAttemptAssistant(assistant) && assistant.provider === "anthropic";
|
||||
});
|
||||
mockedClassifyFailoverReason.mockReturnValue("model_not_found");
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
|
||||
makeAttemptResult({
|
||||
assistantTexts: [],
|
||||
lastAssistant: makeAssistantMessageFixture({
|
||||
stopReason: "error",
|
||||
errorMessage: COMPACTION_REMOVED_ERROR_MESSAGE,
|
||||
provider: "anthropic",
|
||||
model: "test-model",
|
||||
content: [],
|
||||
}),
|
||||
currentAttemptAssistant: undefined,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function runCompactionRemovedFallbackAttempt() {
|
||||
return runEmbeddedAgent({
|
||||
...overflowBaseRunParams,
|
||||
runId: "run-compaction-fallback-error-context",
|
||||
config: makeCrossProviderFallbackConfig(),
|
||||
agentHarnessRuntimeOverride: "openclaw",
|
||||
provider: "anthropic",
|
||||
model: "test-model",
|
||||
modelFallbacksOverride: ["deepseek/deepseek-chat"],
|
||||
});
|
||||
}
|
||||
|
||||
async function expectDeepseekFallbackError(
|
||||
promise: Promise<unknown>,
|
||||
getLastFormattedAssistant: () => unknown,
|
||||
@@ -97,6 +133,14 @@ async function expectDeepseekFallbackError(
|
||||
describe("runEmbeddedAgent cross-provider fallback error handling", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent, {
|
||||
config: makeCrossProviderFallbackConfig(),
|
||||
agentHarnessRuntimeOverride: "openclaw",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-chat",
|
||||
});
|
||||
setupCompactionRemovedFallbackAttempt();
|
||||
await runCompactionRemovedFallbackAttempt().catch(() => undefined);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -142,46 +186,18 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
|
||||
|
||||
it("falls back to the session assistant when compaction removes the current attempt slice", async () => {
|
||||
const getLastFormattedAssistant = captureFormattedAssistant();
|
||||
const sameCandidateErrorMessage = "429 current candidate rate limit";
|
||||
mockedIsFailoverAssistantError.mockImplementation((...args: unknown[]) => {
|
||||
const assistant = args[0];
|
||||
return isCurrentAttemptAssistant(assistant) && assistant.provider === "anthropic";
|
||||
});
|
||||
mockedIsRateLimitAssistantError.mockImplementation((...args: unknown[]) => {
|
||||
const assistant = args[0];
|
||||
return isCurrentAttemptAssistant(assistant) && assistant.provider === "anthropic";
|
||||
});
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
|
||||
makeAttemptResult({
|
||||
assistantTexts: [],
|
||||
lastAssistant: makeAssistantMessageFixture({
|
||||
stopReason: "error",
|
||||
errorMessage: sameCandidateErrorMessage,
|
||||
provider: "anthropic",
|
||||
model: "test-model",
|
||||
content: [],
|
||||
}),
|
||||
currentAttemptAssistant: undefined,
|
||||
}),
|
||||
);
|
||||
|
||||
const promise = runEmbeddedAgent({
|
||||
...overflowBaseRunParams,
|
||||
runId: "run-compaction-fallback-error-context",
|
||||
config: makeCrossProviderFallbackConfig(),
|
||||
agentHarnessRuntimeOverride: "openclaw",
|
||||
provider: "anthropic",
|
||||
model: "test-model",
|
||||
modelFallbacksOverride: ["deepseek/deepseek-chat"],
|
||||
});
|
||||
setupCompactionRemovedFallbackAttempt();
|
||||
const promise = runCompactionRemovedFallbackAttempt();
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(MockedFailoverError);
|
||||
await expect(promise).rejects.toThrow(`anthropic/test-model: ${sameCandidateErrorMessage}`);
|
||||
expect(mockedIsRateLimitAssistantError).toHaveBeenCalledTimes(1);
|
||||
await expect(promise).rejects.toThrow(
|
||||
`anthropic/test-model: ${COMPACTION_REMOVED_ERROR_MESSAGE}`,
|
||||
);
|
||||
expect(mockedIsFailoverAssistantError).toHaveBeenCalledTimes(1);
|
||||
expect(getLastFormattedAssistant()).toMatchObject({
|
||||
provider: "anthropic",
|
||||
model: "test-model",
|
||||
errorMessage: sameCandidateErrorMessage,
|
||||
errorMessage: COMPACTION_REMOVED_ERROR_MESSAGE,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mockedRunEmbeddedAttempt,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
|
||||
@@ -60,6 +61,7 @@ function successAttempt(provider: string, model: string): EmbeddedRunAttemptResu
|
||||
describe("runEmbeddedAgent silent-error retry", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
mockedRunEmbeddedAttempt,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
|
||||
@@ -52,6 +53,7 @@ function resolveAttemptFastMode(params: unknown): void {
|
||||
describe("runEmbeddedAgent fast auto progress", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
mockedSleepWithAbort,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import {
|
||||
buildAttemptReplayMetadata,
|
||||
@@ -53,15 +54,7 @@ function resolveIncompleteTurnPayloadText(
|
||||
describe("runEmbeddedAgent incomplete-turn safety", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
resetRunOverflowCompactionHarnessMocks();
|
||||
mockedGlobalHookRunner.hasHooks.mockImplementation(() => false);
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
|
||||
makeAttemptResult({ assistantTexts: ["warmup"] }),
|
||||
);
|
||||
await runEmbeddedAgent({
|
||||
...overflowBaseRunParams,
|
||||
runId: "run-incomplete-turn-warmup",
|
||||
});
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -19,6 +19,7 @@ import type {
|
||||
import { resetCommandQueueStateForTest } from "../../process/command-queue.js";
|
||||
import type { FailoverReason } from "../embedded-agent-helpers/types.js";
|
||||
import { clearAgentHarnesses, registerAgentHarness } from "../harness/registry.js";
|
||||
import { makeAttemptResult } from "./run.overflow-compaction.fixture.js";
|
||||
import type { buildEmbeddedRunPayloads } from "./run/payloads.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
|
||||
@@ -810,3 +811,19 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
|
||||
const { runEmbeddedAgent } = await import("./run.js");
|
||||
return { runEmbeddedAgent };
|
||||
}
|
||||
|
||||
/** Move one-time runner compilation out of individual behavior timings. */
|
||||
export async function warmRunOverflowCompactionHarness(
|
||||
runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent,
|
||||
params?: Partial<Parameters<typeof runEmbeddedAgent>[0]>,
|
||||
): Promise<void> {
|
||||
resetRunOverflowCompactionHarnessMocks();
|
||||
mockedGlobalHookRunner.hasHooks.mockReturnValue(false);
|
||||
mockedBuildEmbeddedRunPayloads.mockReturnValue([{ text: "warmup" }]);
|
||||
mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ assistantTexts: ["warmup"] }));
|
||||
await runEmbeddedAgent({
|
||||
...overflowBaseRunParams,
|
||||
...params,
|
||||
runId: params?.runId ?? "run-overflow-compaction-harness-warmup",
|
||||
});
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
mockedSessionLikelyHasOversizedToolResults,
|
||||
mockedTruncateOversizedToolResultsInSession,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
|
||||
@@ -66,6 +67,7 @@ function expectRetryContinuesFromTranscript() {
|
||||
describe("overflow compaction in run loop", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -50,6 +50,7 @@ import {
|
||||
mockedWaitForDeferredTurnMaintenanceForSession,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import type { RunEmbeddedAgentParams } from "./run/params.js";
|
||||
import type { EmbeddedRunAttemptParams } from "./run/types.js";
|
||||
@@ -252,6 +253,7 @@ async function waitForRunEvent(events: string[], expected: string): Promise<void
|
||||
describe("runEmbeddedAgent overflow compaction trigger routing", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
mockedRunEmbeddedAttempt,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
|
||||
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
|
||||
@@ -16,6 +17,7 @@ let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
|
||||
describe("runEmbeddedAgent prompt timeout fallback handoff", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
mockedRunPostCompactionSideEffects,
|
||||
overflowBaseRunParams,
|
||||
resetRunOverflowCompactionHarnessMocks,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
|
||||
let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
|
||||
@@ -114,6 +115,7 @@ function hookCallAt(index: number, kind: "before" | "after"): [HookEvent, HookCo
|
||||
describe("timeout-triggered compaction", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -4,7 +4,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { resolveSessionTranscriptPathInDir } from "../../../config/sessions/paths.js";
|
||||
import {
|
||||
appendSessionTranscriptEvent,
|
||||
@@ -43,6 +43,25 @@ const lockOptions = {
|
||||
};
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
const LARGE_LINEAR_TRANSCRIPT_LINE = `${JSON.stringify({
|
||||
type: "message",
|
||||
id: "large-linear-user",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "界".repeat(Math.ceil((7 * 1024 * 1024) / 3)) }],
|
||||
},
|
||||
})}\n`;
|
||||
const LARGE_LEGACY_DELIVERY_TRANSCRIPT_LINE = `${JSON.stringify({
|
||||
type: "message",
|
||||
id: "large-legacy-user",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "界".repeat(Math.ceil((8 * 1024 * 1024) / 3)) }],
|
||||
},
|
||||
})}\n`;
|
||||
const LARGE_OWNED_TRANSCRIPT_TEXT = "界".repeat(1024 * 1024);
|
||||
|
||||
afterEach(async () => {
|
||||
vi.restoreAllMocks();
|
||||
@@ -63,6 +82,19 @@ async function createTempSessionFile(): Promise<string> {
|
||||
return sessionFile;
|
||||
}
|
||||
|
||||
async function readSessionFileTail(sessionFile: string, maxBytes = 512): Promise<string> {
|
||||
const stat = await fs.stat(sessionFile);
|
||||
const length = Math.min(maxBytes, stat.size);
|
||||
const buffer = Buffer.alloc(length);
|
||||
const handle = await fs.open(sessionFile, "r");
|
||||
try {
|
||||
const { bytesRead } = await handle.read(buffer, 0, length, stat.size - length);
|
||||
return buffer.toString("utf8", 0, bytesRead);
|
||||
} finally {
|
||||
await handle.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function waitUntil(predicate: () => boolean, message: string): Promise<void> {
|
||||
const deadline = Date.now() + 1_000;
|
||||
while (!predicate()) {
|
||||
@@ -1685,64 +1717,68 @@ describe("embedded attempt session lock lifecycle", () => {
|
||||
expect(controller.hasSessionTakeover()).toBe(false);
|
||||
});
|
||||
|
||||
it("allows parentless delivery mirrors appended to large legacy linear transcripts", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
await fs.appendFile(
|
||||
sessionFile,
|
||||
`${JSON.stringify({
|
||||
type: "message",
|
||||
id: "large-legacy-user",
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "x".repeat(8 * 1024 * 1024) }],
|
||||
describe("large legacy linear transcript delivery", () => {
|
||||
let result: {
|
||||
hasTakeover: boolean;
|
||||
lastLine?: string;
|
||||
mergedEntries: unknown;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
await fs.appendFile(sessionFile, LARGE_LEGACY_DELIVERY_TRANSCRIPT_LINE, "utf8");
|
||||
const mergePromptReleasedSessionEntries = vi.fn();
|
||||
const controller = await createEmbeddedAttemptSessionLockController({
|
||||
acquireSessionWriteLock,
|
||||
lockOptions: { ...lockOptions, sessionFile },
|
||||
mergePromptReleasedSessionEntries,
|
||||
});
|
||||
|
||||
await controller.releaseForPrompt();
|
||||
const sessionKey = "agent:main:large-linear-delivery";
|
||||
await withOwnedSessionTranscriptWrites(
|
||||
{
|
||||
sessionFile,
|
||||
sessionKey,
|
||||
withSessionWriteLock: (operation, options) =>
|
||||
controller.withSessionWriteLock(operation, options),
|
||||
},
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const mergePromptReleasedSessionEntries = vi.fn();
|
||||
const controller = await createEmbeddedAttemptSessionLockController({
|
||||
acquireSessionWriteLock,
|
||||
lockOptions: { ...lockOptions, sessionFile },
|
||||
mergePromptReleasedSessionEntries,
|
||||
async () =>
|
||||
await runWithOwnedSessionTranscriptWritePublication(
|
||||
{ sessionFile, sessionKey },
|
||||
async () =>
|
||||
await appendSessionTranscriptMessage({
|
||||
transcriptPath: sessionFile,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "mirrored large transcript delivery" }],
|
||||
provider: "openclaw",
|
||||
model: "delivery-mirror",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
result = {
|
||||
hasTakeover: controller.hasSessionTakeover(),
|
||||
lastLine: (await readSessionFileTail(sessionFile)).trimEnd().split("\n").at(-1),
|
||||
mergedEntries: mergePromptReleasedSessionEntries.mock.calls[0]?.[0],
|
||||
};
|
||||
await controller.dispose();
|
||||
});
|
||||
|
||||
await controller.releaseForPrompt();
|
||||
const sessionKey = "agent:main:large-linear-delivery";
|
||||
await withOwnedSessionTranscriptWrites(
|
||||
{
|
||||
sessionFile,
|
||||
sessionKey,
|
||||
withSessionWriteLock: (operation, options) =>
|
||||
controller.withSessionWriteLock(operation, options),
|
||||
},
|
||||
async () =>
|
||||
await runWithOwnedSessionTranscriptWritePublication(
|
||||
{ sessionFile, sessionKey },
|
||||
async () =>
|
||||
await appendSessionTranscriptMessage({
|
||||
transcriptPath: sessionFile,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "mirrored large transcript delivery" }],
|
||||
provider: "openclaw",
|
||||
model: "delivery-mirror",
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const lastLine = (await fs.readFile(sessionFile, "utf8")).trimEnd().split("\n").at(-1);
|
||||
expect(lastLine).toBeDefined();
|
||||
expect(JSON.parse(lastLine ?? "{}")).not.toHaveProperty("parentId");
|
||||
expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([
|
||||
expect.objectContaining({
|
||||
type: "message",
|
||||
parentId: null,
|
||||
message: expect.objectContaining({ model: "delivery-mirror" }),
|
||||
}),
|
||||
]);
|
||||
expect(controller.hasSessionTakeover()).toBe(false);
|
||||
it("allows parentless delivery mirrors appended to large legacy linear transcripts", () => {
|
||||
expect(result.lastLine).toBeDefined();
|
||||
expect(JSON.parse(result.lastLine ?? "{}")).not.toHaveProperty("parentId");
|
||||
expect(result.mergedEntries).toEqual([
|
||||
expect.objectContaining({
|
||||
type: "message",
|
||||
parentId: null,
|
||||
message: expect.objectContaining({ model: "delivery-mirror" }),
|
||||
}),
|
||||
]);
|
||||
expect(result.hasTakeover).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("refreshes the prompt fence after an owned write throws", async () => {
|
||||
@@ -2972,54 +3008,59 @@ describe("embedded attempt session lock lifecycle", () => {
|
||||
await controller.dispose();
|
||||
});
|
||||
|
||||
it("validates large owned entries after migrating a large linear transcript", async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
await fs.appendFile(
|
||||
sessionFile,
|
||||
`${JSON.stringify({
|
||||
type: "message",
|
||||
id: "large-linear-user",
|
||||
timestamp: new Date().toISOString(),
|
||||
message: {
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "x".repeat(7 * 1024 * 1024) }],
|
||||
describe("large owned linear transcript migration", () => {
|
||||
let result: {
|
||||
appendedId: string;
|
||||
hasTakeover: boolean;
|
||||
mergedEntries: unknown;
|
||||
persistedParentLink: boolean;
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
const sessionFile = await createTempSessionFile();
|
||||
await fs.appendFile(sessionFile, LARGE_LINEAR_TRANSCRIPT_LINE, "utf8");
|
||||
const mergePromptReleasedSessionEntries = vi.fn();
|
||||
const controller = await createEmbeddedAttemptSessionLockController({
|
||||
acquireSessionWriteLock,
|
||||
lockOptions: { ...lockOptions, sessionFile },
|
||||
mergePromptReleasedSessionEntries,
|
||||
});
|
||||
await controller.releaseForPrompt();
|
||||
|
||||
const appended = await withOwnedSessionTranscriptWrites(
|
||||
{
|
||||
sessionFile,
|
||||
withSessionWriteLock: (operation, options) =>
|
||||
controller.withSessionWriteLock(operation, options),
|
||||
},
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
const mergePromptReleasedSessionEntries = vi.fn();
|
||||
const controller = await createEmbeddedAttemptSessionLockController({
|
||||
acquireSessionWriteLock,
|
||||
lockOptions: { ...lockOptions, sessionFile },
|
||||
mergePromptReleasedSessionEntries,
|
||||
async () =>
|
||||
await appendSessionTranscriptMessage({
|
||||
transcriptPath: sessionFile,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: LARGE_OWNED_TRANSCRIPT_TEXT }],
|
||||
provider: "anthropic",
|
||||
model: "sonnet-4.6",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
result = {
|
||||
appendedId: appended.messageId,
|
||||
hasTakeover: controller.hasSessionTakeover(),
|
||||
mergedEntries: mergePromptReleasedSessionEntries.mock.calls[0]?.[0],
|
||||
persistedParentLink: (await fs.readFile(sessionFile, "utf8")).includes('"parentId"'),
|
||||
};
|
||||
await controller.dispose();
|
||||
});
|
||||
await controller.releaseForPrompt();
|
||||
|
||||
const appended = await withOwnedSessionTranscriptWrites(
|
||||
{
|
||||
sessionFile,
|
||||
withSessionWriteLock: (operation, options) =>
|
||||
controller.withSessionWriteLock(operation, options),
|
||||
},
|
||||
async () =>
|
||||
await appendSessionTranscriptMessage({
|
||||
transcriptPath: sessionFile,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "y".repeat(3 * 1024 * 1024) }],
|
||||
provider: "anthropic",
|
||||
model: "sonnet-4.6",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const persisted = await fs.readFile(sessionFile, "utf8");
|
||||
expect(persisted).toContain('"parentId"');
|
||||
expect(mergePromptReleasedSessionEntries).toHaveBeenCalledWith([
|
||||
expect.objectContaining({ type: "message", id: appended.messageId }),
|
||||
]);
|
||||
expect(controller.hasSessionTakeover()).toBe(false);
|
||||
await controller.dispose();
|
||||
it("validates large owned entries after migrating a large linear transcript", () => {
|
||||
expect(result.persistedParentLink).toBe(true);
|
||||
expect(result.mergedEntries).toEqual([
|
||||
expect.objectContaining({ type: "message", id: result.appendedId }),
|
||||
]);
|
||||
expect(result.hasTakeover).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it("serializes concurrent nested owned transcript publications", async () => {
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
mockedGlobalHookRunner,
|
||||
mockedRunEmbeddedAttempt,
|
||||
overflowBaseRunParams,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import { isEmbeddedAgentRunActive, queueEmbeddedAgentMessageWithOutcome } from "./runs.js";
|
||||
|
||||
@@ -18,6 +19,7 @@ let runEmbeddedAgent: typeof import("./run.js").runEmbeddedAgent;
|
||||
describe("sessions_yield orchestration", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
mockedEnsureRuntimePluginsLoaded,
|
||||
mockedResolveModelAsync,
|
||||
mockedRunEmbeddedAttempt,
|
||||
warmRunOverflowCompactionHarness,
|
||||
} from "./run.overflow-compaction.harness.js";
|
||||
import type { EmbeddedRunAttemptResult } from "./run/types.js";
|
||||
|
||||
@@ -44,6 +45,7 @@ function firstAttemptInput(): Record<string, unknown> {
|
||||
describe("runEmbeddedAgent usage reporting", () => {
|
||||
beforeAll(async () => {
|
||||
({ runEmbeddedAgent } = await loadRunOverflowCompactionHarness());
|
||||
await warmRunOverflowCompactionHarness(runEmbeddedAgent);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Verifies plugin loading needed before agent harness selection.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
@@ -28,7 +28,12 @@ vi.mock("../../plugins/activation-planner.js", () => ({
|
||||
describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
let ensureSelectedAgentHarnessPlugin: typeof import("./runtime-plugin.js").ensureSelectedAgentHarnessPlugin;
|
||||
|
||||
beforeEach(async () => {
|
||||
beforeAll(async () => {
|
||||
vi.resetModules();
|
||||
({ ensureSelectedAgentHarnessPlugin } = await import("./runtime-plugin.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
mocks.ensurePluginRegistryLoaded.mockReset();
|
||||
mocks.resolveActivatableProviderOwnerPluginIds.mockReset();
|
||||
mocks.resolveBundledProviderCompatPluginIds.mockReset();
|
||||
@@ -67,8 +72,6 @@ describe("ensureSelectedAgentHarnessPlugin", () => {
|
||||
({ pluginIds }: { pluginIds: readonly string[] }) =>
|
||||
pluginIds.filter((pluginId) => pluginId === "memory-core"),
|
||||
);
|
||||
vi.resetModules();
|
||||
({ ensureSelectedAgentHarnessPlugin } = await import("./runtime-plugin.js"));
|
||||
});
|
||||
|
||||
it("loads Codex and the provider owner when an explicit runtime override forces the Codex harness", async () => {
|
||||
|
||||
@@ -118,6 +118,7 @@ export class OpenClawStdioClientTransport implements Transport {
|
||||
async close(): Promise<void> {
|
||||
const processToClose = this.process ?? this.closingProcess;
|
||||
this.process = undefined;
|
||||
this.closingProcess = processToClose;
|
||||
if (processToClose) {
|
||||
this.closingProcess = processToClose;
|
||||
}
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
* Regression coverage for model compatibility and live-model curation.
|
||||
* Exercises catalog compatibility, provider modernity hooks, and live sweep selection.
|
||||
*/
|
||||
import path from "node:path";
|
||||
import type { Api, Model } from "openclaw/plugin-sdk/llm";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const providerRuntimeMocks = vi.hoisted(() => ({
|
||||
resolveProviderModernModelRef: vi.fn(),
|
||||
@@ -88,10 +89,17 @@ function expectNativeStreamingSupported(overrides: Partial<Model>): void {
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
// Endpoint capabilities come from manifests. Keep source tests independent
|
||||
// from partial dist output left by an earlier build in the same checkout.
|
||||
vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.join(process.cwd(), "extensions"));
|
||||
providerRuntimeMocks.resolveProviderModernModelRef.mockReset();
|
||||
providerRuntimeMocks.resolveProviderModernModelRef.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
describe("normalizeModelCompat — Anthropic baseUrl", () => {
|
||||
const anthropicBase = (): Model =>
|
||||
({
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
// Verifies models.json planning applies config env vars and discovery scope.
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { createConfigRuntimeEnv } from "../config/env-vars.js";
|
||||
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.js";
|
||||
import { withEnvAsync } from "../test-utils/env.js";
|
||||
import { __testing as externalAuthTesting } from "./auth-profiles/external-auth.js";
|
||||
import {
|
||||
clearRuntimeAuthProfileStoreSnapshots,
|
||||
replaceRuntimeAuthProfileStoreSnapshots,
|
||||
@@ -16,6 +17,11 @@ import {
|
||||
import type { ProviderConfig } from "./models-config.providers.secrets.js";
|
||||
import { encodePluginModelCatalogRelativePath } from "./plugin-model-catalog.js";
|
||||
|
||||
vi.mock("./provider-auth-aliases.js", () => ({
|
||||
resolveProviderAuthAliasMap: () => Object.create(null) as Record<string, string>,
|
||||
resolveProviderIdForAuth: (provider: string) => provider.trim().toLowerCase(),
|
||||
}));
|
||||
|
||||
const TEST_ENV_VAR = "OPENCLAW_MODELS_CONFIG_TEST_ENV";
|
||||
|
||||
function createImplicitOpenRouterProvider(): ProviderConfig {
|
||||
@@ -126,6 +132,59 @@ async function resolveProvidersAndCaptureDiscoveryEnv(cfg: OpenClawConfig) {
|
||||
let unauthenticatedProviderWritePlan: Awaited<ReturnType<typeof planOpenClawModelsJsonWithDeps>>;
|
||||
let unauthenticatedProviderParsed: { providers?: Record<string, unknown> };
|
||||
|
||||
async function planGoogleVertexProfileCatalog() {
|
||||
const agentDir = "/tmp/openclaw-google-vertex-models-profile";
|
||||
try {
|
||||
externalAuthTesting.setResolveExternalAuthProfilesForTest(() => []);
|
||||
replaceRuntimeAuthProfileStoreSnapshots([
|
||||
{
|
||||
store: { version: 1, profiles: {} },
|
||||
},
|
||||
{
|
||||
agentDir,
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"google-vertex:default": {
|
||||
type: "api_key",
|
||||
provider: "google-vertex",
|
||||
keyRef: { source: "env", provider: "default", id: "GOOGLE_CLOUD_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
return await planOpenClawModelsJsonWithDeps(
|
||||
{
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"google-vertex/gemini-2.5-pro": {},
|
||||
},
|
||||
model: { primary: "google-vertex/gemini-2.5-pro" },
|
||||
},
|
||||
},
|
||||
models: { providers: {} },
|
||||
},
|
||||
agentDir,
|
||||
env: {},
|
||||
existingRaw: "",
|
||||
existingParsed: null,
|
||||
},
|
||||
{
|
||||
resolveImplicitProviders: async () => ({
|
||||
"google-vertex": createImplicitGoogleVertexProvider(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
externalAuthTesting.resetResolveExternalAuthProfilesForTest();
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
}
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
// Reused no-auth write plan proves generated providers stay serializable
|
||||
// even when discovery returns auth-only provider shells.
|
||||
@@ -154,6 +213,7 @@ beforeAll(async () => {
|
||||
unauthenticatedProviderParsed = JSON.parse(unauthenticatedProviderWritePlan.contents) as {
|
||||
providers?: Record<string, unknown>;
|
||||
};
|
||||
await planGoogleVertexProfileCatalog();
|
||||
});
|
||||
|
||||
describe("models-config", () => {
|
||||
@@ -466,67 +526,23 @@ describe("models-config", () => {
|
||||
});
|
||||
|
||||
it("keeps google-vertex static catalog rows when an auth profile supplies the API key", async () => {
|
||||
const agentDir = "/tmp/openclaw-google-vertex-models-profile";
|
||||
try {
|
||||
replaceRuntimeAuthProfileStoreSnapshots([
|
||||
{
|
||||
agentDir,
|
||||
store: {
|
||||
version: 1,
|
||||
profiles: {
|
||||
"google-vertex:default": {
|
||||
type: "api_key",
|
||||
provider: "google-vertex",
|
||||
keyRef: { source: "env", provider: "default", id: "GOOGLE_CLOUD_API_KEY" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
]);
|
||||
const plan = await planGoogleVertexProfileCatalog();
|
||||
|
||||
const plan = await planOpenClawModelsJsonWithDeps(
|
||||
{
|
||||
cfg: {
|
||||
agents: {
|
||||
defaults: {
|
||||
models: {
|
||||
"google-vertex/gemini-2.5-pro": {},
|
||||
},
|
||||
model: { primary: "google-vertex/gemini-2.5-pro" },
|
||||
},
|
||||
},
|
||||
models: { providers: {} },
|
||||
},
|
||||
agentDir,
|
||||
env: {},
|
||||
existingRaw: "",
|
||||
existingParsed: null,
|
||||
},
|
||||
{
|
||||
resolveImplicitProviders: async () => ({
|
||||
"google-vertex": createImplicitGoogleVertexProvider(),
|
||||
}),
|
||||
},
|
||||
);
|
||||
|
||||
expect(plan.action).toBe("write");
|
||||
if (plan.action !== "write") {
|
||||
throw new Error("Expected models.json write plan");
|
||||
}
|
||||
const parsed = JSON.parse(plan.contents) as {
|
||||
providers?: Record<
|
||||
string,
|
||||
{ apiKey?: string; api?: string; models?: Array<{ id?: string }> }
|
||||
>;
|
||||
};
|
||||
expect(parsed.providers?.["google-vertex"]?.api).toBe("google-vertex");
|
||||
expect(parsed.providers?.["google-vertex"]?.apiKey).toBe("GOOGLE_CLOUD_API_KEY");
|
||||
expect(parsed.providers?.["google-vertex"]?.models?.map((model) => model.id)).toEqual([
|
||||
"gemini-2.5-pro",
|
||||
]);
|
||||
} finally {
|
||||
clearRuntimeAuthProfileStoreSnapshots();
|
||||
expect(plan.action).toBe("write");
|
||||
if (plan.action !== "write") {
|
||||
throw new Error("Expected models.json write plan");
|
||||
}
|
||||
const parsed = JSON.parse(plan.contents) as {
|
||||
providers?: Record<
|
||||
string,
|
||||
{ apiKey?: string; api?: string; models?: Array<{ id?: string }> }
|
||||
>;
|
||||
};
|
||||
expect(parsed.providers?.["google-vertex"]?.api).toBe("google-vertex");
|
||||
expect(parsed.providers?.["google-vertex"]?.apiKey).toBe("GOOGLE_CLOUD_API_KEY");
|
||||
expect(parsed.providers?.["google-vertex"]?.models?.map((model) => model.id)).toEqual([
|
||||
"gemini-2.5-pro",
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps google-vertex static catalog rows when discovery supplies the ADC marker", async () => {
|
||||
|
||||
@@ -31,7 +31,7 @@ vi.mock("./model-auth-env-vars.js", () => {
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("../plugin-sdk/provider-http.js", () => ({
|
||||
vi.mock("./provider-attribution.js", () => ({
|
||||
// Only the CN endpoint advertises native streaming usage in this contract.
|
||||
resolveProviderRequestCapabilities: (params: { provider: string; baseUrl?: string }) => ({
|
||||
supportsNativeStreamingUsageCompat:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { loadExtensions } from "./loader.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
@@ -13,7 +13,9 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe("loadExtensions", () => {
|
||||
it("resolves plugin SDK subpaths in jiti-loaded extensions", async () => {
|
||||
let result: Awaited<ReturnType<typeof loadExtensions>>;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Extensions import both public SDK helpers and runtime helper subpaths; the
|
||||
// loader must route those aliases without package-manager involvement.
|
||||
const dir = await mkdtemp(join(tmpdir(), "openclaw-extension-sdk-"));
|
||||
@@ -41,8 +43,10 @@ export default async function(api) {
|
||||
`,
|
||||
);
|
||||
|
||||
const result = await loadExtensions([extensionPath], dir);
|
||||
result = await loadExtensions([extensionPath], dir);
|
||||
});
|
||||
|
||||
it("resolves plugin SDK subpaths in jiti-loaded extensions", () => {
|
||||
expect(result.errors).toEqual([]);
|
||||
expect(result.extensions).toHaveLength(1);
|
||||
expect(result.extensions[0]?.commands.has("sdk-subpath-probe")).toBe(true);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import type { Readable } from "node:stream";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { type ChildProcess, spawn, type ChildProcessByStdio } from "node:child_process";
|
||||
import { EventEmitter } from "node:events";
|
||||
import { PassThrough, type Readable } from "node:stream";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { waitForChildProcess } from "./child-process.js";
|
||||
|
||||
describe.skipIf(process.platform === "win32")("waitForChildProcess", () => {
|
||||
@@ -49,22 +50,29 @@ describe.skipIf(process.platform === "win32")("waitForChildProcess", () => {
|
||||
});
|
||||
|
||||
it("bounds draining from a continuously writing descendant", async () => {
|
||||
child = spawn(
|
||||
"/bin/sh",
|
||||
["-c", 'printf "HEAD\\n"; ( while :; do printf "TICK\\n"; sleep 0.03; done ) &'],
|
||||
{
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
detached: true,
|
||||
},
|
||||
);
|
||||
let output = "";
|
||||
child.stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const stdout = new PassThrough();
|
||||
const stderr = new PassThrough();
|
||||
const fakeChild = Object.assign(new EventEmitter(), {
|
||||
stdout,
|
||||
stderr,
|
||||
}) as unknown as ChildProcess;
|
||||
let output = "";
|
||||
stdout.on("data", (chunk: Buffer) => {
|
||||
output += chunk.toString();
|
||||
});
|
||||
|
||||
const startedAt = Date.now();
|
||||
await expect(waitForChildProcess(child)).resolves.toBe(0);
|
||||
expect(output).toContain("TICK");
|
||||
expect(Date.now() - startedAt).toBeLessThan(2_000);
|
||||
const completion = waitForChildProcess(fakeChild);
|
||||
fakeChild.emit("exit", 0);
|
||||
const writer = setInterval(() => stdout.write("TICK\n"), 30);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await expect(completion).resolves.toBe(0);
|
||||
clearInterval(writer);
|
||||
expect(output).toContain("TICK");
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { testing as cliBackendsTesting } from "../../agents/cli-backends.js";
|
||||
import {
|
||||
testing as embeddedRunTesting,
|
||||
@@ -254,7 +254,7 @@ function firstMockCallArg(mock: MockCallSource, label: string): unknown {
|
||||
return call[0];
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
function setupAgentRunnerMocks(): void {
|
||||
vi.useRealTimers();
|
||||
registerCliBackendsForTest();
|
||||
clearRuntimeConfigSnapshot();
|
||||
@@ -290,7 +290,9 @@ beforeEach(() => {
|
||||
model,
|
||||
}),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(setupAgentRunnerMocks);
|
||||
|
||||
afterEach(() => {
|
||||
cliBackendsTesting.resetDepsForTest();
|
||||
@@ -432,6 +434,17 @@ describe("runReplyAgent auto-compaction token update", () => {
|
||||
return { sessionKey, stored, usageEvent };
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
setupAgentRunnerMocks();
|
||||
await runBaseReplyWithAgentMeta({
|
||||
tmpPrefix: "openclaw-usage-warm-",
|
||||
agentMeta: {
|
||||
usage: { input: 10, output: 5, total: 15 },
|
||||
lastCallUsage: { input: 8, output: 2, total: 10 },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("updates totalTokens from lastCallUsage even without compaction", async () => {
|
||||
const { sessionKey, stored } = await runBaseReplyWithAgentMeta({
|
||||
tmpPrefix: "openclaw-usage-last-",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Tests session lifecycle commands for fork, reset, restart, and cleanup.
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../config/config.js";
|
||||
import type { SessionBindingRecord } from "../../infra/outbound/session-binding-service.js";
|
||||
import type { HandleCommandsParams } from "./commands-types.js";
|
||||
@@ -495,10 +495,8 @@ function expectIdleTimeoutSetReply(
|
||||
}
|
||||
|
||||
describe("/session idle and /session max-age", () => {
|
||||
beforeEach(async () => {
|
||||
if (!handleSessionCommand) {
|
||||
({ handleSessionCommand } = await import("./commands-session.js"));
|
||||
}
|
||||
beforeAll(async () => {
|
||||
({ handleSessionCommand } = await import("./commands-session.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1483,6 +1483,22 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
|
||||
};
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
const sessionEntry = createSessionEntry({ thinkingLevel: "xhigh" });
|
||||
await handleDirectiveOnly(
|
||||
createHandleParams({
|
||||
directives: parseInlineDirectives("/model opencode/claude-opus-4-7"),
|
||||
allowedModelKeys: new Set([...allowedModelKeys, "opencode/claude-opus-4-7"]),
|
||||
allowedModelCatalog: [
|
||||
...allowedModelCatalog,
|
||||
{ provider: "opencode", id: "claude-opus-4-7", name: "Claude Opus 4.7" },
|
||||
],
|
||||
sessionEntry,
|
||||
sessionStore: { [sessionKey]: sessionEntry },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("shows success message when session state is available", async () => {
|
||||
const directives = parseInlineDirectives("/model openai/gpt-4o");
|
||||
const sessionEntry = createSessionEntry();
|
||||
|
||||
@@ -20,6 +20,28 @@ installReplyRuntimeMocks(agentMocks);
|
||||
describe("getReplyFromConfig fast-path runtime", () => {
|
||||
beforeAll(async () => {
|
||||
({ getReplyFromConfig } = await loadGetReplyModuleForTest({ cacheKey: import.meta.url }));
|
||||
vi.stubEnv("OPENCLAW_TEST_FAST", "1");
|
||||
resetReplyRuntimeMocks(agentMocks);
|
||||
agentMocks.runEmbeddedAgent.mockResolvedValue(makeEmbeddedTextResult("warm runtime"));
|
||||
await withTempHome(async (home) => {
|
||||
await getReplyFromConfig(
|
||||
{
|
||||
Body: "warm runtime",
|
||||
BodyForAgent: "warm runtime",
|
||||
RawBody: "warm runtime",
|
||||
CommandBody: "warm runtime",
|
||||
From: "+1001",
|
||||
To: "+2000",
|
||||
SessionKey: "agent:main:whatsapp:+2000",
|
||||
Provider: "whatsapp",
|
||||
Surface: "whatsapp",
|
||||
ChatType: "direct",
|
||||
},
|
||||
{},
|
||||
makeReplyConfig(home) as OpenClawConfig,
|
||||
);
|
||||
});
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
|
||||
@@ -144,10 +144,18 @@ export function installPluginContractRegistryShard(params: ContractShardParams)
|
||||
installEmptyShardSuite("plugin contract registry shard");
|
||||
return;
|
||||
}
|
||||
const pluginCache = new Map<string, Awaited<ReturnType<typeof getBundledChannelPluginAsync>>>();
|
||||
beforeAll(async () => {
|
||||
await Promise.all(
|
||||
entries.map(async (entry) => {
|
||||
pluginCache.set(entry.id, await getBundledChannelPluginAsync(entry.id));
|
||||
}),
|
||||
);
|
||||
});
|
||||
for (const entry of entries) {
|
||||
describe(`${entry.id} plugin contract`, () => {
|
||||
it("satisfies the base channel plugin contract", async () => {
|
||||
const plugin = await getBundledChannelPluginAsync(entry.id);
|
||||
it("satisfies the base channel plugin contract", () => {
|
||||
const plugin = pluginCache.get(entry.id);
|
||||
if (!plugin) {
|
||||
throw new Error(`Missing bundled channel plugin for ${entry.id}`);
|
||||
}
|
||||
|
||||
@@ -170,10 +170,90 @@ function mockNodeGatewayPlanFixture(
|
||||
});
|
||||
}
|
||||
|
||||
async function buildPluginConfigExecSecretRefPlan(home: string) {
|
||||
mockNodeGatewayPlanFixture({ serviceEnvironment: { OPENCLAW_PORT: "3000" } });
|
||||
const pluginRoot = path.join(home, "acme-secrets");
|
||||
createSecurePluginRoot(pluginRoot);
|
||||
writeSecurePluginEntrypoint(path.join(pluginRoot, "secret-ref-resolver.js"));
|
||||
mocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "acme-secrets",
|
||||
origin: "global",
|
||||
rootDir: pluginRoot,
|
||||
secretProviderIntegrations: {
|
||||
"secret-store": {
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./secret-ref-resolver.js"],
|
||||
passEnv: ["ACME_SECRETS_TOKEN"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "acme-plugin",
|
||||
origin: "global",
|
||||
configContracts: {
|
||||
secretInputs: {
|
||||
paths: [{ path: "apiKey", expected: "string" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
return await buildGatewayInstallPlan({
|
||||
env: { HOME: home, ACME_SECRETS_TOKEN: "secret-token" },
|
||||
port: 3000,
|
||||
runtime: "node",
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
entries: {
|
||||
"acme-plugin": {
|
||||
enabled: true,
|
||||
config: {
|
||||
apiKey: {
|
||||
source: "exec",
|
||||
provider: "team-secrets",
|
||||
id: "providers/acme-plugin/apiKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"team-secrets": {
|
||||
source: "exec",
|
||||
pluginIntegration: {
|
||||
pluginId: "acme-secrets",
|
||||
integrationId: "secret-store",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("buildGatewayInstallPlan", () => {
|
||||
beforeAll(async () => {
|
||||
const { resolveConfigSecretTargetByPath } = await import("../secrets/target-registry.js");
|
||||
resolveConfigSecretTargetByPath(["channels", "discord", "token"]);
|
||||
const warmHome = fs.mkdtempSync(path.join(os.tmpdir(), "oc-plan-plugin-warm-"));
|
||||
try {
|
||||
await buildPluginConfigExecSecretRefPlan(warmHome);
|
||||
} finally {
|
||||
fs.rmSync(warmHome, { recursive: true, force: true });
|
||||
}
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
// Prevent tests from reading the developer's real ~/.openclaw/.env when
|
||||
@@ -614,82 +694,7 @@ describe("buildGatewayInstallPlan", () => {
|
||||
});
|
||||
|
||||
it("includes passEnv values for plugin config exec SecretRefs", async () => {
|
||||
mockNodeGatewayPlanFixture({
|
||||
serviceEnvironment: {
|
||||
OPENCLAW_PORT: "3000",
|
||||
},
|
||||
});
|
||||
const pluginRoot = path.join(isolatedHome, "acme-secrets");
|
||||
createSecurePluginRoot(pluginRoot);
|
||||
writeSecurePluginEntrypoint(path.join(pluginRoot, "secret-ref-resolver.js"));
|
||||
mocks.loadPluginManifestRegistry.mockReturnValue({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "acme-secrets",
|
||||
origin: "global",
|
||||
rootDir: pluginRoot,
|
||||
secretProviderIntegrations: {
|
||||
"secret-store": {
|
||||
source: "exec",
|
||||
command: "${node}",
|
||||
args: ["./secret-ref-resolver.js"],
|
||||
passEnv: ["ACME_SECRETS_TOKEN"],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
mocks.loadPluginManifestRegistryForPluginRegistry.mockReturnValue({
|
||||
diagnostics: [],
|
||||
plugins: [
|
||||
{
|
||||
id: "acme-plugin",
|
||||
origin: "global",
|
||||
configContracts: {
|
||||
secretInputs: {
|
||||
paths: [{ path: "apiKey", expected: "string" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const plan = await buildGatewayInstallPlan({
|
||||
env: isolatedPlanEnv({
|
||||
ACME_SECRETS_TOKEN: "secret-token",
|
||||
}),
|
||||
port: 3000,
|
||||
runtime: "node",
|
||||
config: {
|
||||
plugins: {
|
||||
enabled: true,
|
||||
entries: {
|
||||
"acme-plugin": {
|
||||
enabled: true,
|
||||
config: {
|
||||
apiKey: {
|
||||
source: "exec",
|
||||
provider: "team-secrets",
|
||||
id: "providers/acme-plugin/apiKey",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
secrets: {
|
||||
providers: {
|
||||
"team-secrets": {
|
||||
source: "exec",
|
||||
pluginIntegration: {
|
||||
pluginId: "acme-secrets",
|
||||
integrationId: "secret-store",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const plan = await buildPluginConfigExecSecretRefPlan(isolatedHome);
|
||||
|
||||
expect(plan.environment.ACME_SECRETS_TOKEN).toBe("secret-token");
|
||||
expect(plan.environment.OPENCLAW_SERVICE_MANAGED_ENV_KEYS).toBeUndefined();
|
||||
|
||||
@@ -1511,6 +1511,14 @@ describe("doctor config flow", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await collectDoctorWarnings({
|
||||
channels: {
|
||||
googlechat: {
|
||||
groupPolicy: "allowlist",
|
||||
accounts: { work: { groupPolicy: "allowlist" } },
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Doctor security tests cover security audit checks, config findings, and repair output.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { withTempDir } from "../test-helpers/temp-dir.js";
|
||||
|
||||
@@ -29,6 +29,11 @@ describe("noteSecurityWarnings gateway exposure", () => {
|
||||
let prevHome: string | undefined;
|
||||
let prevServiceKind: string | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
listReadOnlyChannelPluginsForConfigMock.mockReturnValue([]);
|
||||
await noteSecurityWarnings({ gateway: { bind: "loopback" } } as OpenClawConfig);
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
note.mockClear();
|
||||
listReadOnlyChannelPluginsForConfigMock.mockReset();
|
||||
|
||||
@@ -4,7 +4,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import process from "node:process";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { loadSessionEntry, updateSessionEntry, upsertSessionEntry } from "./session-accessor.js";
|
||||
|
||||
vi.mock("../config.js", async () => ({
|
||||
@@ -152,6 +152,24 @@ async function waitForChild(child: ReturnType<typeof spawn>): Promise<void> {
|
||||
}
|
||||
|
||||
describe("reply session initialization concurrency", () => {
|
||||
beforeAll(async () => {
|
||||
const sessionAccessorUrl = pathToFileURL(
|
||||
path.resolve("src/config/sessions/session-accessor.ts"),
|
||||
).href;
|
||||
const child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"--input-type=module",
|
||||
"--eval",
|
||||
`await import(${JSON.stringify(sessionAccessorUrl)})`,
|
||||
],
|
||||
{ stdio: ["ignore", "pipe", "pipe"] },
|
||||
);
|
||||
await waitForChild(child);
|
||||
});
|
||||
|
||||
it("commits after same-session activity from another process", async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-reply-init-"));
|
||||
const sessionAccessorUrl = pathToFileURL(
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import "./isolated-agent.mocks.js";
|
||||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
import { resolveDefaultSessionStorePath } from "../config/sessions.js";
|
||||
import { peekSystemEvents, resetSystemEventsForTest } from "../infra/system-events.js";
|
||||
@@ -49,6 +49,22 @@ async function runAnnounceTurn(params: {
|
||||
}
|
||||
|
||||
describe("runCronIsolatedAgentTurn cron delivery awareness", () => {
|
||||
beforeAll(async () => {
|
||||
setupIsolatedAgentTurnMocks();
|
||||
resetCompletedDirectCronDeliveriesForTests();
|
||||
resetSystemEventsForTest();
|
||||
await withTempCronHome(async (home) => {
|
||||
const storePath = await writeDefaultAgentSessionStoreEntries({});
|
||||
mockAgentPayloads([{ text: "warm runtime" }]);
|
||||
await runAnnounceTurn({
|
||||
home,
|
||||
storePath,
|
||||
sessionKey: "cron:warm-runtime",
|
||||
delivery: { mode: "announce", channel: "telegram", to: "123" },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
setupIsolatedAgentTurnMocks();
|
||||
resetCompletedDirectCronDeliveriesForTests();
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Direct delivery tests cover isolated agent delivery through core channel targets.
|
||||
import "./isolated-agent.mocks.js";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runSubagentAnnounceFlow } from "../agents/subagent-announce.js";
|
||||
import type { ChannelOutboundAdapter, ChannelOutboundContext } from "../channels/plugins/types.js";
|
||||
import type { CliDeps } from "../cli/deps.js";
|
||||
@@ -251,54 +251,70 @@ async function expectTelegramAnnounceDelivery({
|
||||
});
|
||||
}
|
||||
|
||||
function setupCoreChannelMocks(): void {
|
||||
setupIsolatedAgentTurnMocks({ fast: true });
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "slack",
|
||||
outbound: createCliDelegatingOutbound({ channel: "slack" }),
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "discord",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "discord",
|
||||
outbound: createCliDelegatingOutbound({
|
||||
channel: "discord",
|
||||
preferFinalAssistantVisibleText: true,
|
||||
}),
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "whatsapp",
|
||||
outbound: createCliDelegatingOutbound({
|
||||
channel: "whatsapp",
|
||||
deliveryMode: "gateway",
|
||||
resolveTarget: identityResolveTarget,
|
||||
}),
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "imessage",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "imessage",
|
||||
outbound: createCliDelegatingOutbound({ channel: "imessage" }),
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("runCronIsolatedAgentTurn core-channel direct delivery", () => {
|
||||
beforeEach(() => {
|
||||
setupIsolatedAgentTurnMocks({ fast: true });
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: "slack",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "slack",
|
||||
outbound: createCliDelegatingOutbound({ channel: "slack" }),
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "discord",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "discord",
|
||||
outbound: createCliDelegatingOutbound({
|
||||
channel: "discord",
|
||||
preferFinalAssistantVisibleText: true,
|
||||
}),
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "whatsapp",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "whatsapp",
|
||||
outbound: createCliDelegatingOutbound({
|
||||
channel: "whatsapp",
|
||||
deliveryMode: "gateway",
|
||||
resolveTarget: identityResolveTarget,
|
||||
}),
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
{
|
||||
pluginId: "imessage",
|
||||
plugin: createOutboundTestPlugin({
|
||||
id: "imessage",
|
||||
outbound: createCliDelegatingOutbound({ channel: "imessage" }),
|
||||
}),
|
||||
source: "test",
|
||||
},
|
||||
]),
|
||||
);
|
||||
beforeAll(async () => {
|
||||
setupCoreChannelMocks();
|
||||
const slack = CASES[0];
|
||||
if (!slack) {
|
||||
throw new Error("expected Slack channel case");
|
||||
}
|
||||
await expectCoreChannelAnnounceDelivery({
|
||||
testCase: slack,
|
||||
payloads: [{ text: "warm runtime" }],
|
||||
assertSend: () => {},
|
||||
});
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
beforeEach(setupCoreChannelMocks);
|
||||
|
||||
afterEach(() => {
|
||||
clearRuntimeConfigSnapshot();
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Hook content wrapping tests cover isolated agent message wrapping for hooks.
|
||||
import "./isolated-agent.mocks.js";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runEmbeddedAgent } from "../agents/embedded-agent.js";
|
||||
import { loadModelCatalog } from "../agents/model-catalog.js";
|
||||
import { makeCfg } from "./isolated-agent.test-harness.js";
|
||||
@@ -24,6 +24,19 @@ function lastEmbeddedPrompt(): string {
|
||||
}
|
||||
|
||||
describe("runCronIsolatedAgentTurn hook content wrapping", () => {
|
||||
beforeAll(async () => {
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
vi.spyOn(isolatedAgentRunRuntime, "resolveThinkingDefault").mockReturnValue("off");
|
||||
vi.mocked(loadModelCatalog).mockResolvedValue([]);
|
||||
await withTempHome(async (home) => {
|
||||
await runCronTurn(home, {
|
||||
jobPayload: { kind: "agentTurn", message: "warm runtime" },
|
||||
message: "warm runtime",
|
||||
sessionKey: "hook:gmail:warm-runtime",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.OPENCLAW_TEST_FAST = "1";
|
||||
vi.spyOn(isolatedAgentRunRuntime, "resolveThinkingDefault").mockReturnValue("off");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import "./isolated-agent.mocks.js";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import * as modelThinkingDefault from "../agents/model-thinking-default.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { runCronIsolatedAgentTurn } from "./isolated-agent.js";
|
||||
@@ -24,7 +24,10 @@ import {
|
||||
import { setupRunCronIsolatedAgentTurnSuite } from "./isolated-agent/run.suite-helpers.js";
|
||||
import {
|
||||
dispatchCronDeliveryMock,
|
||||
makeCronSession,
|
||||
mockRunCronFallbackPassthrough,
|
||||
resetRunCronIsolatedAgentTurnHarness,
|
||||
resolveCronSessionMock,
|
||||
runEmbeddedAgentMock,
|
||||
updateSessionStoreMock,
|
||||
} from "./isolated-agent/run.test-harness.js";
|
||||
@@ -61,6 +64,16 @@ function lastEmbeddedAgentCall(): {
|
||||
}
|
||||
|
||||
describe("runCronIsolatedAgentTurn session identity", () => {
|
||||
beforeAll(async () => {
|
||||
resetRunCronIsolatedAgentTurnHarness();
|
||||
resolveCronSessionMock.mockReturnValue(makeCronSession());
|
||||
vi.spyOn(modelThinkingDefault, "resolveThinkingDefault").mockReturnValue("off");
|
||||
mockRunCronFallbackPassthrough();
|
||||
await withTempHome(async (home) => {
|
||||
await runCronTurn(home, { jobPayload: DEFAULT_AGENT_TURN_PAYLOAD });
|
||||
});
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.spyOn(modelThinkingDefault, "resolveThinkingDefault").mockReturnValue("off");
|
||||
runEmbeddedAgentMock.mockClear();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Chat transcript parent-id tests protect gateway-injected assistant appends so
|
||||
// compaction history remains connected and transcript listeners receive updates.
|
||||
import fs from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||
import { onSessionTranscriptUpdate } from "../../sessions/transcript-events.js";
|
||||
import { appendInjectedAssistantMessageToTranscript } from "./chat-transcript-inject.js";
|
||||
import { createTranscriptFixtureSync } from "./chat.test-helpers.js";
|
||||
@@ -37,9 +37,54 @@ function readLastTranscriptRecord(transcriptPath: string): Record<string, unknow
|
||||
return JSON.parse(lines.at(-1) as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function readLastTranscriptRecordFromTail(transcriptPath: string): Record<string, unknown> {
|
||||
const size = fs.statSync(transcriptPath).size;
|
||||
const length = Math.min(size, 64 * 1024);
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
const fd = fs.openSync(transcriptPath, "r");
|
||||
let bytesRead = 0;
|
||||
try {
|
||||
bytesRead = fs.readSync(fd, buffer, 0, length, size - length);
|
||||
} finally {
|
||||
fs.closeSync(fd);
|
||||
}
|
||||
const lines = buffer.subarray(0, bytesRead).toString("utf8").trimEnd().split(/\r?\n/);
|
||||
return JSON.parse(lines.at(-1) as string) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Guardrail: Gateway-injected assistant transcript messages must attach to the
|
||||
// current leaf with a `parentId` and must not sever compaction history.
|
||||
describe("gateway chat.inject transcript writes", () => {
|
||||
let oversizedDir = "";
|
||||
let oversizedTranscriptPath = "";
|
||||
|
||||
beforeAll(() => {
|
||||
const fixture = createTranscriptFixtureSync({
|
||||
prefix: "openclaw-chat-inject-large-",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
oversizedDir = fixture.dir;
|
||||
oversizedTranscriptPath = fixture.transcriptPath;
|
||||
fs.appendFileSync(
|
||||
oversizedTranscriptPath,
|
||||
`${JSON.stringify({
|
||||
type: "message",
|
||||
id: "legacy-large-message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "x".repeat(9 * 1024 * 1024) }],
|
||||
},
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (oversizedDir) {
|
||||
fs.rmSync(oversizedDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("appends a agent session entry that includes parentId", async () => {
|
||||
const { dir, transcriptPath } = createTranscriptFixtureSync({
|
||||
prefix: "openclaw-chat-inject-",
|
||||
@@ -61,35 +106,15 @@ describe("gateway chat.inject transcript writes", () => {
|
||||
});
|
||||
|
||||
it("uses raw append for oversized append-only transcripts", async () => {
|
||||
const { dir, transcriptPath } = createTranscriptFixtureSync({
|
||||
prefix: "openclaw-chat-inject-large-",
|
||||
sessionId: "sess-1",
|
||||
});
|
||||
const sizeBefore = fs.statSync(oversizedTranscriptPath).size;
|
||||
const messageId = await appendHelloAndRequireId(oversizedTranscriptPath);
|
||||
const last = readLastTranscriptRecordFromTail(oversizedTranscriptPath);
|
||||
|
||||
try {
|
||||
fs.appendFileSync(
|
||||
transcriptPath,
|
||||
`${JSON.stringify({
|
||||
type: "message",
|
||||
id: "legacy-large-message",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "x".repeat(9 * 1024 * 1024) }],
|
||||
},
|
||||
})}\n`,
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const messageId = await appendHelloAndRequireId(transcriptPath);
|
||||
const last = readLastTranscriptRecord(transcriptPath);
|
||||
|
||||
expect(last.type).toBe("message");
|
||||
expect(last).toHaveProperty("id", messageId);
|
||||
expect(last).toHaveProperty("message");
|
||||
expect(Object.hasOwn(last, "parentId")).toBe(false);
|
||||
} finally {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
expect(fs.statSync(oversizedTranscriptPath).size).toBeGreaterThan(sizeBefore);
|
||||
expect(last.type).toBe("message");
|
||||
expect(last).toHaveProperty("id", messageId);
|
||||
expect(last).toHaveProperty("message");
|
||||
expect(Object.hasOwn(last, "parentId")).toBe(false);
|
||||
});
|
||||
|
||||
it("emits and returns the redacted injected assistant message", async () => {
|
||||
|
||||
@@ -1,20 +1,34 @@
|
||||
/**
|
||||
* Tests gateway plugin lifecycle loading, startup, and shutdown behavior.
|
||||
*/
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import { clearFallbackGatewayContext, createGatewaySubagentRuntime } from "./server-plugins.js";
|
||||
import { installGatewayTestHooks, startServer } from "./test-helpers.server.js";
|
||||
|
||||
installGatewayTestHooks();
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
afterEach(() => {
|
||||
clearFallbackGatewayContext();
|
||||
});
|
||||
|
||||
describe("gateway plugin fallback context lifecycle", () => {
|
||||
let started: Awaited<ReturnType<typeof startServer>> | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
const warm = await startServer();
|
||||
await warm.server.close({ reason: "warm fallback context lifecycle" });
|
||||
started = await startServer();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await started?.server.close({ reason: "fallback context lifecycle cleanup" });
|
||||
});
|
||||
|
||||
it("clears the fallback gateway context after server close", async () => {
|
||||
const runtime = createGatewaySubagentRuntime();
|
||||
const started = await startServer();
|
||||
if (!started) {
|
||||
throw new Error("expected gateway server to start");
|
||||
}
|
||||
|
||||
try {
|
||||
await expect(
|
||||
@@ -22,6 +36,7 @@ describe("gateway plugin fallback context lifecycle", () => {
|
||||
).resolves.toEqual({ messages: [] });
|
||||
} finally {
|
||||
await started.server.close({ reason: "fallback context lifecycle test done" });
|
||||
started = undefined;
|
||||
}
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -245,6 +245,8 @@ vi.mock("./server-tailscale.js", () => ({
|
||||
const { startGatewayPostAttachRuntime, startGatewaySidecars, testing } =
|
||||
await import("./server-startup-post-attach.js");
|
||||
const { STARTUP_UNAVAILABLE_GATEWAY_METHODS } = await import("./methods/core-descriptors.js");
|
||||
const { createGatewayCloseHandler } = await import("./server-close.js");
|
||||
const { createChatRunState } = await import("./server-chat-state.js");
|
||||
|
||||
type PostAttachParams = Parameters<typeof startGatewayPostAttachRuntime>[0];
|
||||
type PostAttachRuntimeDeps = NonNullable<Parameters<typeof startGatewayPostAttachRuntime>[1]>;
|
||||
@@ -1894,9 +1896,6 @@ describe("startGatewayPostAttachRuntime", () => {
|
||||
const postReadySidecars = [{ stop: vi.fn() }];
|
||||
const stopChannel = vi.fn(async () => {});
|
||||
const pluginServices = { stop: vi.fn(async () => {}) };
|
||||
const { createGatewayCloseHandler } = await import("./server-close.js");
|
||||
const { createChatRunState } = await import("./server-chat-state.js");
|
||||
|
||||
const close = createGatewayCloseHandler({
|
||||
bonjourStop: null,
|
||||
tailscaleCleanup: null,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Gateway startup web fetch bind tests.
|
||||
*/
|
||||
import http from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import { getFreePort, installGatewayTestHooks, startGatewayServer } from "./test-helpers.js";
|
||||
import { readClientResponseBody } from "./test-http-response.js";
|
||||
@@ -40,7 +40,7 @@ vi.mock("../secrets/runtime-web-tools-public-artifacts.runtime.js", async () =>
|
||||
};
|
||||
});
|
||||
|
||||
installGatewayTestHooks();
|
||||
installGatewayTestHooks({ scope: "suite" });
|
||||
|
||||
afterEach(() => {
|
||||
webFetchProviderDiscovery.resolveBundledWebFetchProvidersFromPublicArtifactsMock.mockClear();
|
||||
@@ -71,53 +71,58 @@ async function writeConfig(config: OpenClawConfig): Promise<void> {
|
||||
}
|
||||
|
||||
describe("gateway startup web fetch config", () => {
|
||||
it("binds HTTP with credential-free tools.web.fetch config without fetch provider discovery", async () => {
|
||||
const previousMinimal = process.env.OPENCLAW_TEST_MINIMAL_GATEWAY;
|
||||
let port: number;
|
||||
let previousMinimal: string | undefined;
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
|
||||
beforeAll(async () => {
|
||||
previousMinimal = process.env.OPENCLAW_TEST_MINIMAL_GATEWAY;
|
||||
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = "0";
|
||||
let server: Awaited<ReturnType<typeof startGatewayServer>> | undefined;
|
||||
try {
|
||||
await writeConfig({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
bind: "loopback",
|
||||
auth: { mode: "none" },
|
||||
},
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: [],
|
||||
entries: {},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
enabled: true,
|
||||
maxChars: 200_000,
|
||||
maxCharsCap: 2_000_000,
|
||||
},
|
||||
await writeConfig({
|
||||
gateway: {
|
||||
mode: "local",
|
||||
bind: "loopback",
|
||||
auth: { mode: "none" },
|
||||
},
|
||||
plugins: {
|
||||
enabled: true,
|
||||
allow: [],
|
||||
entries: {},
|
||||
},
|
||||
tools: {
|
||||
web: {
|
||||
fetch: {
|
||||
enabled: true,
|
||||
maxChars: 200_000,
|
||||
maxCharsCap: 2_000_000,
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
},
|
||||
} as OpenClawConfig);
|
||||
|
||||
const port = await getFreePort();
|
||||
server = await startGatewayServer(port, {
|
||||
auth: { mode: "none" },
|
||||
});
|
||||
port = await getFreePort();
|
||||
server = await startGatewayServer(port, {
|
||||
auth: { mode: "none" },
|
||||
});
|
||||
});
|
||||
|
||||
const response = await requestHealthz(port);
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
webFetchProviderDiscovery.resolveBundledWebFetchProvidersFromPublicArtifactsMock,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(webFetchProviderDiscovery.resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
if (server) {
|
||||
await server.close();
|
||||
}
|
||||
if (previousMinimal === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_MINIMAL_GATEWAY;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = previousMinimal;
|
||||
}
|
||||
afterAll(async () => {
|
||||
if (server) {
|
||||
await server.close();
|
||||
}
|
||||
if (previousMinimal === undefined) {
|
||||
delete process.env.OPENCLAW_TEST_MINIMAL_GATEWAY;
|
||||
} else {
|
||||
process.env.OPENCLAW_TEST_MINIMAL_GATEWAY = previousMinimal;
|
||||
}
|
||||
});
|
||||
|
||||
it("binds HTTP with credential-free tools.web.fetch config without fetch provider discovery", async () => {
|
||||
const response = await requestHealthz(port);
|
||||
expect(response.status).toBe(200);
|
||||
expect(
|
||||
webFetchProviderDiscovery.resolveBundledWebFetchProvidersFromPublicArtifactsMock,
|
||||
).not.toHaveBeenCalled();
|
||||
expect(webFetchProviderDiscovery.resolvePluginWebFetchProvidersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,7 +11,11 @@ import {
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import type { SessionEntry } from "../config/sessions.js";
|
||||
import { registerAgentRunContext, resetAgentRunContextForTest } from "../infra/agent-events.js";
|
||||
import { buildGatewaySessionInfo, listSessionsFromStore } from "./session-utils.js";
|
||||
import {
|
||||
buildGatewaySessionInfo,
|
||||
filterAndSortSessionEntries,
|
||||
listSessionsFromStore,
|
||||
} from "./session-utils.js";
|
||||
|
||||
const MAIN_SESSION_KEY = "agent:main:main";
|
||||
const MAIN_SESSION_ID = "sess-main";
|
||||
@@ -81,6 +85,17 @@ function createLegacyRuntimeStore(model: string): Record<string, SessionEntry> {
|
||||
};
|
||||
}
|
||||
|
||||
function buildLegacyRuntimeRow(cfg: OpenClawConfig, model: string) {
|
||||
const store = createLegacyRuntimeStore(model);
|
||||
return buildGatewaySessionInfo({
|
||||
cfg,
|
||||
storePath: "/tmp/sessions.json",
|
||||
store,
|
||||
key: MAIN_SESSION_KEY,
|
||||
entry: store[MAIN_SESSION_KEY],
|
||||
});
|
||||
}
|
||||
|
||||
function createOpenAiPricingConfig(params: {
|
||||
id: string;
|
||||
label: string;
|
||||
@@ -325,6 +340,20 @@ function childTranscriptEntry(sessionId: string, now: number): SessionEntry {
|
||||
}
|
||||
|
||||
describe("listSessionsFromStore search", () => {
|
||||
beforeAll(() => {
|
||||
listSessionsFromStore({
|
||||
cfg: createModelDefaultsConfig({ primary: "anthropic/claude-sonnet-4-6" }),
|
||||
store: {
|
||||
"agent:main:warm-runtime": {
|
||||
sessionId: "sess-warm-runtime",
|
||||
updatedAt: Date.now(),
|
||||
} as SessionEntry,
|
||||
},
|
||||
storePath: "/tmp/openclaw-session-search-warm.json",
|
||||
opts: { search: "anthropic" },
|
||||
});
|
||||
});
|
||||
|
||||
beforeAll(() => {
|
||||
listSessionsFromStore({
|
||||
cfg: createModelDefaultsConfig({ primary: "openai/gpt-5.4" }),
|
||||
@@ -460,14 +489,14 @@ describe("listSessionsFromStore search", () => {
|
||||
] as const;
|
||||
|
||||
for (const testCase of cases) {
|
||||
const result = listSearchSessions({
|
||||
const entries = filterAndSortSessionEntries({
|
||||
cfg,
|
||||
store,
|
||||
opts: { search: testCase.search },
|
||||
now,
|
||||
});
|
||||
|
||||
expect(result.sessions.map((session) => session.key)).toEqual([testCase.expectedKey]);
|
||||
expect(result.totalCount).toBe(1);
|
||||
expect(entries.map(([key]) => key)).toEqual([testCase.expectedKey]);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -539,14 +568,10 @@ describe("listSessionsFromStore search", () => {
|
||||
expectedProvider: "vercel-ai-gateway",
|
||||
},
|
||||
])("$name", ({ cfg, runtimeModel, expectedProvider }) => {
|
||||
const result = listSearchSessions({
|
||||
cfg,
|
||||
store: createLegacyRuntimeStore(runtimeModel),
|
||||
opts: {},
|
||||
});
|
||||
const row = buildLegacyRuntimeRow(cfg, runtimeModel);
|
||||
|
||||
expect(result.sessions[0]?.modelProvider).toBe(expectedProvider);
|
||||
expect(result.sessions[0]?.model).toBe(runtimeModel);
|
||||
expect(row.modelProvider).toBe(expectedProvider);
|
||||
expect(row.model).toBe(runtimeModel);
|
||||
});
|
||||
|
||||
test("exposes unknown totals when freshness is stale or missing", () => {
|
||||
|
||||
@@ -240,10 +240,10 @@ describe("OpenAI-compatible image provider helper", () => {
|
||||
});
|
||||
|
||||
it("accepts valid multi-image JSON above the generic provider JSON cap", async () => {
|
||||
const imageBytes = Buffer.alloc(6 * 1024 * 1024, 1);
|
||||
const imageBytes = Buffer.alloc(3 * 1024 * 1024 + 64 * 1024, 1);
|
||||
postJsonRequestMock.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
data: Array.from({ length: 3 }, () => ({
|
||||
data: Array.from({ length: 4 }, () => ({
|
||||
b64_json: imageBytes.toString("base64"),
|
||||
})),
|
||||
}),
|
||||
@@ -255,15 +255,16 @@ describe("OpenAI-compatible image provider helper", () => {
|
||||
provider: "sample",
|
||||
model: "sample-image",
|
||||
prompt: "large",
|
||||
count: 3,
|
||||
count: 4,
|
||||
cfg: {} as never,
|
||||
});
|
||||
|
||||
expect(result.images).toHaveLength(3);
|
||||
expect(result.images).toHaveLength(4);
|
||||
expect(result.images.map((image) => image.buffer.byteLength)).toEqual([
|
||||
imageBytes.byteLength,
|
||||
imageBytes.byteLength,
|
||||
imageBytes.byteLength,
|
||||
imageBytes.byteLength,
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Image runtime tests cover model-backed image routing, auth/profile handling,
|
||||
// provider payload transforms, and MiniMax/Copilot special paths.
|
||||
import path from "node:path";
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
@@ -138,11 +139,15 @@ const { describeImageWithModel } = await import("./image.js");
|
||||
describe("describeImageWithModel", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllEnvs();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
// Provider endpoint policy comes from manifests. Pin source manifests so a
|
||||
// prior local build cannot make this source-checkout test read partial dist output.
|
||||
vi.stubEnv("OPENCLAW_BUNDLED_PLUGINS_DIR", path.join(process.cwd(), "extensions"));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.clearAllMocks();
|
||||
fetchMock.mockResolvedValue({
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveAuthProfileDatabasePath,
|
||||
writePersistedAuthProfileStoreRaw,
|
||||
@@ -222,6 +222,17 @@ async function seedAuditFixture(fixture: AuditFixture): Promise<void> {
|
||||
describe("secrets audit", () => {
|
||||
let fixture: AuditFixture;
|
||||
|
||||
beforeAll(async () => {
|
||||
const warmFixture = await createAuditFixture();
|
||||
try {
|
||||
await seedAuditFixture(warmFixture);
|
||||
await runSecretsAudit({ env: warmFixture.env });
|
||||
} finally {
|
||||
closeOpenClawAgentDatabasesForTest();
|
||||
await fs.rm(warmFixture.rootDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function writeModelsProvider(
|
||||
overrides: Partial<{
|
||||
apiKey: unknown;
|
||||
|
||||
@@ -17,6 +17,7 @@ import { resolveConfigSecretTargetByPath } from "./target-registry.js";
|
||||
describe("secrets configure plan helpers", () => {
|
||||
beforeAll(() => {
|
||||
resolveConfigSecretTargetByPath(["channels", "telegram", "botToken"]);
|
||||
buildConfigureCandidates({} as OpenClawConfig);
|
||||
});
|
||||
|
||||
it("builds configure candidates from supported configure targets", () => {
|
||||
|
||||
+24
-11
@@ -267,22 +267,35 @@ describe("secret ref resolver", () => {
|
||||
const scriptPath = await writeForkingNoOutputScript(root);
|
||||
const pidPath = path.join(root, "forked.pid");
|
||||
let childPid: number | undefined;
|
||||
const nativeSetTimeout = globalThis.setTimeout;
|
||||
const noOutputTimeouts: Array<() => void> = [];
|
||||
const setTimeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockImplementation((callback, delay, ...args) => {
|
||||
if (delay === 1_000) {
|
||||
noOutputTimeouts.push(() => callback(...args));
|
||||
return nativeSetTimeout(() => undefined, 60_000);
|
||||
}
|
||||
return nativeSetTimeout(callback, delay, ...args);
|
||||
});
|
||||
|
||||
try {
|
||||
await expect(
|
||||
resolveExecSecret(scriptPath, {
|
||||
env: { NODE_BINARY: process.execPath, PID_FILE: pidPath },
|
||||
// The first no-output window must absorb shell spawn latency under
|
||||
// parallel-suite load; the script's readiness byte then pins the
|
||||
// killing silence window after the pid write.
|
||||
noOutputTimeoutMs: 1000,
|
||||
timeoutMs: 10_000,
|
||||
}),
|
||||
).rejects.toThrow('Exec provider "execmain" produced no output');
|
||||
|
||||
const resultPromise = resolveExecSecret(scriptPath, {
|
||||
env: { NODE_BINARY: process.execPath, PID_FILE: pidPath },
|
||||
// Preserve production-like startup headroom; the test fires the
|
||||
// re-armed timer only after the readiness byte arrives.
|
||||
noOutputTimeoutMs: 1_000,
|
||||
timeoutMs: 10_000,
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(noOutputTimeouts.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
childPid = await readPidFile(pidPath);
|
||||
noOutputTimeouts.at(-1)?.();
|
||||
await expect(resultPromise).rejects.toThrow('Exec provider "execmain" produced no output');
|
||||
expect(await waitForPidToExit(childPid, 5_000)).toBe(true);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
killPidIfAlive(childPid);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -633,6 +633,9 @@ function applyConfigForOpenClawTarget(
|
||||
if (entry.id === "plugins.entries.minimax.config.webSearch.apiKey") {
|
||||
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "minimax");
|
||||
}
|
||||
if (entry.id === "plugins.entries.parallel.config.webSearch.apiKey") {
|
||||
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "parallel");
|
||||
}
|
||||
if (entry.id === "plugins.entries.tavily.config.webSearch.apiKey") {
|
||||
setPathCreateStrict(config, ["tools", "web", "search", "provider"], "tavily");
|
||||
}
|
||||
|
||||
@@ -30,7 +30,12 @@ let compiledCoreOpenClawTargetState: {
|
||||
knownTargetIds: Set<string>;
|
||||
openClawCompiledSecretTargets: CompiledTargetRegistryEntry[];
|
||||
openClawTargetsById: Map<string, CompiledTargetRegistryEntry[]>;
|
||||
targetsByType: Map<string, CompiledTargetRegistryEntry[]>;
|
||||
planTargetsByType: Map<string, CompiledTargetRegistryEntry[]>;
|
||||
} | null = null;
|
||||
|
||||
let compiledCoreAuthProfileTargetState: {
|
||||
entries: CompiledTargetRegistryEntry[];
|
||||
entriesById: Map<string, CompiledTargetRegistryEntry[]>;
|
||||
} | null = null;
|
||||
|
||||
// Channel contract entries are process-stable; plugin install/reload is the owner of freshness.
|
||||
@@ -99,18 +104,33 @@ function getCompiledCoreOpenClawTargetState() {
|
||||
if (compiledCoreOpenClawTargetState) {
|
||||
return compiledCoreOpenClawTargetState;
|
||||
}
|
||||
const openClawCompiledSecretTargets = getCoreSecretTargetRegistry()
|
||||
.filter((entry) => entry.configFile === "openclaw.json")
|
||||
.map(compileTargetRegistryEntry);
|
||||
const compiledCoreSecretTargets = getCoreSecretTargetRegistry().map(compileTargetRegistryEntry);
|
||||
const openClawCompiledSecretTargets = compiledCoreSecretTargets.filter(
|
||||
(entry) => entry.configFile === "openclaw.json",
|
||||
);
|
||||
compiledCoreOpenClawTargetState = {
|
||||
knownTargetIds: new Set(openClawCompiledSecretTargets.map((entry) => entry.id)),
|
||||
knownTargetIds: new Set(compiledCoreSecretTargets.map((entry) => entry.id)),
|
||||
openClawCompiledSecretTargets,
|
||||
openClawTargetsById: buildConfigTargetIdIndex(openClawCompiledSecretTargets),
|
||||
targetsByType: buildTargetTypeIndex(openClawCompiledSecretTargets),
|
||||
planTargetsByType: buildTargetTypeIndex(compiledCoreSecretTargets),
|
||||
};
|
||||
return compiledCoreOpenClawTargetState;
|
||||
}
|
||||
|
||||
function getCompiledCoreAuthProfileTargetState() {
|
||||
if (compiledCoreAuthProfileTargetState) {
|
||||
return compiledCoreAuthProfileTargetState;
|
||||
}
|
||||
const entries = getCoreSecretTargetRegistry()
|
||||
.filter((entry) => entry.configFile === "auth-profiles.json")
|
||||
.map(compileTargetRegistryEntry);
|
||||
compiledCoreAuthProfileTargetState = {
|
||||
entries,
|
||||
entriesById: buildConfigTargetIdIndex(entries),
|
||||
};
|
||||
return compiledCoreAuthProfileTargetState;
|
||||
}
|
||||
|
||||
function getCompiledChannelOpenClawTargets(
|
||||
channelId: string,
|
||||
): CompiledTargetRegistryEntry[] | null {
|
||||
@@ -300,13 +320,16 @@ export function resolvePlanTargetAgainstRegistry(candidate: {
|
||||
providerId?: string;
|
||||
accountId?: string;
|
||||
}): ResolvedPlanTarget | null {
|
||||
const coreEntries = getCompiledCoreOpenClawTargetState().targetsByType.get(candidate.type);
|
||||
const coreEntries = getCompiledCoreOpenClawTargetState().planTargetsByType.get(candidate.type);
|
||||
if (coreEntries) {
|
||||
return resolvePlanTargetAgainstEntries(candidate, coreEntries);
|
||||
}
|
||||
const explicitChannelId =
|
||||
candidate.pathSegments[0] === "channels" ? (candidate.pathSegments[1]?.trim() ?? "") : "";
|
||||
if (explicitChannelId) {
|
||||
if (/[\\/:]/.test(explicitChannelId)) {
|
||||
return null;
|
||||
}
|
||||
const channelEntries = getCompiledChannelOpenClawTargets(explicitChannelId) ?? [];
|
||||
const channelTypeEntries = buildTargetTypeIndex(channelEntries).get(candidate.type);
|
||||
if (channelTypeEntries) {
|
||||
@@ -463,11 +486,11 @@ export function discoverAuthProfileSecretTargets(
|
||||
targetIds?: Iterable<string>,
|
||||
): DiscoveredConfigSecretTarget[] {
|
||||
const allowedTargetIds = normalizeAllowedTargetIds(targetIds);
|
||||
const registryState = getCompiledSecretTargetRegistryState();
|
||||
const registryState = getCompiledCoreAuthProfileTargetState();
|
||||
const discoveryEntries = resolveDiscoveryEntries({
|
||||
allowedTargetIds,
|
||||
defaultEntries: registryState.authProfilesCompiledSecretTargets,
|
||||
entriesById: registryState.authProfilesTargetsById,
|
||||
defaultEntries: registryState.entries,
|
||||
entriesById: registryState.entriesById,
|
||||
});
|
||||
return discoverSecretTargetsFromEntries(store, discoveryEntries);
|
||||
}
|
||||
@@ -476,7 +499,7 @@ export function discoverAuthProfileSecretTargets(
|
||||
* Lists auth-profile target entries that participate in plaintext/unresolved-ref audit.
|
||||
*/
|
||||
export function listAuthProfileSecretTargetEntries(): SecretTargetRegistryEntry[] {
|
||||
return getCompiledSecretTargetRegistryState().compiledSecretTargetRegistry.filter(
|
||||
return getCoreSecretTargetRegistry().filter(
|
||||
(entry) => entry.configFile === "auth-profiles.json" && entry.includeInAudit,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -121,4 +121,14 @@ describe("secret target registry fast path", () => {
|
||||
expect(target?.entry.id).toBe("channels.telegram.botToken");
|
||||
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("resolves auth-profile plan targets without loading plugin metadata", () => {
|
||||
const target = resolvePlanTargetAgainstRegistry({
|
||||
type: "auth-profiles.api_key.key",
|
||||
pathSegments: ["profiles", "openai:default", "key"],
|
||||
});
|
||||
|
||||
expect(target?.entry.id).toBe("auth-profiles.api_key.key");
|
||||
expect(loadPluginManifestRegistryMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import {
|
||||
killPidIfAlive,
|
||||
@@ -223,9 +223,20 @@ describe("runInstallPolicy", () => {
|
||||
const forkScriptPath = await writeForkingNoOutputScript(sourceDir);
|
||||
const pidPath = path.join(sourceDir, "forked.pid");
|
||||
let childPid: number | undefined;
|
||||
const nativeSetTimeout = globalThis.setTimeout;
|
||||
const noOutputTimeouts: Array<() => void> = [];
|
||||
const setTimeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockImplementation((callback, delay, ...args) => {
|
||||
if (delay === 1_000) {
|
||||
noOutputTimeouts.push(() => callback(...args));
|
||||
return nativeSetTimeout(() => undefined, 60_000);
|
||||
}
|
||||
return nativeSetTimeout(callback, delay, ...args);
|
||||
});
|
||||
|
||||
try {
|
||||
const result = await runInstallPolicy({
|
||||
const resultPromise = runInstallPolicy({
|
||||
config: {
|
||||
security: {
|
||||
installPolicy: {
|
||||
@@ -235,10 +246,9 @@ describe("runInstallPolicy", () => {
|
||||
command: forkScriptPath,
|
||||
env: { NODE_BINARY: process.execPath, PID_FILE: pidPath },
|
||||
allowInsecurePath: true,
|
||||
// The first no-output window must absorb shell spawn latency
|
||||
// under parallel-suite load; the script's readiness byte then
|
||||
// pins the killing silence window after the pid write.
|
||||
noOutputTimeoutMs: 1000,
|
||||
// Preserve production-like startup headroom; the test fires
|
||||
// the re-armed timer only after the readiness byte arrives.
|
||||
noOutputTimeoutMs: 1_000,
|
||||
timeoutMs: 10_000,
|
||||
},
|
||||
},
|
||||
@@ -246,11 +256,17 @@ describe("runInstallPolicy", () => {
|
||||
},
|
||||
request: baseRequest(sourceDir),
|
||||
});
|
||||
await vi.waitFor(() => {
|
||||
expect(noOutputTimeouts.length).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
childPid = await readPidFile(pidPath);
|
||||
noOutputTimeouts.at(-1)?.();
|
||||
const result = await resultPromise;
|
||||
|
||||
expect(result?.blocked?.reason).toContain("policy command produced no output");
|
||||
childPid = await readPidFile(pidPath);
|
||||
expect(await waitForPidToExit(childPid, 5_000)).toBe(true);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
killPidIfAlive(childPid);
|
||||
}
|
||||
},
|
||||
|
||||
@@ -34,6 +34,8 @@ function readRestartIntentRow(env: NodeJS.ProcessEnv) {
|
||||
|
||||
describe("gateway restart benchmark script", () => {
|
||||
let helpResult: ReturnType<typeof spawnSync>;
|
||||
let unknownArgsResult: ReturnType<typeof spawnSync>;
|
||||
let duplicateCaseResult: ReturnType<typeof spawnSync>;
|
||||
|
||||
beforeAll(() => {
|
||||
helpResult = spawnSync(
|
||||
@@ -48,6 +50,38 @@ describe("gateway restart benchmark script", () => {
|
||||
},
|
||||
},
|
||||
);
|
||||
unknownArgsResult = spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "scripts/bench-gateway-restart.ts", "--wat"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_NO_WARNINGS: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
duplicateCaseResult = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/bench-gateway-restart.ts",
|
||||
"--case",
|
||||
"skipChannels",
|
||||
"--case",
|
||||
"skipChannels",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_NO_WARNINGS: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("prints help without running benchmark cases", () => {
|
||||
@@ -104,51 +138,17 @@ describe("gateway restart benchmark script", () => {
|
||||
});
|
||||
|
||||
it("rejects unknown benchmark CLI args before checking platform or running cases", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
["--import", "tsx", "scripts/bench-gateway-restart.ts", "--wat"],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_NO_WARNINGS: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr.trim()).toBe("Unknown argument: --wat");
|
||||
expect(result.stderr).not.toContain("\n at ");
|
||||
expect(unknownArgsResult.status).toBe(1);
|
||||
expect(unknownArgsResult.stdout).toBe("");
|
||||
expect(unknownArgsResult.stderr.trim()).toBe("Unknown argument: --wat");
|
||||
expect(unknownArgsResult.stderr).not.toContain("\n at ");
|
||||
});
|
||||
|
||||
it("reports duplicate benchmark cases without a stack trace", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/bench-gateway-restart.ts",
|
||||
"--case",
|
||||
"skipChannels",
|
||||
"--case",
|
||||
"skipChannels",
|
||||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
...process.env,
|
||||
NODE_NO_WARNINGS: "1",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expect(result.stderr.trim()).toBe('Duplicate --case "skipChannels"');
|
||||
expect(result.stderr).not.toContain("\n at ");
|
||||
expect(duplicateCaseResult.status).toBe(1);
|
||||
expect(duplicateCaseResult.stdout).toBe("");
|
||||
expect(duplicateCaseResult.stderr.trim()).toBe('Duplicate --case "skipChannels"');
|
||||
expect(duplicateCaseResult.stderr).not.toContain("\n at ");
|
||||
});
|
||||
|
||||
it("guards the SIGUSR1 restart benchmark on Windows", () => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// Issue 78851 profiler CLI tests cover argument handling before work starts.
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
issue78851ModelResolutionHelpRequested,
|
||||
@@ -37,20 +36,9 @@ describe("issue 78851 model resolution profiler CLI", () => {
|
||||
});
|
||||
|
||||
it("rejects invalid arguments even when help is also requested", () => {
|
||||
const result = spawnSync(
|
||||
process.execPath,
|
||||
[
|
||||
"--import",
|
||||
"tsx",
|
||||
"scripts/perf/issue-78851-model-resolution.ts",
|
||||
"--wat",
|
||||
"--help",
|
||||
],
|
||||
{ encoding: "utf8" },
|
||||
expect(() => parseIssue78851ModelResolutionOptions(["--wat", "--help"])).toThrow(
|
||||
"Unknown argument: --wat",
|
||||
);
|
||||
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("Unknown argument: --wat");
|
||||
});
|
||||
|
||||
it("rejects duplicate value flags before starting the profiler", () => {
|
||||
|
||||
@@ -359,6 +359,17 @@ describe("prepare-extension-package-boundary-artifacts", () => {
|
||||
tempRoots.add(rootDir);
|
||||
const descendantPidPath = path.join(rootDir, "descendant.pid");
|
||||
let descendantPid = 0;
|
||||
const nativeSetTimeout = globalThis.setTimeout;
|
||||
let triggerStepTimeout: (() => void) | undefined;
|
||||
const setTimeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockImplementation((callback, timeout, ...args) => {
|
||||
if (timeout === 2_000 && !triggerStepTimeout) {
|
||||
triggerStepTimeout = () => callback(...args);
|
||||
return nativeSetTimeout(() => undefined, 60_000);
|
||||
}
|
||||
return nativeSetTimeout(callback, timeout, ...args);
|
||||
});
|
||||
const descendantScript = [
|
||||
"process.on('SIGTERM', () => {});",
|
||||
"setInterval(() => {}, 1000);",
|
||||
@@ -372,17 +383,20 @@ describe("prepare-extension-package-boundary-artifacts", () => {
|
||||
].join("\n");
|
||||
|
||||
try {
|
||||
// The parent records the descendant pid immediately after spawn, so this
|
||||
// needs headroom for only one Node boot under parallel-suite load.
|
||||
// The parent records the descendant pid at spawn time, before it
|
||||
// boots; fire the captured production timeout after that readiness proof.
|
||||
const command = runNodeStep("hung-group-prep", ["--eval", parentScript], 2_000);
|
||||
const expectedFailure = expect(command).rejects.toThrow(
|
||||
"hung-group-prep timed out after 2000ms",
|
||||
);
|
||||
descendantPid = Number.parseInt(await waitForFile(descendantPidPath, 4_000), 10);
|
||||
expect(triggerStepTimeout).toBeDefined();
|
||||
triggerStepTimeout?.();
|
||||
|
||||
await expectedFailure;
|
||||
await waitForDead(descendantPid, 2_000);
|
||||
} finally {
|
||||
setTimeoutSpy.mockRestore();
|
||||
if (descendantPid && isProcessAlive(descendantPid)) {
|
||||
process.kill(descendantPid, "SIGKILL");
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
buildGroupedTestComparison,
|
||||
buildGroupedTestReport,
|
||||
@@ -343,6 +343,62 @@ describe("scripts/test-group-report aggregation", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("continues allow-failures profiling after a config writes an empty JSON report", async () => {
|
||||
const tempDir = makeTempDir();
|
||||
try {
|
||||
const result = await runReportPlans({
|
||||
args: parseTestGroupReportArgs([
|
||||
"--config",
|
||||
"failed.config.ts",
|
||||
"--config",
|
||||
"passed.config.ts",
|
||||
"--allow-failures",
|
||||
"--no-rss",
|
||||
]),
|
||||
logDir: path.join(tempDir, "logs"),
|
||||
reportDir: path.join(tempDir, "reports"),
|
||||
runPlans: [
|
||||
{ config: "failed.config.ts", forwardedArgs: [], label: "failed" },
|
||||
{ config: "passed.config.ts", forwardedArgs: [], label: "passed" },
|
||||
],
|
||||
runVitestJsonReport: async (params: {
|
||||
config: string;
|
||||
label: string;
|
||||
logPath: string;
|
||||
reportPath: string;
|
||||
}) => {
|
||||
fs.mkdirSync(path.dirname(params.reportPath), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
params.reportPath,
|
||||
`${JSON.stringify({
|
||||
testResults: params.label === "failed" ? [] : [{ name: "passed.test.ts" }],
|
||||
})}\n`,
|
||||
"utf8",
|
||||
);
|
||||
return {
|
||||
config: params.config,
|
||||
elapsedMs: 10,
|
||||
label: params.label,
|
||||
logPath: params.logPath,
|
||||
maxRssBytes: null,
|
||||
reportPath: params.reportPath,
|
||||
status: params.label === "failed" ? 1 : 0,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
expect(result.failed).toBe(true);
|
||||
expect(result.exitCode).toBe(0);
|
||||
expect(result.runs.map((run) => [run.label, run.status])).toStrictEqual([
|
||||
["failed", 1],
|
||||
["passed", 0],
|
||||
]);
|
||||
expect(result.runEntries.map((entry) => entry.config)).toStrictEqual(["passed"]);
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("prints slow tests as soon as each config report completes", async () => {
|
||||
const tempDir = makeTempDir();
|
||||
const logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
@@ -1179,6 +1235,24 @@ describe("scripts/test-group-report child process guard", () => {
|
||||
});
|
||||
|
||||
describe("scripts/test-group-report run plans", () => {
|
||||
let serialFullSuitePlans: ReturnType<typeof resolveRunPlans> = [];
|
||||
let parallelFullSuitePlans: ReturnType<typeof resolveRunPlans> = [];
|
||||
|
||||
beforeAll(() => {
|
||||
withEnv(
|
||||
{
|
||||
OPENCLAW_TEST_PROJECTS_PARALLEL: undefined,
|
||||
OPENCLAW_TEST_PROJECTS_LEAF_SHARDS: undefined,
|
||||
},
|
||||
() => {
|
||||
serialFullSuitePlans = resolveRunPlans(parseTestGroupReportArgs(["--full-suite"]));
|
||||
},
|
||||
);
|
||||
withEnv({ OPENCLAW_TEST_PROJECTS_PARALLEL: "6" }, () => {
|
||||
parallelFullSuitePlans = resolveRunPlans(parseTestGroupReportArgs(["--full-suite"]));
|
||||
});
|
||||
});
|
||||
|
||||
it("isolates full-suite duration reports by default", () => {
|
||||
expect(resolveReportVitestArgs(parseTestGroupReportArgs(["--full-suite"]))).toEqual([
|
||||
"--isolate=true",
|
||||
@@ -1258,40 +1332,27 @@ describe("scripts/test-group-report run plans", () => {
|
||||
});
|
||||
|
||||
it("uses leaf configs for full-suite profiling without requiring parallel env", () => {
|
||||
withEnv(
|
||||
{
|
||||
OPENCLAW_TEST_PROJECTS_PARALLEL: undefined,
|
||||
OPENCLAW_TEST_PROJECTS_LEAF_SHARDS: undefined,
|
||||
},
|
||||
() => {
|
||||
const plans = resolveRunPlans(parseTestGroupReportArgs(["--full-suite"]));
|
||||
|
||||
expect(plans.map((plan) => plan.config)).not.toContain(
|
||||
"test/vitest/vitest.full-agentic.config.ts",
|
||||
);
|
||||
expect(plans.map((plan) => plan.config)).toContain(
|
||||
"test/vitest/vitest.agents-tools.config.ts",
|
||||
);
|
||||
},
|
||||
expect(serialFullSuitePlans.map((plan) => plan.config)).not.toContain(
|
||||
"test/vitest/vitest.full-agentic.config.ts",
|
||||
);
|
||||
expect(serialFullSuitePlans.map((plan) => plan.config)).toContain(
|
||||
"test/vitest/vitest.agents-tools.config.ts",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves full-suite shard file args and unique report labels", () => {
|
||||
withEnv({ OPENCLAW_TEST_PROJECTS_PARALLEL: "6" }, () => {
|
||||
const plans = resolveRunPlans(parseTestGroupReportArgs(["--full-suite"]));
|
||||
const gatewayServerPlans = plans.filter(
|
||||
(plan) => plan.config === "test/vitest/vitest.gateway-server.config.ts",
|
||||
);
|
||||
const gatewayServerPlans = parallelFullSuitePlans.filter(
|
||||
(plan) => plan.config === "test/vitest/vitest.gateway-server.config.ts",
|
||||
);
|
||||
|
||||
expect(gatewayServerPlans.length).toBeGreaterThan(1);
|
||||
expect(new Set(gatewayServerPlans.map((plan) => plan.label)).size).toBe(
|
||||
gatewayServerPlans.length,
|
||||
);
|
||||
expect(gatewayServerPlans.every((plan) => plan.forwardedArgs.length > 0)).toBe(true);
|
||||
expect(gatewayServerPlans.flatMap((plan) => plan.forwardedArgs)).toContain(
|
||||
"src/gateway/server.node-pairing-authz.test.ts",
|
||||
);
|
||||
});
|
||||
expect(gatewayServerPlans.length).toBeGreaterThan(1);
|
||||
expect(new Set(gatewayServerPlans.map((plan) => plan.label)).size).toBe(
|
||||
gatewayServerPlans.length,
|
||||
);
|
||||
expect(gatewayServerPlans.every((plan) => plan.forwardedArgs.length > 0)).toBe(true);
|
||||
expect(gatewayServerPlans.flatMap((plan) => plan.forwardedArgs)).toContain(
|
||||
"src/gateway/server.node-pairing-authz.test.ts",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -4130,19 +4130,23 @@ describe("scripts/test-projects full-suite sharding", () => {
|
||||
|
||||
it("can expand full-suite shards to project configs for perf experiments", () => {
|
||||
const gatewayServerConfig = "test/vitest/vitest.gateway-server.config.ts";
|
||||
const toolingConfig = "test/vitest/vitest.tooling.config.ts";
|
||||
const unitFastConfig = "test/vitest/vitest.unit-fast.config.ts";
|
||||
const plans = leafShardPlans;
|
||||
const toolingPlans = plans.filter((plan) => plan.config === toolingConfig);
|
||||
const unitFastPlans = plans.filter((plan) => plan.config === unitFastConfig);
|
||||
|
||||
if (leafShardHasGitGatewayListing) {
|
||||
expect(leafShardGatewayTreeReads).toEqual([]);
|
||||
}
|
||||
expect(leafShardPlans.map((plan) => plan.config)).toEqual([
|
||||
"test/vitest/vitest.unit-fast.config.ts",
|
||||
...unitFastPlans.map(() => unitFastConfig),
|
||||
"test/vitest/vitest.unit-fast-fake-timers.config.ts",
|
||||
"test/vitest/vitest.unit-src.config.ts",
|
||||
"test/vitest/vitest.unit-security.config.ts",
|
||||
"test/vitest/vitest.unit-support.config.ts",
|
||||
"test/vitest/vitest.boundary.config.ts",
|
||||
"test/vitest/vitest.tooling.config.ts",
|
||||
...toolingPlans.map(() => toolingConfig),
|
||||
"test/vitest/vitest.tooling-docker.config.ts",
|
||||
"test/vitest/vitest.tooling-isolated.config.ts",
|
||||
"test/vitest/vitest.contracts-channel-surface.config.ts",
|
||||
@@ -4235,9 +4239,36 @@ describe("scripts/test-projects full-suite sharding", () => {
|
||||
expect(gatewayTargets).toContain("src/gateway/server-network-runtime.e2e.test.ts");
|
||||
expect(gatewayTargets).not.toContain("src/gateway/gateway.test.ts");
|
||||
expect(Math.max(...gatewayChunkSizes) - Math.min(...gatewayChunkSizes)).toBeLessThanOrEqual(1);
|
||||
expect(plans.filter((plan) => plan.config !== gatewayServerConfig)).toEqual(
|
||||
const unitFastTargets = unitFastPlans.flatMap((plan) => plan.forwardedArgs);
|
||||
expect(unitFastPlans.length).toBeGreaterThan(10);
|
||||
expect(unitFastPlans.every((plan) => plan.forwardedArgs.length <= 70)).toBe(true);
|
||||
expect(unitFastTargets.length).toBeGreaterThan(1_000);
|
||||
expect(new Set(unitFastTargets).size).toBe(unitFastTargets.length);
|
||||
expect(unitFastTargets).toContain("extensions/canvas/src/host/server.state-dir.test.ts");
|
||||
expect(unitFastTargets).not.toContain("src/utils.test.ts");
|
||||
const toolingTargets = toolingPlans.flatMap((plan) => plan.forwardedArgs);
|
||||
expect(toolingPlans.length).toBeGreaterThan(1);
|
||||
expect(toolingPlans.every((plan) => plan.forwardedArgs.length <= 2)).toBe(true);
|
||||
expect(new Set(toolingTargets).size).toBe(toolingTargets.length);
|
||||
expect(toolingTargets).toContain("test/scripts/test-group-report.test.ts");
|
||||
expect(toolingTargets).toContain("src/scripts/control-ui-i18n-report.test.ts");
|
||||
expect(toolingTargets).not.toContain("test/scripts/docker-build-helper.test.ts");
|
||||
expect(toolingTargets).not.toContain("test/scripts/openclaw-e2e-instance.test.ts");
|
||||
expect(
|
||||
plans.filter(
|
||||
(plan) =>
|
||||
plan.config !== gatewayServerConfig &&
|
||||
plan.config !== toolingConfig &&
|
||||
plan.config !== unitFastConfig,
|
||||
),
|
||||
).toEqual(
|
||||
plans
|
||||
.filter((plan) => plan.config !== gatewayServerConfig)
|
||||
.filter(
|
||||
(plan) =>
|
||||
plan.config !== gatewayServerConfig &&
|
||||
plan.config !== toolingConfig &&
|
||||
plan.config !== unitFastConfig,
|
||||
)
|
||||
.map((plan) => ({
|
||||
config: plan.config,
|
||||
forwardedArgs: [],
|
||||
|
||||
Reference in New Issue
Block a user