mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-31 13:42:50 +00:00
Manual metadata generation is a non-empty agents.metadataGeneration.providers list, but the resolver always appended the built-in defaults and the current agent selection after it. When a manual model failed, the prompt was retried on providers the user never chose. Manual now resolves to exactly the configured list; Automatic is unchanged. Commit message and PR text generation swallowed failures without logging, so a broken manual model was invisible. The git metadata generator now logs the final failure with each attempt's provider, model and error, and the structured generation loop receives the session logger for per-attempt warnings, matching the branch name path.
333 lines
10 KiB
TypeScript
333 lines
10 KiB
TypeScript
import { describe, it, expect } from "vitest";
|
|
import { z } from "zod";
|
|
import {
|
|
getStructuredAgentResponse,
|
|
generateStructuredAgentResponseWithFallback,
|
|
StructuredAgentFallbackError,
|
|
StructuredAgentResponseError,
|
|
type AgentCaller,
|
|
} from "./agent-response-loop.js";
|
|
import type { AgentManager } from "./agent-manager.js";
|
|
|
|
function createScriptedCaller(responses: string[]) {
|
|
const prompts: string[] = [];
|
|
const caller: AgentCaller = async (prompt) => {
|
|
prompts.push(prompt);
|
|
const index = prompts.length - 1;
|
|
return responses[index] ?? responses[responses.length - 1] ?? "";
|
|
};
|
|
return { caller, prompts };
|
|
}
|
|
|
|
describe("getStructuredAgentResponse", () => {
|
|
it("retries on invalid JSON and succeeds", async () => {
|
|
const schema = z.object({ title: z.string() });
|
|
const { caller, prompts } = createScriptedCaller(["not json", '{"title":"ok"}']);
|
|
|
|
const result = await getStructuredAgentResponse({
|
|
caller,
|
|
prompt: "Provide a title",
|
|
schema,
|
|
maxRetries: 2,
|
|
});
|
|
|
|
expect(result).toEqual({ title: "ok" });
|
|
expect(prompts).toHaveLength(2);
|
|
expect(prompts[1]).toContain("Previous response was invalid");
|
|
expect(prompts[1]).toContain("Invalid JSON");
|
|
});
|
|
|
|
it("retries on schema mismatch with validation errors", async () => {
|
|
const schema = z.object({ count: z.number() });
|
|
const { caller, prompts } = createScriptedCaller(['{"count":"nope"}', '{"count":2}']);
|
|
|
|
const result = await getStructuredAgentResponse({
|
|
caller,
|
|
prompt: "Provide a count",
|
|
schema,
|
|
maxRetries: 2,
|
|
});
|
|
|
|
expect(result).toEqual({ count: 2 });
|
|
expect(prompts).toHaveLength(2);
|
|
expect(prompts[1]).toContain("validation errors");
|
|
expect(prompts[1]).toContain("count");
|
|
});
|
|
|
|
it("fails after maxRetries with last response and validation errors", async () => {
|
|
const schema = z.object({ count: z.number() });
|
|
const { caller } = createScriptedCaller(['{"count":"nope"}', '{"count":"still"}']);
|
|
|
|
try {
|
|
await getStructuredAgentResponse({
|
|
caller,
|
|
prompt: "Provide a count",
|
|
schema,
|
|
maxRetries: 1,
|
|
});
|
|
throw new Error("Expected getStructuredAgentResponse to throw");
|
|
} catch (error) {
|
|
expect(error).toBeInstanceOf(StructuredAgentResponseError);
|
|
expect(error).toEqual(
|
|
expect.objectContaining({
|
|
name: "StructuredAgentResponseError",
|
|
lastResponse: '{"count":"still"}',
|
|
validationErrors: expect.arrayContaining([expect.stringContaining("count")]),
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
|
|
it("retries on raw JSON Schema validation errors and succeeds", async () => {
|
|
const schema = {
|
|
type: "object",
|
|
properties: {
|
|
name: { type: "string" },
|
|
},
|
|
required: ["name"],
|
|
additionalProperties: false,
|
|
};
|
|
const { caller, prompts } = createScriptedCaller(['{"name": 123}', '{"name": "ok"}']);
|
|
|
|
const result = await getStructuredAgentResponse({
|
|
caller,
|
|
prompt: "Provide a name",
|
|
schema,
|
|
maxRetries: 2,
|
|
});
|
|
|
|
expect(result).toEqual({ name: "ok" });
|
|
expect(prompts).toHaveLength(2);
|
|
expect(prompts[1]).toContain("validation errors");
|
|
});
|
|
|
|
it("extracts JSON from markdown code fences", async () => {
|
|
const schema = z.object({ message: z.string() });
|
|
const { caller } = createScriptedCaller(['```json\n{"message": "hello"}\n```']);
|
|
|
|
const result = await getStructuredAgentResponse({
|
|
caller,
|
|
prompt: "Provide a message",
|
|
schema,
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(result).toEqual({ message: "hello" });
|
|
});
|
|
|
|
it("extracts JSON from plain code fences", async () => {
|
|
const schema = z.object({ value: z.number() });
|
|
const { caller } = createScriptedCaller(['```\n{"value": 42}\n```']);
|
|
|
|
const result = await getStructuredAgentResponse({
|
|
caller,
|
|
prompt: "Provide a value",
|
|
schema,
|
|
maxRetries: 0,
|
|
});
|
|
|
|
expect(result).toEqual({ value: 42 });
|
|
});
|
|
});
|
|
|
|
describe("generateStructuredAgentResponseWithFallback", () => {
|
|
const schema = z.object({ summary: z.string() });
|
|
|
|
function createManager(
|
|
availability: Array<{ provider: string; available: boolean; error: string | null }>,
|
|
): AgentManager & { checkedProviders: string[] } {
|
|
const checkedProviders: string[] = [];
|
|
const availabilityByProvider = new Map(availability.map((entry) => [entry.provider, entry]));
|
|
return {
|
|
checkedProviders,
|
|
getProviderAvailability: async (provider: string) => {
|
|
checkedProviders.push(provider);
|
|
return (
|
|
availabilityByProvider.get(provider) ?? {
|
|
provider,
|
|
available: false,
|
|
error: `No client registered for provider '${provider}'`,
|
|
}
|
|
);
|
|
},
|
|
} as unknown as AgentManager & { checkedProviders: string[] };
|
|
}
|
|
|
|
it("uses the first available provider in the waterfall", async () => {
|
|
const calls: Array<{ provider: string; model?: string; persistSession?: boolean }> = [];
|
|
const manager = createManager([
|
|
{ provider: "claude", available: true, error: null },
|
|
{ provider: "codex", available: true, error: null },
|
|
{ provider: "opencode", available: true, error: null },
|
|
]);
|
|
|
|
const result = await generateStructuredAgentResponseWithFallback({
|
|
manager,
|
|
cwd: "/tmp/project",
|
|
prompt: "Return JSON",
|
|
schema,
|
|
providers: [
|
|
{ provider: "claude", model: "haiku" },
|
|
{ provider: "codex", model: "gpt-5.4-mini" },
|
|
],
|
|
persistSession: false,
|
|
runner: async (options) => {
|
|
calls.push({
|
|
provider: options.agentConfig.provider,
|
|
model: options.agentConfig.model ?? undefined,
|
|
persistSession: options.persistSession,
|
|
});
|
|
return { summary: "ok" };
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ summary: "ok" });
|
|
expect(calls).toEqual([{ provider: "claude", model: "haiku", persistSession: false }]);
|
|
expect(manager.checkedProviders).toEqual(["claude"]);
|
|
});
|
|
|
|
it("skips unavailable providers and uses the next available one", async () => {
|
|
const calls: Array<{ provider: string; model?: string }> = [];
|
|
const manager = createManager([
|
|
{ provider: "claude", available: false, error: "missing auth" },
|
|
{ provider: "codex", available: true, error: null },
|
|
{ provider: "opencode", available: true, error: null },
|
|
]);
|
|
|
|
const result = await generateStructuredAgentResponseWithFallback({
|
|
manager,
|
|
cwd: "/tmp/project",
|
|
prompt: "Return JSON",
|
|
schema,
|
|
providers: [
|
|
{ provider: "claude", model: "haiku" },
|
|
{ provider: "codex", model: "gpt-5.4-mini" },
|
|
],
|
|
runner: async (options) => {
|
|
calls.push({
|
|
provider: options.agentConfig.provider,
|
|
model: options.agentConfig.model ?? undefined,
|
|
});
|
|
return { summary: "ok" };
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ summary: "ok" });
|
|
expect(calls).toEqual([{ provider: "codex", model: "gpt-5.4-mini" }]);
|
|
expect(manager.checkedProviders).toEqual(["claude", "codex"]);
|
|
});
|
|
|
|
it("falls back when an available provider fails", async () => {
|
|
const calls: Array<{ provider: string; model?: string }> = [];
|
|
const manager = createManager([
|
|
{ provider: "claude", available: true, error: null },
|
|
{ provider: "codex", available: true, error: null },
|
|
{ provider: "opencode", available: true, error: null },
|
|
]);
|
|
|
|
const result = await generateStructuredAgentResponseWithFallback({
|
|
manager,
|
|
cwd: "/tmp/project",
|
|
prompt: "Return JSON",
|
|
schema,
|
|
providers: [
|
|
{ provider: "claude", model: "haiku" },
|
|
{ provider: "codex", model: "gpt-5.4-mini" },
|
|
],
|
|
runner: async (options) => {
|
|
calls.push({
|
|
provider: options.agentConfig.provider,
|
|
model: options.agentConfig.model ?? undefined,
|
|
});
|
|
if (options.agentConfig.provider === "claude") {
|
|
throw new Error("claude failed");
|
|
}
|
|
return { summary: "ok" };
|
|
},
|
|
});
|
|
|
|
expect(result).toEqual({ summary: "ok" });
|
|
expect(calls).toEqual([
|
|
{ provider: "claude", model: "haiku" },
|
|
{ provider: "codex", model: "gpt-5.4-mini" },
|
|
]);
|
|
expect(manager.checkedProviders).toEqual(["claude", "codex"]);
|
|
});
|
|
|
|
it("logs the provider, model and error of every failed attempt", async () => {
|
|
const warnings: Array<{ obj: object; msg?: string }> = [];
|
|
const runnerError = new Error("connection refused");
|
|
const manager = createManager([
|
|
{ provider: "local-llm", available: true, error: null },
|
|
{ provider: "opencode", available: false, error: "not installed" },
|
|
]);
|
|
|
|
await expect(
|
|
generateStructuredAgentResponseWithFallback({
|
|
manager,
|
|
cwd: "/tmp/project",
|
|
prompt: "Return JSON",
|
|
schema,
|
|
schemaName: "BranchName",
|
|
providers: [
|
|
{ provider: "local-llm", model: "qwen-local" },
|
|
{ provider: "opencode", model: "minimax-m3" },
|
|
],
|
|
logger: {
|
|
info: () => undefined,
|
|
warn: (obj, msg) => warnings.push({ obj, msg }),
|
|
},
|
|
runner: async () => {
|
|
throw runnerError;
|
|
},
|
|
}),
|
|
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
|
|
|
|
expect(warnings).toEqual([
|
|
{
|
|
obj: {
|
|
err: runnerError,
|
|
provider: "local-llm",
|
|
model: "qwen-local",
|
|
schemaName: "BranchName",
|
|
},
|
|
msg: "Structured generation: provider failed",
|
|
},
|
|
{
|
|
obj: {
|
|
provider: "opencode",
|
|
model: "minimax-m3",
|
|
schemaName: "BranchName",
|
|
reason: "not installed",
|
|
},
|
|
msg: "Structured generation: skipping unavailable provider",
|
|
},
|
|
]);
|
|
});
|
|
|
|
it("throws a fallback error when all providers are unavailable or fail", async () => {
|
|
const manager = createManager([
|
|
{ provider: "claude", available: false, error: "missing auth" },
|
|
{ provider: "codex", available: true, error: null },
|
|
{ provider: "opencode", available: false, error: "not installed" },
|
|
]);
|
|
|
|
await expect(
|
|
generateStructuredAgentResponseWithFallback({
|
|
manager,
|
|
cwd: "/tmp/project",
|
|
prompt: "Return JSON",
|
|
schema,
|
|
providers: [
|
|
{ provider: "claude", model: "haiku" },
|
|
{ provider: "codex", model: "gpt-5.4-mini" },
|
|
{ provider: "opencode", model: "opencode/gpt-5-nano" },
|
|
],
|
|
runner: async () => {
|
|
throw new Error("failed");
|
|
},
|
|
}),
|
|
).rejects.toBeInstanceOf(StructuredAgentFallbackError);
|
|
});
|
|
});
|