Files
paseo/packages/server/src/server/agent/agent-response-loop.test.ts
T
Mohamed Boudra 4574142314 fix(server): use only the configured models for manual metadata generation (#7611)
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.
2026-10-22 14:36:22 +07:00

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);
});
});