test: move more runtime specs to fast lane

This commit is contained in:
Peter Steinberger
2026-04-28 04:23:42 +01:00
parent c205577f2c
commit 073b3fbf88
9 changed files with 431 additions and 310 deletions

View File

@@ -1,5 +1,5 @@
import type { SetSessionModeRequest } from "@agentclientprotocol/sdk";
import { describe, expect, it, vi } from "vitest";
import { describe, expect, it } from "vitest";
import type { GatewayClient } from "../gateway/client.js";
import { createInMemorySessionStore } from "./session.js";
import { AcpGatewayAgent } from "./translator.js";
@@ -24,38 +24,55 @@ function createAgentWithSession(request: GatewayClient["request"]) {
});
}
function createRequestRecorder(
handler: (...args: Parameters<GatewayClient["request"]>) => Promise<unknown>,
) {
const calls: Parameters<GatewayClient["request"]>[] = [];
const request = (async (...args: Parameters<GatewayClient["request"]>) => {
calls.push(args);
return handler(...args);
}) as GatewayClient["request"];
return { calls, request };
}
describe("acp setSessionMode", () => {
it("setSessionMode propagates gateway error", async () => {
const request = vi.fn(async () => {
const { calls, request } = createRequestRecorder(async () => {
throw new Error("gateway rejected mode change");
}) as GatewayClient["request"];
});
const agent = createAgentWithSession(request);
await expect(agent.setSessionMode(createSetSessionModeRequest("high"))).rejects.toThrow(
"gateway rejected mode change",
);
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "agent:main:main",
thinkingLevel: "high",
});
expect(calls).toContainEqual([
"sessions.patch",
{
key: "agent:main:main",
thinkingLevel: "high",
},
]);
});
it("setSessionMode succeeds when gateway accepts", async () => {
const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"];
const { calls, request } = createRequestRecorder(async () => ({ ok: true }));
const agent = createAgentWithSession(request);
await expect(agent.setSessionMode(createSetSessionModeRequest("low"))).resolves.toEqual({});
expect(request).toHaveBeenCalledWith("sessions.patch", {
key: "agent:main:main",
thinkingLevel: "low",
});
expect(calls).toContainEqual([
"sessions.patch",
{
key: "agent:main:main",
thinkingLevel: "low",
},
]);
});
it("setSessionMode returns early for empty modeId", async () => {
const request = vi.fn(async () => ({ ok: true })) as GatewayClient["request"];
const { calls, request } = createRequestRecorder(async () => ({ ok: true }));
const agent = createAgentWithSession(request);
await expect(agent.setSessionMode(createSetSessionModeRequest(""))).resolves.toEqual({});
expect(request).not.toHaveBeenCalled();
expect(calls).toEqual([]);
});
});

View File

@@ -10,6 +10,7 @@ import {
resolveCapabilityModelCandidates,
throwCapabilityGenerationFailure,
} from "../media-generation/runtime-shared.js";
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
import { parseImageGenerationModelRef } from "./model-ref.js";
import { resolveImageGenerationOverrides } from "./normalization.js";
import { getImageGenerationProvider, listImageGenerationProviders } from "./provider-registry.js";
@@ -18,23 +19,42 @@ import type { ImageGenerationResult } from "./types.js";
const log = createSubsystemLogger("image-generation");
export type ImageGenerationRuntimeDeps = {
getProvider?: typeof getImageGenerationProvider;
listProviders?: typeof listImageGenerationProviders;
getProviderEnvVars?: typeof getProviderEnvVars;
log?: Pick<typeof log, "warn">;
};
export type { GenerateImageParams, GenerateImageRuntimeResult } from "./runtime-types.js";
function buildNoImageGenerationModelConfiguredMessage(cfg: OpenClawConfig): string {
function buildNoImageGenerationModelConfiguredMessage(
cfg: OpenClawConfig,
deps: ImageGenerationRuntimeDeps,
): string {
const listProviders = deps.listProviders ?? listImageGenerationProviders;
return buildNoCapabilityModelConfiguredMessage({
capabilityLabel: "image-generation",
modelConfigKey: "imageGenerationModel",
providers: listImageGenerationProviders(cfg),
providers: listProviders(cfg),
getProviderEnvVars: deps.getProviderEnvVars,
});
}
export function listRuntimeImageGenerationProviders(params?: { config?: OpenClawConfig }) {
return listImageGenerationProviders(params?.config);
export function listRuntimeImageGenerationProviders(
params?: { config?: OpenClawConfig },
deps: ImageGenerationRuntimeDeps = {},
) {
return (deps.listProviders ?? listImageGenerationProviders)(params?.config);
}
export async function generateImage(
params: GenerateImageParams,
deps: ImageGenerationRuntimeDeps = {},
): Promise<GenerateImageRuntimeResult> {
const getProvider = deps.getProvider ?? getImageGenerationProvider;
const listProviders = deps.listProviders ?? listImageGenerationProviders;
const logger = deps.log ?? log;
const timeoutMs =
params.timeoutMs ??
resolveAgentModelTimeoutMsValue(params.cfg.agents?.defaults?.imageGenerationModel);
@@ -44,17 +64,17 @@ export async function generateImage(
modelOverride: params.modelOverride,
parseModelRef: parseImageGenerationModelRef,
agentDir: params.agentDir,
listProviders: listImageGenerationProviders,
listProviders,
});
if (candidates.length === 0) {
throw new Error(buildNoImageGenerationModelConfiguredMessage(params.cfg));
throw new Error(buildNoImageGenerationModelConfiguredMessage(params.cfg, deps));
}
const attempts: FallbackAttempt[] = [];
let lastError: unknown;
for (const candidate of candidates) {
const provider = getImageGenerationProvider(candidate.provider, params.cfg);
const provider = getProvider(candidate.provider, params.cfg);
if (!provider) {
const error = `No image-generation provider registered for ${candidate.provider}`;
attempts.push({
@@ -63,7 +83,7 @@ export async function generateImage(
error,
});
lastError = new Error(error);
log.warn(
logger.warn(
`image-generation candidate failed: ${candidate.provider}/${candidate.model}: ${error}`,
);
continue;
@@ -127,7 +147,7 @@ export async function generateImage(
status: described?.status,
code: described?.code,
});
log.warn(
logger.warn(
`image-generation candidate failed: ${candidate.provider}/${candidate.model}: ${
described?.message ?? formatErrorMessage(err)
}`,

View File

@@ -11,7 +11,7 @@ import {
import type { AgentModelConfig } from "../config/types.agents-shared.js";
import type { OpenClawConfig } from "../config/types.js";
import { formatErrorMessage } from "../infra/errors.js";
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
import { getProviderEnvVars as getDefaultProviderEnvVars } from "../secrets/provider-env-vars.js";
import { normalizeOptionalString } from "../shared/string-coerce.js";
import type {
MediaGenerationNormalizationMetadataInput,
@@ -504,7 +504,9 @@ export function buildNoCapabilityModelConfiguredMessage(params: {
modelConfigKey: string;
providers: Array<{ id: string; defaultModel?: string | null }>;
fallbackSampleRef?: string;
getProviderEnvVars?: typeof getDefaultProviderEnvVars;
}): string {
const getProviderEnvVars = params.getProviderEnvVars ?? getDefaultProviderEnvVars;
const sampleModel = params.providers.find(
(provider) =>
normalizeOptionalString(provider.id) && normalizeOptionalString(provider.defaultModel),

View File

@@ -1,24 +1,41 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
getMediaGenerationRuntimeMocks,
resetMusicGenerationRuntimeMocks,
} from "../../test/helpers/media-generation/runtime-module-mocks.js";
import type { OpenClawConfig } from "../config/types.js";
import { generateMusic, listRuntimeMusicGenerationProviders } from "./runtime.js";
import {
generateMusic,
listRuntimeMusicGenerationProviders,
type GenerateMusicParams,
type MusicGenerationRuntimeDeps,
} from "./runtime.js";
import type { MusicGenerationProvider } from "./types.js";
const mocks = getMediaGenerationRuntimeMocks();
let providers: MusicGenerationProvider[] = [];
let listedConfigs: Array<OpenClawConfig | undefined> = [];
const runtimeDeps: MusicGenerationRuntimeDeps = {
getProvider: (providerId) => providers.find((provider) => provider.id === providerId),
listProviders: (config) => {
listedConfigs.push(config);
return providers;
},
log: {
debug: () => {},
},
};
function runGenerateMusic(params: GenerateMusicParams) {
return generateMusic(params, runtimeDeps);
}
describe("music-generation runtime", () => {
beforeEach(() => {
resetMusicGenerationRuntimeMocks();
providers = [];
listedConfigs = [];
});
it("generates tracks through the active music-generation provider", async () => {
const authStore = { version: 1, profiles: {} } as const;
let seenAuthStore: unknown;
let seenTimeoutMs: number | undefined;
mocks.resolveAgentModelPrimaryValue.mockReturnValue("music-plugin/track-v1");
const provider: MusicGenerationProvider = {
id: "music-plugin",
capabilities: {},
@@ -37,9 +54,9 @@ describe("music-generation runtime", () => {
};
},
};
mocks.getMusicGenerationProvider.mockReturnValue(provider);
providers = [provider];
const result = await generateMusic({
const result = await runGenerateMusic({
cfg: {
agents: {
defaults: {
@@ -69,52 +86,31 @@ describe("music-generation runtime", () => {
});
it("auto-detects and falls through to another configured music-generation provider by default", async () => {
mocks.getMusicGenerationProvider.mockImplementation((providerId: string) => {
if (providerId === "google") {
return {
id: "google",
defaultModel: "lyria-3-clip-preview",
capabilities: {},
isConfigured: () => true,
async generateMusic() {
throw new Error("Google music generation response missing audio data");
},
};
}
if (providerId === "minimax") {
return {
id: "minimax",
defaultModel: "music-2.6",
capabilities: {},
isConfigured: () => true,
async generateMusic() {
return {
tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }],
model: "music-2.6",
};
},
};
}
return undefined;
});
mocks.listMusicGenerationProviders.mockReturnValue([
providers = [
{
id: "google",
defaultModel: "lyria-3-clip-preview",
capabilities: {},
isConfigured: () => true,
generateMusic: async () => ({ tracks: [] }),
async generateMusic() {
throw new Error("Google music generation response missing audio data");
},
},
{
id: "minimax",
defaultModel: "music-2.6",
capabilities: {},
isConfigured: () => true,
generateMusic: async () => ({ tracks: [] }),
async generateMusic() {
return {
tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }],
model: "music-2.6",
};
},
},
]);
];
const result = await generateMusic({
const result = await runGenerateMusic({
cfg: {} as OpenClawConfig,
prompt: "play a synth line",
});
@@ -131,7 +127,7 @@ describe("music-generation runtime", () => {
});
it("lists runtime music-generation providers through the provider registry", () => {
const providers: MusicGenerationProvider[] = [
const registryProviders: MusicGenerationProvider[] = [
{
id: "music-plugin",
defaultModel: "track-v1",
@@ -146,12 +142,12 @@ describe("music-generation runtime", () => {
}),
},
];
mocks.listMusicGenerationProviders.mockReturnValue(providers);
providers = registryProviders;
expect(listRuntimeMusicGenerationProviders({ config: {} as OpenClawConfig })).toEqual(
providers,
);
expect(mocks.listMusicGenerationProviders).toHaveBeenCalledWith({} as OpenClawConfig);
expect(
listRuntimeMusicGenerationProviders({ config: {} as OpenClawConfig }, runtimeDeps),
).toEqual(registryProviders);
expect(listedConfigs).toEqual([{} as OpenClawConfig]);
});
it("ignores unsupported optional overrides per provider and model", async () => {
@@ -163,34 +159,35 @@ describe("music-generation runtime", () => {
format?: string;
}
| undefined;
mocks.resolveAgentModelPrimaryValue.mockReturnValue("google/lyria-3-clip-preview");
mocks.getMusicGenerationProvider.mockReturnValue({
id: "google",
capabilities: {
generate: {
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
"lyria-3-clip-preview": ["mp3"],
providers = [
{
id: "google",
capabilities: {
generate: {
supportsLyrics: true,
supportsInstrumental: true,
supportsFormat: true,
supportedFormatsByModel: {
"lyria-3-clip-preview": ["mp3"],
},
},
},
generateMusic: async (req) => {
seenRequest = {
lyrics: req.lyrics,
instrumental: req.instrumental,
durationSeconds: req.durationSeconds,
format: req.format,
};
return {
tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }],
model: "lyria-3-clip-preview",
};
},
},
generateMusic: async (req) => {
seenRequest = {
lyrics: req.lyrics,
instrumental: req.instrumental,
durationSeconds: req.durationSeconds,
format: req.format,
};
return {
tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }],
model: "lyria-3-clip-preview",
};
},
});
];
const result = await generateMusic({
const result = await runGenerateMusic({
cfg: {
agents: {
defaults: {
@@ -226,40 +223,41 @@ describe("music-generation runtime", () => {
format?: string;
}
| undefined;
mocks.resolveAgentModelPrimaryValue.mockReturnValue("google/lyria-3-pro-preview");
mocks.getMusicGenerationProvider.mockReturnValue({
id: "google",
capabilities: {
generate: {
supportsLyrics: false,
supportsInstrumental: false,
supportsFormat: true,
supportedFormats: ["mp3"],
providers = [
{
id: "google",
capabilities: {
generate: {
supportsLyrics: false,
supportsInstrumental: false,
supportsFormat: true,
supportedFormats: ["mp3"],
},
edit: {
enabled: true,
maxInputImages: 1,
supportsLyrics: true,
supportsInstrumental: true,
supportsDuration: false,
supportsFormat: false,
},
},
edit: {
enabled: true,
maxInputImages: 1,
supportsLyrics: true,
supportsInstrumental: true,
supportsDuration: false,
supportsFormat: false,
generateMusic: async (req) => {
seenRequest = {
lyrics: req.lyrics,
instrumental: req.instrumental,
durationSeconds: req.durationSeconds,
format: req.format,
};
return {
tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }],
model: "lyria-3-pro-preview",
};
},
},
generateMusic: async (req) => {
seenRequest = {
lyrics: req.lyrics,
instrumental: req.instrumental,
durationSeconds: req.durationSeconds,
format: req.format,
};
return {
tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }],
model: "lyria-3-pro-preview",
};
},
});
];
const result = await generateMusic({
const result = await runGenerateMusic({
cfg: {
agents: {
defaults: {
@@ -293,27 +291,28 @@ describe("music-generation runtime", () => {
durationSeconds?: number;
}
| undefined;
mocks.resolveAgentModelPrimaryValue.mockReturnValue("minimax/music-2.6");
mocks.getMusicGenerationProvider.mockReturnValue({
id: "minimax",
capabilities: {
generate: {
supportsDuration: true,
maxDurationSeconds: 30,
providers = [
{
id: "minimax",
capabilities: {
generate: {
supportsDuration: true,
maxDurationSeconds: 30,
},
},
generateMusic: async (req) => {
seenRequest = {
durationSeconds: req.durationSeconds,
};
return {
tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }],
model: "music-2.6",
};
},
},
generateMusic: async (req) => {
seenRequest = {
durationSeconds: req.durationSeconds,
};
return {
tracks: [{ buffer: Buffer.from("mp3-bytes"), mimeType: "audio/mpeg" }],
model: "music-2.6",
};
},
});
];
const result = await generateMusic({
const result = await runGenerateMusic({
cfg: {
agents: {
defaults: {

View File

@@ -8,6 +8,7 @@ import {
resolveCapabilityModelCandidates,
throwCapabilityGenerationFailure,
} from "../media-generation/runtime-shared.js";
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
import { parseMusicGenerationModelRef } from "./model-ref.js";
import { resolveMusicGenerationOverrides } from "./normalization.js";
import { getMusicGenerationProvider, listMusicGenerationProviders } from "./provider-registry.js";
@@ -16,30 +17,45 @@ import type { MusicGenerationResult } from "./types.js";
const log = createSubsystemLogger("music-generation");
export type MusicGenerationRuntimeDeps = {
getProvider?: typeof getMusicGenerationProvider;
listProviders?: typeof listMusicGenerationProviders;
getProviderEnvVars?: typeof getProviderEnvVars;
log?: Pick<typeof log, "debug">;
};
export type { GenerateMusicParams, GenerateMusicRuntimeResult } from "./runtime-types.js";
export function listRuntimeMusicGenerationProviders(params?: { config?: OpenClawConfig }) {
return listMusicGenerationProviders(params?.config);
export function listRuntimeMusicGenerationProviders(
params?: { config?: OpenClawConfig },
deps: MusicGenerationRuntimeDeps = {},
) {
return (deps.listProviders ?? listMusicGenerationProviders)(params?.config);
}
export async function generateMusic(
params: GenerateMusicParams,
deps: MusicGenerationRuntimeDeps = {},
): Promise<GenerateMusicRuntimeResult> {
const getProvider = deps.getProvider ?? getMusicGenerationProvider;
const listProviders = deps.listProviders ?? listMusicGenerationProviders;
const logger = deps.log ?? log;
const candidates = resolveCapabilityModelCandidates({
cfg: params.cfg,
modelConfig: params.cfg.agents?.defaults?.musicGenerationModel,
modelOverride: params.modelOverride,
parseModelRef: parseMusicGenerationModelRef,
agentDir: params.agentDir,
listProviders: listMusicGenerationProviders,
listProviders,
});
if (candidates.length === 0) {
throw new Error(
buildNoCapabilityModelConfiguredMessage({
capabilityLabel: "music-generation",
modelConfigKey: "musicGenerationModel",
providers: listMusicGenerationProviders(params.cfg),
providers: listProviders(params.cfg),
fallbackSampleRef: "google/lyria-3-clip-preview",
getProviderEnvVars: deps.getProviderEnvVars,
}),
);
}
@@ -48,7 +64,7 @@ export async function generateMusic(
let lastError: unknown;
for (const candidate of candidates) {
const provider = getMusicGenerationProvider(candidate.provider, params.cfg);
const provider = getProvider(candidate.provider, params.cfg);
if (!provider) {
const error = `No music-generation provider registered for ${candidate.provider}`;
attempts.push({
@@ -110,7 +126,7 @@ export async function generateMusic(
model: candidate.model,
error: err,
});
log.debug(`music-generation candidate failed: ${candidate.provider}/${candidate.model}`);
logger.debug(`music-generation candidate failed: ${candidate.provider}/${candidate.model}`);
}
}

View File

@@ -1,29 +1,49 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { beforeEach, describe, expect, it } from "vitest";
import type { DebugProxySettings } from "./env.js";
import {
captureHttpExchange,
finalizeDebugProxyCapture,
initializeDebugProxyCapture,
type DebugProxyCaptureRuntimeDeps,
} from "./runtime.js";
const storeState = vi.hoisted(() => {
const events: Record<string, unknown>[] = [];
const store = {
upsertSession: vi.fn(),
endSession: vi.fn(),
recordEvent: vi.fn((event: Record<string, unknown>) => {
events.push(event);
}),
};
return {
events,
store,
closeDebugProxyCaptureStore: vi.fn(),
};
});
type StoreCall = { name: string; args: unknown[] };
vi.mock("./store.sqlite.js", () => ({
closeDebugProxyCaptureStore: storeState.closeDebugProxyCaptureStore,
getDebugProxyCaptureStore: () => storeState.store,
const settings: DebugProxySettings = {
enabled: true,
required: false,
dbPath: "/tmp/openclaw-proxy-runtime-test.sqlite",
blobDir: "/tmp/openclaw-proxy-runtime-test-blobs",
certDir: "/tmp/openclaw-proxy-runtime-test-certs",
sessionId: "runtime-test-session",
sourceProcess: "runtime-test",
};
const fetchTarget: typeof globalThis = {
...globalThis,
fetch: async () => new Response("{}", { status: 200 }),
};
const events: Record<string, unknown>[] = [];
const calls: StoreCall[] = [];
const store = {
upsertSession: (...args: unknown[]) => {
calls.push({ name: "upsertSession", args });
},
endSession: (...args: unknown[]) => {
calls.push({ name: "endSession", args });
},
recordEvent: (event: Record<string, unknown>) => {
events.push(event);
},
};
const deps: DebugProxyCaptureRuntimeDeps = {
fetchTarget,
getStore: () => store,
closeStore: () => {
calls.push({ name: "closeStore", args: [] });
},
persistEventPayload: (
_store: unknown,
payload: { data?: Buffer | string | null; contentType?: string },
@@ -32,87 +52,60 @@ vi.mock("./store.sqlite.js", () => ({
...(typeof payload.data === "string" ? { dataText: payload.data } : {}),
}),
safeJsonString: (value: unknown) => (value == null ? undefined : JSON.stringify(value)),
}));
};
describe("debug proxy runtime", () => {
const envKeys = [
"OPENCLAW_DEBUG_PROXY_ENABLED",
"OPENCLAW_DEBUG_PROXY_DB_PATH",
"OPENCLAW_DEBUG_PROXY_BLOB_DIR",
"OPENCLAW_DEBUG_PROXY_SESSION_ID",
"OPENCLAW_DEBUG_PROXY_SOURCE_PROCESS",
] as const;
const savedEnv = Object.fromEntries(envKeys.map((key) => [key, process.env[key]]));
const originalFetch = globalThis.fetch;
beforeEach(() => {
storeState.events.length = 0;
storeState.store.upsertSession.mockClear();
storeState.store.endSession.mockClear();
storeState.store.recordEvent.mockClear();
storeState.closeDebugProxyCaptureStore.mockClear();
process.env.OPENCLAW_DEBUG_PROXY_ENABLED = "1";
process.env.OPENCLAW_DEBUG_PROXY_DB_PATH = "/tmp/openclaw-proxy-runtime-test.sqlite";
process.env.OPENCLAW_DEBUG_PROXY_BLOB_DIR = "/tmp/openclaw-proxy-runtime-test-blobs";
process.env.OPENCLAW_DEBUG_PROXY_SESSION_ID = "runtime-test-session";
process.env.OPENCLAW_DEBUG_PROXY_SOURCE_PROCESS = "runtime-test";
});
afterEach(() => {
finalizeDebugProxyCapture();
globalThis.fetch = originalFetch;
for (const key of envKeys) {
const value = savedEnv[key];
if (value == null) {
delete process.env[key];
} else {
process.env[key] = value;
}
}
finalizeDebugProxyCapture(settings, deps);
events.length = 0;
calls.length = 0;
fetchTarget.fetch = async () => new Response("{}", { status: 200 });
});
it("captures ambient global fetch calls when debug proxy mode is enabled", async () => {
globalThis.fetch = vi.fn(async () => new Response("{}", { status: 200 })) as typeof fetch;
initializeDebugProxyCapture("test");
await globalThis.fetch("https://api.minimax.io/anthropic/messages", {
initializeDebugProxyCapture("test", settings, deps);
await fetchTarget.fetch("https://api.minimax.io/anthropic/messages", {
method: "POST",
headers: { "content-type": "application/json" },
body: '{"input":"hello"}',
});
await new Promise((resolve) => setImmediate(resolve));
finalizeDebugProxyCapture();
finalizeDebugProxyCapture(settings, deps);
const events = storeState.events.filter((event) => event.sessionId === "runtime-test-session");
expect(events.some((event) => event.host === "api.minimax.io")).toBe(true);
expect(events.some((event) => event.kind === "request")).toBe(true);
expect(events.some((event) => event.kind === "response")).toBe(true);
const sessionEvents = events.filter((event) => event.sessionId === "runtime-test-session");
expect(sessionEvents.some((event) => event.host === "api.minimax.io")).toBe(true);
expect(sessionEvents.some((event) => event.kind === "request")).toBe(true);
expect(sessionEvents.some((event) => event.kind === "response")).toBe(true);
});
it("redacts sensitive request and response headers before persistence", async () => {
initializeDebugProxyCapture("test");
captureHttpExchange({
url: "https://discord.com/api/v10/gateway/bot",
method: "GET",
requestHeaders: {
Authorization: "Bot discord-token",
Cookie: "sid=session-token",
"x-api-key": "provider-key",
"content-type": "application/json",
"x-safe": "visible",
},
response: new Response("{}", {
status: 200,
headers: {
initializeDebugProxyCapture("test", settings, deps);
captureHttpExchange(
{
url: "https://discord.com/api/v10/gateway/bot",
method: "GET",
requestHeaders: {
Authorization: "Bot discord-token",
Cookie: "sid=session-token",
"x-api-key": "provider-key",
"content-type": "application/json",
"set-cookie": "sid=response-token",
"x-safe": "visible",
},
}),
});
response: new Response("{}", {
status: 200,
headers: {
"content-type": "application/json",
"set-cookie": "sid=response-token",
},
}),
},
settings,
deps,
);
await new Promise((resolve) => setImmediate(resolve));
finalizeDebugProxyCapture();
finalizeDebugProxyCapture(settings, deps);
const request = storeState.events.find((event) => event.kind === "request");
const request = events.find((event) => event.kind === "request");
expect(JSON.parse(String(request?.headersJson))).toMatchObject({
Authorization: "[REDACTED]",
Cookie: "[REDACTED]",
@@ -120,7 +113,7 @@ describe("debug proxy runtime", () => {
"content-type": "application/json",
"x-safe": "visible",
});
const response = storeState.events.find((event) => event.kind === "response");
const response = events.find((event) => event.kind === "response");
expect(JSON.parse(String(response?.headersJson))).toMatchObject({
"content-type": "application/json",
"set-cookie": "[REDACTED]",

View File

@@ -47,6 +47,35 @@ type GlobalFetchPatchTarget = typeof globalThis & {
[DEBUG_PROXY_FETCH_PATCH_KEY]?: GlobalFetchPatchedState;
};
type DebugProxyCaptureStoreLike = Pick<
ReturnType<typeof getDebugProxyCaptureStore>,
"upsertSession" | "endSession" | "recordEvent"
>;
export type DebugProxyCaptureRuntimeDeps = {
getStore?: (dbPath: string, blobDir: string) => DebugProxyCaptureStoreLike;
closeStore?: typeof closeDebugProxyCaptureStore;
persistEventPayload?: (
store: DebugProxyCaptureStoreLike,
payload: Parameters<typeof persistEventPayload>[1],
) => ReturnType<typeof persistEventPayload>;
safeJsonString?: typeof safeJsonString;
fetchTarget?: typeof globalThis;
};
function resolveRuntimeDeps(deps: DebugProxyCaptureRuntimeDeps = {}) {
return {
getStore: deps.getStore ?? getDebugProxyCaptureStore,
closeStore: deps.closeStore ?? closeDebugProxyCaptureStore,
persistEventPayload:
deps.persistEventPayload ??
((store, payload) =>
persistEventPayload(store as ReturnType<typeof getDebugProxyCaptureStore>, payload)),
safeJsonString: deps.safeJsonString ?? safeJsonString,
fetchTarget: deps.fetchTarget ?? globalThis,
};
}
function protocolFromUrl(rawUrl: string): CaptureProtocol {
try {
const url = new URL(rawUrl);
@@ -129,51 +158,59 @@ function createHttpCaptureEventBase(params: {
};
}
function installDebugProxyGlobalFetchPatch(settings: DebugProxySettings): void {
if (typeof globalThis.fetch !== "function") {
function installDebugProxyGlobalFetchPatch(
settings: DebugProxySettings,
deps: DebugProxyCaptureRuntimeDeps = {},
): void {
const runtime = resolveRuntimeDeps(deps);
const fetchTarget = runtime.fetchTarget as GlobalFetchPatchTarget;
if (typeof fetchTarget.fetch !== "function") {
return;
}
const patched = globalThis as GlobalFetchPatchTarget;
if (patched[DEBUG_PROXY_FETCH_PATCH_KEY]) {
if (fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY]) {
return;
}
const originalFetch = globalThis.fetch.bind(globalThis);
patched[DEBUG_PROXY_FETCH_PATCH_KEY] = { originalFetch };
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const originalFetch = fetchTarget.fetch.bind(fetchTarget);
fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY] = { originalFetch };
fetchTarget.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = resolveUrlString(input);
try {
const response = await originalFetch(input, init);
if (url && /^https?:/i.test(url)) {
captureHttpExchange({
url,
method:
(typeof Request !== "undefined" && input instanceof Request
? input.method
: undefined) ??
init?.method ??
"GET",
requestHeaders:
(typeof Request !== "undefined" && input instanceof Request
? input.headers
: undefined) ?? (init?.headers as Headers | Record<string, string> | undefined),
requestBody:
(typeof Request !== "undefined" && input instanceof Request
? (input as Request & { body?: BodyInit | null }).body
: undefined) ??
(init as (RequestInit & { body?: BodyInit | null }) | undefined)?.body ??
null,
response,
transport: "http",
meta: {
captureOrigin: "global-fetch",
source: settings.sourceProcess,
captureHttpExchange(
{
url,
method:
(typeof Request !== "undefined" && input instanceof Request
? input.method
: undefined) ??
init?.method ??
"GET",
requestHeaders:
(typeof Request !== "undefined" && input instanceof Request
? input.headers
: undefined) ?? (init?.headers as Headers | Record<string, string> | undefined),
requestBody:
(typeof Request !== "undefined" && input instanceof Request
? (input as Request & { body?: BodyInit | null }).body
: undefined) ??
(init as (RequestInit & { body?: BodyInit | null }) | undefined)?.body ??
null,
response,
transport: "http",
meta: {
captureOrigin: "global-fetch",
source: settings.sourceProcess,
},
},
});
settings,
deps,
);
}
return response;
} catch (error) {
if (url && /^https?:/i.test(url)) {
const store = getDebugProxyCaptureStore(settings.dbPath, settings.blobDir);
const store = runtime.getStore(settings.dbPath, settings.blobDir);
const parsed = new URL(url);
store.recordEvent({
sessionId: settings.sessionId,
@@ -193,7 +230,7 @@ function installDebugProxyGlobalFetchPatch(settings: DebugProxySettings): void {
host: parsed.host,
path: `${parsed.pathname}${parsed.search}`,
errorText: error instanceof Error ? error.message : String(error),
metaJson: safeJsonString({ captureOrigin: "global-fetch" }),
metaJson: runtime.safeJsonString({ captureOrigin: "global-fetch" }),
});
}
throw error;
@@ -201,26 +238,30 @@ function installDebugProxyGlobalFetchPatch(settings: DebugProxySettings): void {
}) as typeof globalThis.fetch;
}
function uninstallDebugProxyGlobalFetchPatch(): void {
const patched = globalThis as GlobalFetchPatchTarget;
const state = patched[DEBUG_PROXY_FETCH_PATCH_KEY];
function uninstallDebugProxyGlobalFetchPatch(deps: DebugProxyCaptureRuntimeDeps = {}): void {
const fetchTarget = resolveRuntimeDeps(deps).fetchTarget as GlobalFetchPatchTarget;
const state = fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY];
if (!state) {
return;
}
globalThis.fetch = state.originalFetch;
delete patched[DEBUG_PROXY_FETCH_PATCH_KEY];
fetchTarget.fetch = state.originalFetch;
delete fetchTarget[DEBUG_PROXY_FETCH_PATCH_KEY];
}
export function isDebugProxyGlobalFetchPatchInstalled(): boolean {
return Boolean((globalThis as GlobalFetchPatchTarget)[DEBUG_PROXY_FETCH_PATCH_KEY]);
}
export function initializeDebugProxyCapture(mode: string, resolved?: DebugProxySettings): void {
export function initializeDebugProxyCapture(
mode: string,
resolved?: DebugProxySettings,
deps: DebugProxyCaptureRuntimeDeps = {},
): void {
const settings = resolved ?? resolveDebugProxySettings();
if (!settings.enabled) {
return;
}
getDebugProxyCaptureStore(settings.dbPath, settings.blobDir).upsertSession({
resolveRuntimeDeps(deps).getStore(settings.dbPath, settings.blobDir).upsertSession({
id: settings.sessionId,
startedAt: Date.now(),
mode,
@@ -230,41 +271,50 @@ export function initializeDebugProxyCapture(mode: string, resolved?: DebugProxyS
dbPath: settings.dbPath,
blobDir: settings.blobDir,
});
installDebugProxyGlobalFetchPatch(settings);
installDebugProxyGlobalFetchPatch(settings, deps);
}
export function finalizeDebugProxyCapture(resolved?: DebugProxySettings): void {
export function finalizeDebugProxyCapture(
resolved?: DebugProxySettings,
deps: DebugProxyCaptureRuntimeDeps = {},
): void {
const settings = resolved ?? resolveDebugProxySettings();
if (!settings.enabled) {
return;
}
getDebugProxyCaptureStore(settings.dbPath, settings.blobDir).endSession(settings.sessionId);
uninstallDebugProxyGlobalFetchPatch();
closeDebugProxyCaptureStore();
const runtime = resolveRuntimeDeps(deps);
runtime.getStore(settings.dbPath, settings.blobDir).endSession(settings.sessionId);
uninstallDebugProxyGlobalFetchPatch(deps);
runtime.closeStore();
}
export function captureHttpExchange(params: {
url: string;
method: string;
requestHeaders?: Headers | Record<string, string> | undefined;
requestBody?: BodyInit | Buffer | string | null;
response: Response;
transport?: "http" | "sse";
flowId?: string;
meta?: Record<string, unknown>;
}): void {
const settings = resolveDebugProxySettings();
export function captureHttpExchange(
params: {
url: string;
method: string;
requestHeaders?: Headers | Record<string, string> | undefined;
requestBody?: BodyInit | Buffer | string | null;
response: Response;
transport?: "http" | "sse";
flowId?: string;
meta?: Record<string, unknown>;
},
resolved?: DebugProxySettings,
deps: DebugProxyCaptureRuntimeDeps = {},
): void {
const settings = resolved ?? resolveDebugProxySettings();
if (!settings.enabled) {
return;
}
const store = getDebugProxyCaptureStore(settings.dbPath, settings.blobDir);
const runtime = resolveRuntimeDeps(deps);
const store = runtime.getStore(settings.dbPath, settings.blobDir);
const flowId = params.flowId ?? randomUUID();
const url = new URL(params.url);
const requestBody =
typeof params.requestBody === "string" || Buffer.isBuffer(params.requestBody)
? params.requestBody
: null;
const requestPayload = persistEventPayload(store, {
const requestPayload = runtime.persistEventPayload(store, {
data: requestBody,
contentType:
params.requestHeaders instanceof Headers
@@ -286,8 +336,8 @@ export function captureHttpExchange(params: {
params.requestHeaders instanceof Headers
? (params.requestHeaders.get("content-type") ?? undefined)
: params.requestHeaders?.["content-type"],
headersJson: safeJsonString(redactedCaptureHeaders(params.requestHeaders)),
metaJson: safeJsonString(params.meta),
headersJson: runtime.safeJsonString(redactedCaptureHeaders(params.requestHeaders)),
metaJson: runtime.safeJsonString(params.meta),
...requestPayload,
});
const cloneable =
@@ -313,9 +363,9 @@ export function captureHttpExchange(params: {
: undefined,
headersJson:
params.response.headers && typeof params.response.headers.entries === "function"
? safeJsonString(redactedCaptureHeaders(params.response.headers))
? runtime.safeJsonString(redactedCaptureHeaders(params.response.headers))
: undefined,
metaJson: safeJsonString({ ...params.meta, bodyCapture: "unavailable" }),
metaJson: runtime.safeJsonString({ ...params.meta, bodyCapture: "unavailable" }),
});
return;
}
@@ -323,7 +373,7 @@ export function captureHttpExchange(params: {
.clone()
.arrayBuffer()
.then((buffer) => {
const responsePayload = persistEventPayload(store, {
const responsePayload = runtime.persistEventPayload(store, {
data: Buffer.from(buffer),
contentType: params.response.headers.get("content-type") ?? undefined,
});
@@ -340,8 +390,8 @@ export function captureHttpExchange(params: {
}),
status: params.response.status,
contentType: params.response.headers.get("content-type") ?? undefined,
headersJson: safeJsonString(redactedCaptureHeaders(params.response.headers)),
metaJson: safeJsonString(params.meta),
headersJson: runtime.safeJsonString(redactedCaptureHeaders(params.response.headers)),
metaJson: runtime.safeJsonString(params.meta),
...responsePayload,
});
})

View File

@@ -8,6 +8,7 @@ import {
resolveCapabilityModelCandidates,
throwCapabilityGenerationFailure,
} from "../media-generation/runtime-shared.js";
import { getProviderEnvVars } from "../secrets/provider-env-vars.js";
import { resolveVideoGenerationModeCapabilities } from "./capabilities.js";
import { resolveVideoGenerationSupportedDurations } from "./duration-support.js";
import { parseVideoGenerationModelRef } from "./model-ref.js";
@@ -17,6 +18,14 @@ import type { GenerateVideoParams, GenerateVideoRuntimeResult } from "./runtime-
import type { VideoGenerationProviderOptionType, VideoGenerationResult } from "./types.js";
const log = createSubsystemLogger("video-generation");
export type VideoGenerationRuntimeDeps = {
getProvider?: typeof getVideoGenerationProvider;
listProviders?: typeof listVideoGenerationProviders;
getProviderEnvVars?: typeof getProviderEnvVars;
log?: Pick<typeof log, "debug" | "warn">;
};
export type { GenerateVideoParams, GenerateVideoRuntimeResult } from "./runtime-types.js";
/**
@@ -73,31 +82,43 @@ function validateProviderOptionsAgainstDeclaration(params: {
return undefined;
}
function buildNoVideoGenerationModelConfiguredMessage(cfg: OpenClawConfig): string {
function buildNoVideoGenerationModelConfiguredMessage(
cfg: OpenClawConfig,
deps: VideoGenerationRuntimeDeps,
): string {
const listProviders = deps.listProviders ?? listVideoGenerationProviders;
return buildNoCapabilityModelConfiguredMessage({
capabilityLabel: "video-generation",
modelConfigKey: "videoGenerationModel",
providers: listVideoGenerationProviders(cfg),
providers: listProviders(cfg),
getProviderEnvVars: deps.getProviderEnvVars,
});
}
export function listRuntimeVideoGenerationProviders(params?: { config?: OpenClawConfig }) {
return listVideoGenerationProviders(params?.config);
export function listRuntimeVideoGenerationProviders(
params?: { config?: OpenClawConfig },
deps: VideoGenerationRuntimeDeps = {},
) {
return (deps.listProviders ?? listVideoGenerationProviders)(params?.config);
}
export async function generateVideo(
params: GenerateVideoParams,
deps: VideoGenerationRuntimeDeps = {},
): Promise<GenerateVideoRuntimeResult> {
const getProvider = deps.getProvider ?? getVideoGenerationProvider;
const listProviders = deps.listProviders ?? listVideoGenerationProviders;
const logger = deps.log ?? log;
const candidates = resolveCapabilityModelCandidates({
cfg: params.cfg,
modelConfig: params.cfg.agents?.defaults?.videoGenerationModel,
modelOverride: params.modelOverride,
parseModelRef: parseVideoGenerationModelRef,
agentDir: params.agentDir,
listProviders: listVideoGenerationProviders,
listProviders,
});
if (candidates.length === 0) {
throw new Error(buildNoVideoGenerationModelConfiguredMessage(params.cfg));
throw new Error(buildNoVideoGenerationModelConfiguredMessage(params.cfg, deps));
}
const attempts: FallbackAttempt[] = [];
@@ -110,12 +131,12 @@ export async function generateVideo(
// passed over without flooding logs on long fallback chains.
if (!skipWarnEmitted) {
skipWarnEmitted = true;
log.warn(`video-generation candidate skipped: ${reason}`);
logger.warn(`video-generation candidate skipped: ${reason}`);
}
};
for (const candidate of candidates) {
const provider = getVideoGenerationProvider(candidate.provider, params.cfg);
const provider = getProvider(candidate.provider, params.cfg);
if (!provider) {
const error = `No video-generation provider registered for ${candidate.provider}`;
attempts.push({
@@ -151,7 +172,7 @@ export async function generateVideo(
attempts.push({ provider: candidate.provider, model: candidate.model, error });
lastError = new Error(error);
warnOnFirstSkip(error);
log.debug(
logger.debug(
`video-generation candidate skipped (audio capability): ${candidate.provider}/${candidate.model}`,
);
continue;
@@ -188,7 +209,7 @@ export async function generateVideo(
attempts.push({ provider: candidate.provider, model: candidate.model, error: mismatch });
lastError = new Error(mismatch);
warnOnFirstSkip(mismatch);
log.debug(
logger.debug(
`video-generation candidate skipped (providerOptions): ${candidate.provider}/${candidate.model}`,
);
continue;
@@ -226,7 +247,7 @@ export async function generateVideo(
attempts.push({ provider: candidate.provider, model: candidate.model, error });
lastError = new Error(error);
warnOnFirstSkip(error);
log.debug(
logger.debug(
`video-generation candidate skipped (duration capability): ${candidate.provider}/${candidate.model}`,
);
continue;
@@ -299,7 +320,7 @@ export async function generateVideo(
model: candidate.model,
error: err,
});
log.debug(`video-generation candidate failed: ${candidate.provider}/${candidate.model}`);
logger.debug(`video-generation candidate failed: ${candidate.provider}/${candidate.model}`);
}
}

View File

@@ -68,6 +68,7 @@ export const forcedUnitFastTestFiles = [
"src/acp/persistent-bindings.test.ts",
"src/acp/server.startup.test.ts",
"src/acp/translator.session-rate-limit.test.ts",
"src/acp/translator.set-session-mode.test.ts",
"src/browser-lifecycle-cleanup.test.ts",
"src/canvas-host/server.test.ts",
"src/crestodian/audit.test.ts",
@@ -96,6 +97,7 @@ export const forcedUnitFastTestFiles = [
"src/memory-host-sdk/host/embeddings-remote-fetch.test.ts",
"src/memory-host-sdk/host/post-json.test.ts",
"src/memory-host-sdk/host/session-files.test.ts",
"src/music-generation/runtime.test.ts",
"src/mcp/channel-server.shutdown-unhandled-rejection.test.ts",
"src/node-host/invoke-system-run-plan.test.ts",
"src/node-host/invoke-system-run.test.ts",
@@ -104,6 +106,7 @@ export const forcedUnitFastTestFiles = [
"src/pairing/setup-code.test.ts",
"src/plugin-activation-boundary.test.ts",
"src/plugin-sdk/memory-host-events.test.ts",
"src/proxy-capture/runtime.test.ts",
"src/proxy-capture/store.sqlite.test.ts",
"src/security/audit-exec-surface.test.ts",
"src/security/audit-extra.async.test.ts",