test: trim slow agent fallback coverage

This commit is contained in:
Peter Steinberger
2026-05-06 00:52:26 +01:00
parent e428a2dfe2
commit cb42efb6e6
7 changed files with 522 additions and 642 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -509,6 +509,11 @@ function resolveImageFallbackDefaultProvider(cfg: OpenClawConfig | undefined): s
return DEFAULT_PROVIDER;
}
export const __testing = {
resolveFallbackCandidates,
resolveImageFallbackCandidates,
} as const;
function resolveFallbackCandidates(params: {
cfg: OpenClawConfig | undefined;
provider: string;

View File

@@ -25,6 +25,10 @@ import {
resolveModelRefFromString,
} from "./model-selection.js";
vi.mock("./provider-model-normalization.runtime.js", () => ({
normalizeProviderModelIdWithRuntime: () => undefined,
}));
const EXPLICIT_ALLOWLIST_CONFIG = {
agents: {
defaults: {
@@ -195,11 +199,14 @@ describe("model-selection", () => {
expected: { provider: string; model: string },
) => {
for (const raw of variants) {
expect(parseModelRef(raw, defaultProvider), raw).toEqual(expected);
expect(
parseModelRef(raw, defaultProvider, { allowPluginNormalization: false }),
raw,
).toEqual(expected);
}
};
it.each([
const parseModelRefCases = [
{
name: "parses explicit provider/model refs",
variants: ["anthropic/claude-3-5-sonnet"],
@@ -335,19 +342,30 @@ describe("model-selection", () => {
defaultProvider: "google-vertex",
expected: { provider: "google-vertex", model: "gemini-3.1-flash-lite-preview" },
},
])("$name", ({ variants, defaultProvider, expected }) => {
expectParsedModelVariants(variants, defaultProvider, expected);
];
it("parses and normalizes provider/model refs", () => {
for (const { variants, defaultProvider, expected } of parseModelRefCases) {
expectParsedModelVariants(variants, defaultProvider, expected);
}
});
it("round-trips normalized refs through modelKey", () => {
const parsed = parseModelRef(" opus-4.6 ", "anthropic");
const parsed = parseModelRef(" opus-4.6 ", "anthropic", {
allowPluginNormalization: false,
});
expect(parsed).toEqual({ provider: "anthropic", model: "claude-opus-4-6" });
expect(modelKey(parsed?.provider ?? "", parsed?.model ?? "")).toBe(
"anthropic/claude-opus-4-6",
);
});
it.each(["", " ", "/", "anthropic/", "/model"])("returns null for invalid ref %j", (raw) => {
expect(parseModelRef(raw, "anthropic")).toBeNull();
it("returns null for invalid refs", () => {
for (const raw of ["", " ", "/", "anthropic/", "/model"]) {
expect(
parseModelRef(raw, "anthropic", { allowPluginNormalization: false }),
raw,
).toBeNull();
}
});
});

View File

@@ -13,11 +13,13 @@ vi.mock("./auth-profiles/source-check.js", () => ({
}));
describe("Outcome/fallback runtime contract - Pi fallback classifier", () => {
it.each([
const fallbackClassificationCases = [
["empty", "empty_result"],
["reasoning-only", "reasoning_only_result"],
["planning-only", "planning_only_result"],
] as const)(
] as const;
it.each(fallbackClassificationCases)(
"maps harness classification %s to a format fallback code",
(classification, code) => {
expect(
@@ -38,54 +40,47 @@ describe("Outcome/fallback runtime contract - Pi fallback classifier", () => {
},
);
it.each([
["empty", "empty_result"],
["reasoning-only", "reasoning_only_result"],
["planning-only", "planning_only_result"],
] as const)(
"advances to the configured fallback after a classified GPT-5 %s terminal result",
async (classification, code) => {
const primary = createContractRunResult({
meta: {
durationMs: 1,
agentHarnessResultClassification: classification,
},
});
const fallback = createContractRunResult({
payloads: [{ text: "fallback ok" }],
meta: { durationMs: 1, finalAssistantVisibleText: "fallback ok" },
});
const run = vi.fn().mockResolvedValueOnce(primary).mockResolvedValueOnce(fallback);
it("advances to the configured fallback after a classified GPT-5 terminal result", async () => {
const primary = createContractRunResult({
meta: {
durationMs: 1,
agentHarnessResultClassification: "empty",
},
});
const fallback = createContractRunResult({
payloads: [{ text: "fallback ok" }],
meta: { durationMs: 1, finalAssistantVisibleText: "fallback ok" },
});
const run = vi.fn().mockResolvedValueOnce(primary).mockResolvedValueOnce(fallback);
const result = await runWithModelFallback({
cfg: createContractFallbackConfig() as unknown as OpenClawConfig,
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
run,
classifyResult: ({ provider, model, result }) =>
classifyEmbeddedPiRunResultForModelFallback({
provider,
model,
result,
}),
});
const result = await runWithModelFallback({
cfg: createContractFallbackConfig() as unknown as OpenClawConfig,
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
run,
classifyResult: ({ provider, model, result }) =>
classifyEmbeddedPiRunResultForModelFallback({
provider,
model,
result,
}),
});
expect(result.result).toBe(fallback);
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]).toEqual([
OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackProvider,
OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackModel,
]);
expect(result.attempts[0]).toMatchObject({
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
reason: "format",
code,
});
},
);
expect(result.result).toBe(fallback);
expect(run).toHaveBeenCalledTimes(2);
expect(run.mock.calls[1]).toEqual([
OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackProvider,
OUTCOME_FALLBACK_RUNTIME_CONTRACT.fallbackModel,
]);
expect(result.attempts[0]).toMatchObject({
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
reason: "format",
code: "empty_result",
});
});
it.each([
const nonFallbackCases = [
{
name: "intentional NO_REPLY",
result: createContractRunResult({
@@ -153,17 +148,24 @@ describe("Outcome/fallback runtime contract - Pi fallback classifier", () => {
}),
hasBlockReplyPipelineOutput: true,
},
])("does not fallback for $name", async (contractCase) => {
expect(
classifyEmbeddedPiRunResultForModelFallback({
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
result: contractCase.result,
hasDirectlySentBlockReply: contractCase.hasDirectlySentBlockReply,
hasBlockReplyPipelineOutput: contractCase.hasBlockReplyPipelineOutput,
}),
).toBeNull();
];
it("does not classify terminal results with visible output or side effects as fallbacks", () => {
for (const contractCase of nonFallbackCases) {
expect(
classifyEmbeddedPiRunResultForModelFallback({
provider: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryProvider,
model: OUTCOME_FALLBACK_RUNTIME_CONTRACT.primaryModel,
result: contractCase.result,
hasDirectlySentBlockReply: contractCase.hasDirectlySentBlockReply,
hasBlockReplyPipelineOutput: contractCase.hasBlockReplyPipelineOutput,
}),
).toBeNull();
}
});
it("keeps running on the primary when terminal output is not classified as fallback", async () => {
const contractCase = nonFallbackCases[0];
const run = vi.fn().mockResolvedValue(contractCase.result);
const result = await runWithModelFallback({
cfg: createContractFallbackConfig() as unknown as OpenClawConfig,

View File

@@ -1,7 +1,7 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../config/config.js";
import {
filterToolsByPolicy,
@@ -15,6 +15,15 @@ import {
import { createStubTool } from "./test-helpers/pi-tool-stubs.js";
import { providerAliasCases } from "./test-helpers/provider-alias-cases.js";
vi.mock("../channels/plugins/session-conversation.js", () => ({
resolveSessionConversation: ({ rawId }: { rawId: string }) => ({
id: rawId,
threadId: undefined,
baseConversationId: rawId,
parentConversationCandidates: [],
}),
}));
describe("pi-tools.policy", () => {
it("treats * in allow as allow-all", () => {
const tools = [createStubTool("read"), createStubTool("exec")];

View File

@@ -103,6 +103,19 @@ const mocks = vi.hoisted(() => ({
),
}));
const openClawToolsFactoryMocks = vi.hoisted(() => {
const tool = (name: string) => ({
name,
displaySummary: `${name} test stub`,
description: `${name} test stub`,
parameters: { type: "object", properties: {} },
execute: vi.fn(async () => ({ type: "json", data: { ok: true } })),
});
return {
tool,
};
});
vi.mock("../../infra/outbound/message-action-runner.js", async () => {
const actual = await vi.importActual<
typeof import("../../infra/outbound/message-action-runner.js")
@@ -130,6 +143,76 @@ vi.mock("../../cli/command-secret-targets.js", () => ({
getScopedChannelsCommandSecretTargets: mocks.getScopedChannelsCommandSecretTargets,
}));
vi.mock("./agents-list-tool.js", () => ({
createAgentsListTool: () => openClawToolsFactoryMocks.tool("agents"),
}));
vi.mock("./canvas-tool.js", () => ({
createCanvasTool: () => openClawToolsFactoryMocks.tool("canvas"),
}));
vi.mock("./cron-tool.js", () => ({
createCronTool: () => openClawToolsFactoryMocks.tool("cron"),
}));
vi.mock("./gateway-tool.js", () => ({
createGatewayTool: () => openClawToolsFactoryMocks.tool("gateway"),
}));
vi.mock("./heartbeat-response-tool.js", () => ({
createHeartbeatResponseTool: () => openClawToolsFactoryMocks.tool("heartbeat_response"),
}));
vi.mock("./image-generate-tool.js", () => ({
createImageGenerateTool: () => null,
}));
vi.mock("./image-tool.js", () => ({
createImageTool: () => null,
}));
vi.mock("./manifest-capability-availability.js", () => ({
hasSnapshotCapabilityAvailability: () => false,
hasSnapshotProviderEnvAvailability: () => false,
loadCapabilityMetadataSnapshot: () => ({ index: {}, plugins: [] }),
}));
vi.mock("./music-generate-tool.js", () => ({
createMusicGenerateTool: () => null,
}));
vi.mock("./nodes-tool.js", () => ({
createNodesTool: () => openClawToolsFactoryMocks.tool("nodes"),
}));
vi.mock("./pdf-tool.js", () => ({
createPdfTool: () => null,
}));
vi.mock("./session-status-tool.js", () => ({
createSessionStatusTool: () => openClawToolsFactoryMocks.tool("session_status"),
}));
vi.mock("./sessions-history-tool.js", () => ({
createSessionsHistoryTool: () => openClawToolsFactoryMocks.tool("sessions_history"),
}));
vi.mock("./sessions-list-tool.js", () => ({
createSessionsListTool: () => openClawToolsFactoryMocks.tool("sessions_list"),
}));
vi.mock("./sessions-send-tool.js", () => ({
createSessionsSendTool: () => openClawToolsFactoryMocks.tool("sessions_send"),
}));
vi.mock("./sessions-spawn-tool.js", () => ({
createSessionsSpawnTool: () => openClawToolsFactoryMocks.tool("sessions_spawn"),
}));
vi.mock("./sessions-yield-tool.js", () => ({
createSessionsYieldTool: () => openClawToolsFactoryMocks.tool("sessions_yield"),
}));
vi.mock("./subagents-tool.js", () => ({
createSubagentsTool: () => openClawToolsFactoryMocks.tool("subagents"),
}));
vi.mock("./tts-tool.js", () => ({
createTtsTool: () => openClawToolsFactoryMocks.tool("tts"),
}));
vi.mock("./update-plan-tool.js", () => ({
createUpdatePlanTool: () => openClawToolsFactoryMocks.tool("update_plan"),
}));
vi.mock("./video-generate-tool.js", () => ({
createVideoGenerateTool: () => null,
}));
vi.mock("./web-tools.js", () => ({
createWebFetchTool: () => openClawToolsFactoryMocks.tool("web_fetch"),
createWebSearchTool: () => openClawToolsFactoryMocks.tool("web_search"),
}));
function mockSendResult(overrides: { channel?: string; to?: string } = {}) {
mocks.runMessageAction.mockClear();
mocks.runMessageAction.mockResolvedValue({

View File

@@ -1,6 +1,6 @@
import fs from "node:fs/promises";
import path from "node:path";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import { withTempDir } from "../test-helpers/temp-dir.js";
import {
type ConfigDocBaselineEntry,
@@ -9,6 +9,14 @@ import {
writeConfigDocBaselineArtifacts,
} from "./doc-baseline.js";
vi.mock("./doc-baseline.runtime.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./doc-baseline.runtime.js")>();
return {
...actual,
collectBundledChannelConfigs: () => undefined,
};
});
describe("config doc baseline integration", () => {
let sharedRenderedPromise: Promise<
Awaited<ReturnType<typeof renderConfigDocBaselineArtifacts>>