Prefer configured provider fallbacks for metadata generation (#1219)
* Prefer configured provider fallbacks for metadata generation * Update client config test for metadata defaults
This commit is contained in:
@@ -3,14 +3,18 @@ import type { Logger } from "pino";
|
||||
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent-response-loop.js";
|
||||
import {
|
||||
resolveStructuredGenerationProviders,
|
||||
type StructuredGenerationDaemonConfig,
|
||||
} from "./structured-generation-providers.js";
|
||||
import { MAX_AUTO_AGENT_TITLE_CHARS } from "@getpaseo/protocol/agent-title-limits";
|
||||
import { buildMetadataPrompt } from "../../utils/build-metadata-prompt.js";
|
||||
import type { WorkspaceGitService } from "../workspace-git-service.js";
|
||||
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
|
||||
export interface AgentMetadataGeneratorDeps {
|
||||
generateStructuredAgentResponseWithFallback?: typeof generateStructuredAgentResponseWithFallback;
|
||||
@@ -21,6 +25,13 @@ export interface AgentMetadataGenerationOptions {
|
||||
agentId: string;
|
||||
cwd: string;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
|
||||
daemonConfig?: StructuredGenerationDaemonConfig | null;
|
||||
currentSelection?: {
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
};
|
||||
initialPrompt?: string | null;
|
||||
explicitTitle?: string | null;
|
||||
paseoHome?: string;
|
||||
@@ -120,6 +131,14 @@ export async function generateAndApplyAgentMetadata(
|
||||
let result: { title?: string };
|
||||
|
||||
try {
|
||||
const providers = options.providerSnapshotManager
|
||||
? await resolveStructuredGenerationProviders({
|
||||
cwd: options.cwd,
|
||||
providerSnapshotManager: options.providerSnapshotManager,
|
||||
daemonConfig: options.daemonConfig,
|
||||
currentSelection: options.currentSelection,
|
||||
})
|
||||
: [];
|
||||
result = await generator({
|
||||
manager: options.agentManager,
|
||||
cwd: options.cwd,
|
||||
@@ -130,7 +149,7 @@ export async function generateAndApplyAgentMetadata(
|
||||
schema,
|
||||
schemaName: "AgentMetadata",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
providers,
|
||||
persistSession: false,
|
||||
logger: options.logger,
|
||||
agentConfigOverrides: {
|
||||
|
||||
@@ -3,7 +3,6 @@ import { zodToJsonSchema } from "zod-to-json-schema";
|
||||
import Ajv, { type ErrorObject, type Options as AjvOptions } from "ajv";
|
||||
import type { AgentProvider, AgentSessionConfig } from "./agent-sdk-types.js";
|
||||
import type { AgentManager } from "./agent-manager.js";
|
||||
import { getAgentProviderDefinition } from "@getpaseo/protocol/provider-manifest";
|
||||
|
||||
export interface StructuredGenerationLogger {
|
||||
info: (obj: object, msg?: string) => void;
|
||||
@@ -99,12 +98,8 @@ export interface StructuredAgentGenerationWithFallbackOptions<T> {
|
||||
runner?: <TResult>(options: StructuredAgentGenerationOptions<TResult>) => Promise<TResult>;
|
||||
}
|
||||
|
||||
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProvider[] = [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
{ provider: "opencode", model: "opencode/minimax-m2.5-free" },
|
||||
{ provider: "opencode", model: "opencode/nemotron-3-super-free" },
|
||||
] as const;
|
||||
// Re-export from the legacy module path so existing server consumers keep working.
|
||||
export { DEFAULT_STRUCTURED_GENERATION_PROVIDERS } from "./structured-generation-providers.js";
|
||||
|
||||
interface SchemaValidator<T> {
|
||||
jsonSchema: JsonSchema;
|
||||
@@ -350,13 +345,7 @@ export async function generateStructuredAgentResponse<T>(
|
||||
): Promise<T> {
|
||||
const { manager, agentConfig, agentId, persistSession, prompt, schema, maxRetries, schemaName } =
|
||||
options;
|
||||
const modeId =
|
||||
agentConfig.modeId ??
|
||||
getAgentProviderDefinition(agentConfig.provider).defaultModeId ??
|
||||
undefined;
|
||||
const agent = await manager.createAgent({ ...agentConfig, modeId }, agentId, {
|
||||
persistSession,
|
||||
});
|
||||
const agent = await manager.createAgent(agentConfig, agentId, { persistSession });
|
||||
try {
|
||||
const caller: AgentCaller = async (nextPrompt) => {
|
||||
const result = await manager.runAgent(agent.id, nextPrompt);
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
import type { AgentAttachment, FirstAgentContext, GitSetupOptions } from "../../messages.js";
|
||||
import type { AgentManager, ManagedAgent } from "../agent-manager.js";
|
||||
import { scheduleAgentMetadataGeneration } from "../agent-metadata-generator.js";
|
||||
import type { StructuredGenerationDaemonConfig } from "../structured-generation-providers.js";
|
||||
import type {
|
||||
AgentPromptContentBlock,
|
||||
AgentPromptInput,
|
||||
@@ -51,6 +52,7 @@ interface CreateAgentCommandDependencies {
|
||||
>;
|
||||
terminalManager?: TerminalManager | null;
|
||||
providerSnapshotManager: ProviderSnapshotManager;
|
||||
daemonConfig?: StructuredGenerationDaemonConfig | null;
|
||||
createPaseoWorktree?: CreatePaseoWorktreeWorkflowFn;
|
||||
}
|
||||
|
||||
@@ -291,6 +293,14 @@ async function sendInitialPrompt(
|
||||
agentId: snapshot.id,
|
||||
cwd: snapshot.cwd,
|
||||
workspaceGitService: dependencies.workspaceGitService,
|
||||
providerSnapshotManager: dependencies.providerSnapshotManager,
|
||||
daemonConfig: dependencies.daemonConfig,
|
||||
currentSelection: {
|
||||
provider: snapshot.provider,
|
||||
model: snapshot.runtimeInfo?.model ?? resolved.config.model,
|
||||
thinkingOptionId:
|
||||
snapshot.runtimeInfo?.thinkingOptionId ?? resolved.config.thinkingOptionId ?? null,
|
||||
},
|
||||
initialPrompt: resolved.metadataInitialPrompt,
|
||||
explicitTitle: resolved.explicitTitle,
|
||||
paseoHome: dependencies.paseoHome,
|
||||
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
PersistedAgentDescriptor,
|
||||
} from "./agent-sdk-types.js";
|
||||
import { scheduleAgentMetadataGeneration } from "./agent-metadata-generator.js";
|
||||
import type { StructuredGenerationDaemonConfig } from "./structured-generation-providers.js";
|
||||
import { resolveCreateAgentTitles } from "./create-agent-title.js";
|
||||
import { unarchiveAgentState } from "./agent-prompt.js";
|
||||
import { toRecentProviderSessionDescriptorPayload } from "./agent-projections.js";
|
||||
@@ -62,6 +63,8 @@ export interface ImportProviderSessionInput {
|
||||
agentManager: AgentManager;
|
||||
agentStorage: AgentStorage;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
|
||||
daemonConfig?: StructuredGenerationDaemonConfig | null;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
deps?: {
|
||||
@@ -180,6 +183,8 @@ export async function importProviderSession(
|
||||
snapshot,
|
||||
agentManager: input.agentManager,
|
||||
workspaceGitService: input.workspaceGitService,
|
||||
providerSnapshotManager: input.providerSnapshotManager,
|
||||
daemonConfig: input.daemonConfig,
|
||||
paseoHome: input.paseoHome,
|
||||
logger: input.logger,
|
||||
scheduleAgentMetadataGeneration:
|
||||
@@ -213,6 +218,8 @@ async function applyImportedAgentTitle(input: {
|
||||
snapshot: ManagedAgent;
|
||||
agentManager: AgentManager;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
|
||||
daemonConfig?: StructuredGenerationDaemonConfig | null;
|
||||
paseoHome?: string;
|
||||
logger: Logger;
|
||||
scheduleAgentMetadataGeneration: typeof scheduleAgentMetadataGeneration;
|
||||
@@ -235,6 +242,16 @@ async function applyImportedAgentTitle(input: {
|
||||
agentId: input.snapshot.id,
|
||||
cwd: input.snapshot.cwd,
|
||||
workspaceGitService: input.workspaceGitService,
|
||||
providerSnapshotManager: input.providerSnapshotManager,
|
||||
daemonConfig: input.daemonConfig,
|
||||
currentSelection: {
|
||||
provider: input.snapshot.provider,
|
||||
model: input.snapshot.runtimeInfo?.model ?? input.snapshot.config.model,
|
||||
thinkingOptionId:
|
||||
input.snapshot.runtimeInfo?.thinkingOptionId ??
|
||||
input.snapshot.config.thinkingOptionId ??
|
||||
null,
|
||||
},
|
||||
initialPrompt,
|
||||
explicitTitle,
|
||||
paseoHome: input.paseoHome,
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
import { describe, expect, test, vi } from "vitest";
|
||||
|
||||
import { resolveStructuredGenerationProviders } from "./structured-generation-providers.js";
|
||||
|
||||
const READY = "ready" as const;
|
||||
const ERROR = "error" as const;
|
||||
|
||||
describe("resolveStructuredGenerationProviders", () => {
|
||||
test("prefers configured providers, resolves dynamic defaults, and dedupes duplicates", async () => {
|
||||
const listProviders = vi.fn(async () => [
|
||||
{
|
||||
provider: "work-claude",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "work-claude", id: "claude-haiku-2026", label: "Haiku", isDefault: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "work-codex",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "work-codex",
|
||||
id: "gpt-5.4-mini-2026",
|
||||
label: "GPT 5.4 Mini",
|
||||
isDefault: true,
|
||||
thinkingOptions: [
|
||||
{ id: "low", label: "Low" },
|
||||
{ id: "medium", label: "Medium", isDefault: true },
|
||||
],
|
||||
defaultThinkingOptionId: "medium",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
provider: "router",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{ provider: "router", id: "minimax-m2.5-free", label: "MiniMax M2.5", isDefault: true },
|
||||
{ provider: "router", id: "nemotron-3-super-free", label: "Nemotron 3 Super" },
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: { listProviders },
|
||||
daemonConfig: {
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" },
|
||||
{ provider: "work-claude" },
|
||||
],
|
||||
},
|
||||
},
|
||||
currentSelection: {
|
||||
provider: "focused-provider",
|
||||
model: "focused-model",
|
||||
thinkingOptionId: "high",
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([
|
||||
{ provider: "stale-codex", model: "missing-model", thinkingOptionId: "low" },
|
||||
{ provider: "work-claude", model: "claude-haiku-2026" },
|
||||
{ provider: "work-codex", model: "gpt-5.4-mini-2026", thinkingOptionId: "low" },
|
||||
{ provider: "router", model: "minimax-m2.5-free" },
|
||||
{ provider: "router", model: "nemotron-3-super-free" },
|
||||
{ provider: "focused-provider", model: "focused-model", thinkingOptionId: "high" },
|
||||
]);
|
||||
expect(listProviders).toHaveBeenCalledWith({ cwd: "/tmp/repo", wait: true });
|
||||
});
|
||||
|
||||
test("falls back to the current selection when defaults do not match", async () => {
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
{
|
||||
provider: "current-provider",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "current-provider",
|
||||
id: "selected-model",
|
||||
label: "Selected Model",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
currentSelection: {
|
||||
provider: "current-provider",
|
||||
model: "selected-model",
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([
|
||||
{ provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("resolves a provider-only current selection to that provider's default model", async () => {
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
{
|
||||
provider: "focused-provider",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "focused-provider",
|
||||
id: "focused-default",
|
||||
label: "Focused Default",
|
||||
isDefault: true,
|
||||
defaultThinkingOptionId: "balanced",
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
currentSelection: { provider: "focused-provider" },
|
||||
});
|
||||
|
||||
expect(providers).toEqual([
|
||||
{ provider: "focused-provider", model: "focused-default", thinkingOptionId: "balanced" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("normalizes nested OpenCode provider entries to the top-level provider and full model id", async () => {
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
{
|
||||
provider: "opencode",
|
||||
status: READY,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "opencode",
|
||||
id: "plexus/small-fast",
|
||||
label: "Small Fast",
|
||||
isDefault: true,
|
||||
metadata: {
|
||||
providerId: "plexus",
|
||||
modelId: "small-fast",
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
daemonConfig: {
|
||||
metadataGeneration: {
|
||||
providers: [{ provider: "plexus", model: "small-fast" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([{ provider: "opencode", model: "plexus/small-fast" }]);
|
||||
});
|
||||
|
||||
test("keeps explicit candidates when provider snapshots are in error state", async () => {
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
{
|
||||
provider: "current-provider",
|
||||
status: ERROR,
|
||||
enabled: true,
|
||||
error: "timed out",
|
||||
},
|
||||
]),
|
||||
},
|
||||
daemonConfig: {
|
||||
metadataGeneration: {
|
||||
providers: [{ provider: "current-provider", model: "configured-model" }],
|
||||
},
|
||||
},
|
||||
currentSelection: {
|
||||
provider: "current-provider",
|
||||
model: "selected-model",
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
});
|
||||
|
||||
expect(providers).toEqual([
|
||||
{ provider: "current-provider", model: "configured-model" },
|
||||
{ provider: "current-provider", model: "selected-model", thinkingOptionId: "medium" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,298 @@
|
||||
import type {
|
||||
AgentModelDefinition,
|
||||
AgentProvider,
|
||||
ProviderSnapshotEntry,
|
||||
} from "./agent-sdk-types.js";
|
||||
import type { StructuredGenerationProvider } from "./agent-response-loop.js";
|
||||
import type { ProviderSnapshotManager } from "./provider-snapshot-manager.js";
|
||||
|
||||
export interface StructuredGenerationDaemonConfig {
|
||||
metadataGeneration?: {
|
||||
providers?: Array<{
|
||||
provider: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface StructuredGenerationProviderIdentifier {
|
||||
modelSubstring: string;
|
||||
thinkingOptionId?: string;
|
||||
}
|
||||
|
||||
export const DEFAULT_STRUCTURED_GENERATION_PROVIDERS: readonly StructuredGenerationProviderIdentifier[] =
|
||||
[
|
||||
{ modelSubstring: "haiku" },
|
||||
{ modelSubstring: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
{ modelSubstring: "minimax-m2.5" },
|
||||
{ modelSubstring: "nemotron-3-super" },
|
||||
] as const;
|
||||
|
||||
export interface ResolveStructuredGenerationProvidersOptions {
|
||||
cwd: string;
|
||||
providerSnapshotManager: Pick<ProviderSnapshotManager, "listProviders">;
|
||||
daemonConfig?: StructuredGenerationDaemonConfig | null;
|
||||
currentSelection?: {
|
||||
provider?: AgentProvider | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveStructuredGenerationProviders(
|
||||
options: ResolveStructuredGenerationProvidersOptions,
|
||||
): Promise<StructuredGenerationProvider[]> {
|
||||
const providerEntries = await options.providerSnapshotManager.listProviders({
|
||||
cwd: options.cwd,
|
||||
wait: true,
|
||||
});
|
||||
const enabledEntries = providerEntries.filter((entry) => entry.enabled);
|
||||
const modelEntries = enabledEntries.filter((entry) => (entry.models?.length ?? 0) > 0);
|
||||
const entriesByProvider = new Map(enabledEntries.map((entry) => [entry.provider, entry]));
|
||||
const providers: StructuredGenerationProvider[] = [];
|
||||
|
||||
for (const configured of readConfiguredProviders(options.daemonConfig)) {
|
||||
const resolvedConfigured = resolveConfiguredCandidate(
|
||||
configured,
|
||||
modelEntries,
|
||||
entriesByProvider,
|
||||
);
|
||||
if (!resolvedConfigured) {
|
||||
continue;
|
||||
}
|
||||
providers.push(resolvedConfigured);
|
||||
}
|
||||
|
||||
for (const identifier of DEFAULT_STRUCTURED_GENERATION_PROVIDERS) {
|
||||
const resolved = resolveByModelSubstring(modelEntries, identifier);
|
||||
if (resolved) {
|
||||
providers.push(resolved);
|
||||
}
|
||||
}
|
||||
|
||||
const currentSelection = resolveCurrentSelection(
|
||||
options.currentSelection,
|
||||
modelEntries,
|
||||
entriesByProvider,
|
||||
);
|
||||
if (currentSelection) {
|
||||
providers.push(currentSelection);
|
||||
}
|
||||
|
||||
return dedupeProviders(providers);
|
||||
}
|
||||
|
||||
function resolveCurrentSelection(
|
||||
selection: ResolveStructuredGenerationProvidersOptions["currentSelection"],
|
||||
readyEntries: readonly ProviderSnapshotEntry[],
|
||||
entriesByProvider: ReadonlyMap<AgentProvider, ProviderSnapshotEntry>,
|
||||
): StructuredGenerationProvider | null {
|
||||
if (!selection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = selection.provider?.trim();
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = resolveConfiguredCandidate(
|
||||
{
|
||||
provider,
|
||||
...(selection.model ? { model: selection.model } : {}),
|
||||
...(selection.thinkingOptionId ? { thinkingOptionId: selection.thinkingOptionId } : {}),
|
||||
},
|
||||
readyEntries,
|
||||
entriesByProvider,
|
||||
);
|
||||
if (normalized) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
const explicitModel = selection.model?.trim();
|
||||
if (explicitModel) {
|
||||
return {
|
||||
provider,
|
||||
model: explicitModel,
|
||||
...(selection.thinkingOptionId ? { thinkingOptionId: selection.thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const model = selectDefaultModel(entriesByProvider.get(provider)?.models ?? []);
|
||||
if (!model) {
|
||||
return { provider };
|
||||
}
|
||||
|
||||
const thinkingOptionId = resolveThinkingOptionId(model, selection.thinkingOptionId);
|
||||
return {
|
||||
provider,
|
||||
model: model.id,
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveConfiguredCandidate(
|
||||
candidate: { provider: string; model?: string; thinkingOptionId?: string },
|
||||
readyEntries: readonly ProviderSnapshotEntry[],
|
||||
entriesByProvider: ReadonlyMap<AgentProvider, ProviderSnapshotEntry>,
|
||||
): StructuredGenerationProvider | null {
|
||||
const provider = candidate.provider.trim();
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const topLevelEntry = entriesByProvider.get(provider);
|
||||
const configuredModel = candidate.model?.trim();
|
||||
if (topLevelEntry) {
|
||||
if (configuredModel) {
|
||||
return {
|
||||
provider,
|
||||
model: configuredModel,
|
||||
...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const model = selectDefaultModel(topLevelEntry.models ?? []);
|
||||
const thinkingOptionId = resolveThinkingOptionId(model, candidate.thinkingOptionId);
|
||||
return {
|
||||
provider,
|
||||
...(model ? { model: model.id } : {}),
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
if (!configuredModel) {
|
||||
return {
|
||||
provider,
|
||||
...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const nestedMatch = resolveNestedProviderModel(provider, configuredModel, readyEntries);
|
||||
if (!nestedMatch) {
|
||||
return {
|
||||
provider,
|
||||
model: configuredModel,
|
||||
...(candidate.thinkingOptionId ? { thinkingOptionId: candidate.thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
const thinkingOptionId = resolveThinkingOptionId(nestedMatch.model, candidate.thinkingOptionId);
|
||||
return {
|
||||
provider: nestedMatch.provider,
|
||||
model: nestedMatch.model.id,
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function resolveNestedProviderModel(
|
||||
providerId: string,
|
||||
modelId: string,
|
||||
entries: readonly ProviderSnapshotEntry[],
|
||||
): { provider: AgentProvider; model: AgentModelDefinition } | null {
|
||||
const normalizedProviderId = providerId.trim().toLowerCase();
|
||||
const normalizedModelId = modelId.trim().toLowerCase();
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const model of entry.models ?? []) {
|
||||
const modelProviderId = readModelMetadataString(model, "providerId")?.toLowerCase();
|
||||
const nestedModelId = readModelMetadataString(model, "modelId")?.toLowerCase();
|
||||
if (modelProviderId !== normalizedProviderId) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
normalizedModelId === model.id.toLowerCase() ||
|
||||
normalizedModelId === nestedModelId ||
|
||||
model.id.toLowerCase() === `${normalizedProviderId}/${normalizedModelId}`
|
||||
) {
|
||||
return { provider: entry.provider, model };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveByModelSubstring(
|
||||
entries: readonly ProviderSnapshotEntry[],
|
||||
identifier: StructuredGenerationProviderIdentifier,
|
||||
): StructuredGenerationProvider | null {
|
||||
const needle = identifier.modelSubstring.trim().toLowerCase();
|
||||
if (!needle) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
for (const model of entry.models ?? []) {
|
||||
const haystacks = [model.id, model.label].map((value) => value.toLowerCase());
|
||||
if (!haystacks.some((value) => value.includes(needle))) {
|
||||
continue;
|
||||
}
|
||||
const thinkingOptionId = resolveThinkingOptionId(model, identifier.thinkingOptionId);
|
||||
return {
|
||||
provider: entry.provider,
|
||||
model: model.id,
|
||||
...(thinkingOptionId ? { thinkingOptionId } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function readConfiguredProviders(
|
||||
daemonConfig: ResolveStructuredGenerationProvidersOptions["daemonConfig"],
|
||||
): Array<{ provider: string; model?: string; thinkingOptionId?: string }> {
|
||||
const metadataGeneration = daemonConfig?.metadataGeneration;
|
||||
if (!metadataGeneration || typeof metadataGeneration !== "object") {
|
||||
return [];
|
||||
}
|
||||
const providers = "providers" in metadataGeneration ? metadataGeneration.providers : undefined;
|
||||
return Array.isArray(providers) ? providers : [];
|
||||
}
|
||||
|
||||
function selectDefaultModel(models: readonly AgentModelDefinition[]): AgentModelDefinition | null {
|
||||
return models.find((model) => model.isDefault) ?? models[0] ?? null;
|
||||
}
|
||||
|
||||
function resolveThinkingOptionId(
|
||||
model: AgentModelDefinition | null | undefined,
|
||||
preferredThinkingOptionId: string | null | undefined,
|
||||
): string | undefined {
|
||||
if (!model) {
|
||||
return undefined;
|
||||
}
|
||||
if (
|
||||
preferredThinkingOptionId &&
|
||||
model.thinkingOptions?.some((option) => option.id === preferredThinkingOptionId)
|
||||
) {
|
||||
return preferredThinkingOptionId;
|
||||
}
|
||||
return model.defaultThinkingOptionId;
|
||||
}
|
||||
|
||||
function dedupeProviders(
|
||||
providers: readonly StructuredGenerationProvider[],
|
||||
): StructuredGenerationProvider[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: StructuredGenerationProvider[] = [];
|
||||
|
||||
for (const provider of providers) {
|
||||
const key = [provider.provider, provider.model ?? "", provider.thinkingOptionId ?? ""].join(
|
||||
"\0",
|
||||
);
|
||||
if (seen.has(key)) {
|
||||
continue;
|
||||
}
|
||||
seen.add(key);
|
||||
deduped.push(provider);
|
||||
}
|
||||
|
||||
return deduped;
|
||||
}
|
||||
|
||||
function readModelMetadataString(model: AgentModelDefinition, key: string): string | undefined {
|
||||
const value = model.metadata?.[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : undefined;
|
||||
}
|
||||
@@ -258,6 +258,13 @@ export interface PaseoDaemonConfig {
|
||||
dictationFinalTimeoutMs?: number;
|
||||
downloadTokenTtlMs?: number;
|
||||
agentProviderSettings?: AgentProviderRuntimeSettingsMap;
|
||||
metadataGeneration?: {
|
||||
providers?: Array<{
|
||||
provider: string;
|
||||
model?: string;
|
||||
thinkingOptionId?: string;
|
||||
}>;
|
||||
};
|
||||
providerOverrides?: Record<string, ProviderOverride>;
|
||||
log?: PersistedConfig["log"];
|
||||
onLifecycleIntent?: (intent: DaemonLifecycleIntent) => void;
|
||||
@@ -297,6 +304,9 @@ export async function createPaseoDaemon(
|
||||
},
|
||||
]),
|
||||
),
|
||||
metadataGeneration: {
|
||||
providers: config.metadataGeneration?.providers ?? [],
|
||||
},
|
||||
autoArchiveAfterMerge: config.autoArchiveAfterMerge ?? false,
|
||||
appendSystemPrompt: config.appendSystemPrompt ?? "",
|
||||
},
|
||||
|
||||
@@ -345,6 +345,7 @@ export function loadConfig(
|
||||
voiceLlmProviderExplicit: voiceLlm.providerExplicit,
|
||||
voiceLlmModel: voiceLlm.model,
|
||||
agentProviderSettings: extractAgentProviderSettings(providerOverrides),
|
||||
metadataGeneration: persisted.agents?.metadataGeneration,
|
||||
providerOverrides,
|
||||
log: resolveLogConfigFromEnv(env, persisted),
|
||||
};
|
||||
|
||||
@@ -68,19 +68,26 @@ describe("DaemonConfigStore", () => {
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
const initial = loadPersistedConfig(paseoHome);
|
||||
initial.agents = {
|
||||
providers: {
|
||||
gemini: {
|
||||
extends: "acp",
|
||||
label: "Gemini",
|
||||
command: ["gemini", "--acp"],
|
||||
},
|
||||
},
|
||||
};
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
// Reuse the validated serializer through the store path by seeding the file directly.
|
||||
// This keeps the test focused on the merge behavior.
|
||||
const seeded = JSON.stringify(initial, null, 2) + "\n";
|
||||
const seeded =
|
||||
JSON.stringify(
|
||||
{
|
||||
...initial,
|
||||
agents: {
|
||||
providers: {
|
||||
gemini: {
|
||||
extends: "acp",
|
||||
label: "Gemini",
|
||||
command: ["gemini", "--acp"],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
) + "\n";
|
||||
writeFileSync(configPath, seeded);
|
||||
|
||||
const store = new DaemonConfigStore(
|
||||
@@ -88,6 +95,9 @@ describe("DaemonConfigStore", () => {
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -116,6 +126,8 @@ describe("DaemonConfigStore", () => {
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
@@ -138,6 +150,9 @@ describe("DaemonConfigStore", () => {
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
@@ -175,6 +190,8 @@ describe("DaemonConfigStore", () => {
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
@@ -188,6 +205,79 @@ describe("DaemonConfigStore", () => {
|
||||
expect(persisted.daemon?.appendSystemPrompt).toBe("Prefer terse replies.");
|
||||
});
|
||||
|
||||
test("patch persists metadata generation providers into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
const store = new DaemonConfigStore(
|
||||
paseoHome,
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
metadataGeneration: { providers: [] },
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.metadataGeneration).toEqual({
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("patch persists clearing metadata generation providers into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
|
||||
const configPath = path.join(paseoHome, "config.json");
|
||||
writeFileSync(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 1,
|
||||
agents: {
|
||||
metadataGeneration: {
|
||||
providers: [{ provider: "claude", model: "haiku" }],
|
||||
},
|
||||
},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
|
||||
const store = new DaemonConfigStore(
|
||||
paseoHome,
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
metadataGeneration: { providers: [{ provider: "claude", model: "haiku" }] },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
store.patch({ metadataGeneration: { providers: [] } });
|
||||
|
||||
const persisted = loadPersistedConfig(paseoHome);
|
||||
expect(persisted.agents?.metadataGeneration).toEqual({ providers: [] });
|
||||
});
|
||||
|
||||
test("patch persists custom ACP provider overrides into config.json", () => {
|
||||
const paseoHome = mkdtempSync(path.join(tmpdir(), "paseo-daemon-config-store-"));
|
||||
tempDirs.push(paseoHome);
|
||||
@@ -197,6 +287,9 @@ describe("DaemonConfigStore", () => {
|
||||
{
|
||||
mcp: { injectIntoAgents: false },
|
||||
providers: {},
|
||||
autoArchiveAfterMerge: false,
|
||||
appendSystemPrompt: "",
|
||||
metadataGeneration: { providers: [] },
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
|
||||
@@ -172,13 +172,33 @@ function mergeMutableConfigIntoPersistedConfig(params: {
|
||||
mutable: MutableDaemonConfig;
|
||||
}): PersistedConfig {
|
||||
const { persisted, mutable } = params;
|
||||
const metadataGenerationProviders = readMetadataGenerationProviders(mutable);
|
||||
const providerOverrides = applyMutableProviderConfigToOverrides(
|
||||
persisted.agents?.providers as Record<string, ProviderOverride> | undefined,
|
||||
mutable.providers,
|
||||
);
|
||||
const persistedAgents = persisted.agents as
|
||||
| ({ providers?: Record<string, ProviderOverride> } & Record<string, unknown>)
|
||||
| undefined;
|
||||
const persistedAgents = persisted.agents as Record<string, unknown> | undefined;
|
||||
const persistedMetadataGeneration = {
|
||||
providers: metadataGenerationProviders,
|
||||
};
|
||||
const shouldPersistMetadataGeneration =
|
||||
metadataGenerationProviders.length > 0 || persisted.agents?.metadataGeneration !== undefined;
|
||||
|
||||
let nextAgents = persisted.agents as PersistedConfig["agents"];
|
||||
if (providerOverrides && Object.keys(providerOverrides).length > 0) {
|
||||
nextAgents = {
|
||||
...persistedAgents,
|
||||
providers: providerOverrides,
|
||||
...(shouldPersistMetadataGeneration
|
||||
? { metadataGeneration: persistedMetadataGeneration }
|
||||
: {}),
|
||||
} as PersistedConfig["agents"];
|
||||
} else if (shouldPersistMetadataGeneration) {
|
||||
nextAgents = {
|
||||
...persistedAgents,
|
||||
metadataGeneration: persistedMetadataGeneration,
|
||||
} as PersistedConfig["agents"];
|
||||
}
|
||||
|
||||
return {
|
||||
...persisted,
|
||||
@@ -191,12 +211,33 @@ function mergeMutableConfigIntoPersistedConfig(params: {
|
||||
autoArchiveAfterMerge: mutable.autoArchiveAfterMerge,
|
||||
appendSystemPrompt: mutable.appendSystemPrompt,
|
||||
},
|
||||
agents:
|
||||
providerOverrides && Object.keys(providerOverrides).length > 0
|
||||
? {
|
||||
...persistedAgents,
|
||||
providers: providerOverrides,
|
||||
}
|
||||
: persisted.agents,
|
||||
agents: nextAgents,
|
||||
} as PersistedConfig;
|
||||
}
|
||||
|
||||
function readMetadataGenerationProviders(
|
||||
mutable: MutableDaemonConfig,
|
||||
): Array<{ provider: string; model?: string; thinkingOptionId?: string }> {
|
||||
const metadataGeneration = mutable.metadataGeneration;
|
||||
if (!isRecord(metadataGeneration)) {
|
||||
return [];
|
||||
}
|
||||
const providers = metadataGeneration["providers"];
|
||||
if (!Array.isArray(providers)) {
|
||||
return [];
|
||||
}
|
||||
return providers.flatMap((entry) => {
|
||||
if (!isRecord(entry) || typeof entry["provider"] !== "string") {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
provider: entry["provider"],
|
||||
...(typeof entry["model"] === "string" ? { model: entry["model"] } : {}),
|
||||
...(typeof entry["thinkingOptionId"] === "string"
|
||||
? { thinkingOptionId: entry["thinkingOptionId"] }
|
||||
: {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
@@ -133,6 +133,26 @@ describe("PersistedConfigSchema agent provider runtime settings", () => {
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
test("accepts metadata generation provider fallbacks", () => {
|
||||
const parsed = PersistedConfigSchema.parse({
|
||||
agents: {
|
||||
metadataGeneration: {
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.agents?.metadataGeneration).toEqual({
|
||||
providers: [
|
||||
{ provider: "claude", model: "haiku" },
|
||||
{ provider: "codex", model: "gpt-5.4-mini", thinkingOptionId: "low" },
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("provider overrides (new format)", () => {
|
||||
|
||||
@@ -131,6 +131,20 @@ const FeatureVoiceModeSchema = z
|
||||
})
|
||||
.strict();
|
||||
|
||||
const StructuredGenerationProviderConfigSchema = z
|
||||
.object({
|
||||
provider: z.string().min(1),
|
||||
model: z.string().min(1).optional(),
|
||||
thinkingOptionId: z.string().min(1).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const AgentMetadataGenerationSchema = z
|
||||
.object({
|
||||
providers: z.array(StructuredGenerationProviderConfigSchema).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const BUILTIN_PROVIDER_IDS = ["claude", "codex", "copilot", "opencode", "pi"] as const;
|
||||
|
||||
function isLegacyProviderEntry(value: unknown): boolean {
|
||||
@@ -237,6 +251,7 @@ export const PersistedConfigSchema = z
|
||||
agents: z
|
||||
.object({
|
||||
providers: z.preprocess(normalizeAgentProviders, ProviderOverridesSchema).optional(),
|
||||
metadataGeneration: AgentMetadataGenerationSchema.optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
|
||||
@@ -125,11 +125,14 @@ import {
|
||||
type TimelineProjectionMode,
|
||||
} from "./agent/timeline-projection.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import {
|
||||
resolveStructuredGenerationProviders,
|
||||
type StructuredGenerationDaemonConfig,
|
||||
} from "./agent/structured-generation-providers.js";
|
||||
import {
|
||||
getAgentStreamEventTurnId,
|
||||
type AgentPersistenceHandle,
|
||||
@@ -1057,6 +1060,37 @@ export class Session {
|
||||
return this.clientActivity;
|
||||
}
|
||||
|
||||
private getFocusedAgentSelectionForCwd(cwd: string):
|
||||
| {
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
}
|
||||
| undefined {
|
||||
const focusedAgentId = this.clientActivity?.focusedAgentId;
|
||||
if (!focusedAgentId) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const agent = this.agentManager.getAgent(focusedAgentId);
|
||||
if (!agent || agent.cwd !== cwd) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
provider: agent.provider,
|
||||
model: agent.runtimeInfo?.model ?? agent.config.model ?? null,
|
||||
thinkingOptionId:
|
||||
agent.runtimeInfo?.thinkingOptionId ?? agent.config.thinkingOptionId ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
private readStructuredGenerationDaemonConfig(): StructuredGenerationDaemonConfig {
|
||||
return {
|
||||
metadataGeneration: this.daemonConfigStore.get().metadataGeneration,
|
||||
};
|
||||
}
|
||||
|
||||
public getRuntimeMetrics(): SessionRuntimeMetrics {
|
||||
const terminalMetrics = this.terminalController.getMetrics();
|
||||
return {
|
||||
@@ -3066,6 +3100,7 @@ export class Session {
|
||||
paseoHome: this.paseoHome,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
daemonConfig: this.readStructuredGenerationDaemonConfig(),
|
||||
},
|
||||
{
|
||||
kind: "session",
|
||||
@@ -3263,6 +3298,8 @@ export class Session {
|
||||
agentManager: this.agentManager,
|
||||
agentStorage: this.agentStorage,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
daemonConfig: this.readStructuredGenerationDaemonConfig(),
|
||||
paseoHome: this.paseoHome,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
@@ -3501,6 +3538,9 @@ export class Session {
|
||||
agentManager: this.agentManager,
|
||||
cwd,
|
||||
workspaceGitService: this.workspaceGitService,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
daemonConfig: this.readStructuredGenerationDaemonConfig(),
|
||||
currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
|
||||
firstAgentContext,
|
||||
logger: this.sessionLogger,
|
||||
});
|
||||
@@ -3963,6 +4003,12 @@ export class Session {
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n"),
|
||||
});
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
daemonConfig: this.readStructuredGenerationDaemonConfig(),
|
||||
currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
|
||||
});
|
||||
try {
|
||||
const result = await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
@@ -3971,7 +4017,7 @@ export class Session {
|
||||
schema,
|
||||
schemaName: "CommitMessage",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
providers,
|
||||
persistSession: false,
|
||||
agentConfigOverrides: {
|
||||
title: "Commit generator",
|
||||
@@ -4035,6 +4081,12 @@ export class Session {
|
||||
patch.length > 0 ? patch : "(No diff available)",
|
||||
].join("\n"),
|
||||
});
|
||||
const providers = await resolveStructuredGenerationProviders({
|
||||
cwd,
|
||||
providerSnapshotManager: this.providerSnapshotManager,
|
||||
daemonConfig: this.readStructuredGenerationDaemonConfig(),
|
||||
currentSelection: this.getFocusedAgentSelectionForCwd(cwd),
|
||||
});
|
||||
try {
|
||||
return await generateStructuredAgentResponseWithFallback({
|
||||
manager: this.agentManager,
|
||||
@@ -4043,7 +4095,7 @@ export class Session {
|
||||
schema,
|
||||
schemaName: "PullRequest",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
providers,
|
||||
persistSession: false,
|
||||
agentConfigOverrides: {
|
||||
title: "PR generator",
|
||||
|
||||
@@ -4,6 +4,7 @@ import path from "node:path";
|
||||
import { afterEach, describe, expect, test, vi } from "vitest";
|
||||
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import type { StructuredAgentGenerationWithFallbackOptions } from "./agent/agent-response-loop.js";
|
||||
import {
|
||||
attemptFirstAgentBranchAutoName,
|
||||
type AttemptFirstAgentBranchAutoNameResult,
|
||||
@@ -32,26 +33,44 @@ afterEach(() => {
|
||||
|
||||
function createLogger() {
|
||||
return {
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
}
|
||||
|
||||
function createStructuredGenerator(result: { branch: string }) {
|
||||
const calls: StructuredAgentGenerationWithFallbackOptions<unknown>[] = [];
|
||||
|
||||
async function generateStructured<T>(
|
||||
options: StructuredAgentGenerationWithFallbackOptions<T>,
|
||||
): Promise<T> {
|
||||
calls.push(options as StructuredAgentGenerationWithFallbackOptions<unknown>);
|
||||
return result as T;
|
||||
}
|
||||
|
||||
return { generateStructured, calls };
|
||||
}
|
||||
|
||||
describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
test("calls the structured generator with first-agent prompt text", async () => {
|
||||
const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" }));
|
||||
const structured = createStructuredGenerator({ branch: "fix-login-flow" });
|
||||
|
||||
const branch = await generateBranchNameFromFirstAgentContext({
|
||||
agentManager: {} as AgentManager,
|
||||
cwd: "/tmp/repo",
|
||||
firstAgentContext: { prompt: "Fix the login flow" },
|
||||
logger: createLogger(),
|
||||
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
|
||||
deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured },
|
||||
});
|
||||
|
||||
expect(branch).toBe("fix-login-flow");
|
||||
expect(generateStructured).toHaveBeenCalledTimes(1);
|
||||
expect(generateStructured.mock.calls[0]?.[0]).toMatchObject({
|
||||
expect(structured.calls).toHaveLength(1);
|
||||
const firstCall = structured.calls[0];
|
||||
if (!firstCall) {
|
||||
throw new Error("expected structured generation call");
|
||||
}
|
||||
expect(firstCall).toMatchObject({
|
||||
cwd: "/tmp/repo",
|
||||
schemaName: "BranchName",
|
||||
maxRetries: 2,
|
||||
@@ -60,11 +79,11 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
internal: true,
|
||||
},
|
||||
});
|
||||
expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Fix the login flow");
|
||||
expect(firstCall.prompt).toContain("Fix the login flow");
|
||||
});
|
||||
|
||||
test("uses attachment-only context", async () => {
|
||||
const generateStructured = vi.fn(async () => ({ branch: "review-flaky-checkout" }));
|
||||
const structured = createStructuredGenerator({ branch: "review-flaky-checkout" });
|
||||
|
||||
const branch = await generateBranchNameFromFirstAgentContext({
|
||||
agentManager: {} as AgentManager,
|
||||
@@ -81,11 +100,58 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
],
|
||||
},
|
||||
logger: createLogger(),
|
||||
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
|
||||
deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured },
|
||||
});
|
||||
|
||||
expect(branch).toBe("review-flaky-checkout");
|
||||
expect(generateStructured.mock.calls[0]?.[0].prompt).toContain("Review flaky checkout");
|
||||
const firstCall = structured.calls[0];
|
||||
if (!firstCall) {
|
||||
throw new Error("expected structured generation call");
|
||||
}
|
||||
expect(firstCall.prompt).toContain("Review flaky checkout");
|
||||
});
|
||||
|
||||
test("uses the current selection as the final provider fallback", async () => {
|
||||
const structured = createStructuredGenerator({ branch: "focused-branch" });
|
||||
|
||||
const branch = await generateBranchNameFromFirstAgentContext({
|
||||
agentManager: {} as AgentManager,
|
||||
cwd: "/tmp/repo",
|
||||
providerSnapshotManager: {
|
||||
listProviders: vi.fn(async () => [
|
||||
{
|
||||
provider: "focused-provider",
|
||||
status: "ready" as const,
|
||||
enabled: true,
|
||||
models: [
|
||||
{
|
||||
provider: "focused-provider",
|
||||
id: "selected-model",
|
||||
label: "Selected Model",
|
||||
isDefault: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
},
|
||||
currentSelection: {
|
||||
provider: "focused-provider",
|
||||
model: "selected-model",
|
||||
thinkingOptionId: "medium",
|
||||
},
|
||||
firstAgentContext: { prompt: "Fix the login flow" },
|
||||
logger: createLogger(),
|
||||
deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured },
|
||||
});
|
||||
|
||||
expect(branch).toBe("focused-branch");
|
||||
const firstCall = structured.calls[0];
|
||||
if (!firstCall) {
|
||||
throw new Error("expected structured generation call");
|
||||
}
|
||||
expect(firstCall.providers).toEqual([
|
||||
{ provider: "focused-provider", model: "selected-model", thinkingOptionId: "medium" },
|
||||
]);
|
||||
});
|
||||
|
||||
test.each([
|
||||
@@ -152,8 +218,11 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
const generateStructured = vi.fn(async () => ({ branch: "Invalid Branch Name" }));
|
||||
const renameCurrentBranch = vi.fn(async () => ({ currentBranch: "Invalid Branch Name" }));
|
||||
const structured = createStructuredGenerator({ branch: "Invalid Branch Name" });
|
||||
const renameCurrentBranch = vi.fn(async () => ({
|
||||
previousBranch: "dazzling-yak",
|
||||
currentBranch: "Invalid Branch Name",
|
||||
}));
|
||||
|
||||
const result: AttemptFirstAgentBranchAutoNameResult = await attemptFirstAgentBranchAutoName({
|
||||
cwd: worktreeRoot,
|
||||
@@ -167,7 +236,7 @@ describe("generateBranchNameFromFirstAgentContext", () => {
|
||||
}),
|
||||
firstAgentContext,
|
||||
logger: createLogger(),
|
||||
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
|
||||
deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured },
|
||||
}),
|
||||
getCurrentBranch: async () => "dazzling-yak",
|
||||
renameCurrentBranch,
|
||||
@@ -186,7 +255,7 @@ async function generateBranchPromptWithConfig(config: unknown): Promise<{ prompt
|
||||
writeConfig(repoRoot, config);
|
||||
}
|
||||
|
||||
const generateStructured = vi.fn(async () => ({ branch: "fix-login-flow" }));
|
||||
const structured = createStructuredGenerator({ branch: "fix-login-flow" });
|
||||
|
||||
await generateBranchNameFromFirstAgentContext({
|
||||
agentManager: {} as AgentManager,
|
||||
@@ -196,11 +265,11 @@ async function generateBranchPromptWithConfig(config: unknown): Promise<{ prompt
|
||||
}),
|
||||
firstAgentContext: { prompt: "Fix the login flow" },
|
||||
logger: createLogger(),
|
||||
deps: { generateStructuredAgentResponseWithFallback: generateStructured },
|
||||
deps: { generateStructuredAgentResponseWithFallback: structured.generateStructured },
|
||||
});
|
||||
|
||||
return {
|
||||
prompt: String(generateStructured.mock.calls[0]?.[0].prompt),
|
||||
prompt: String(structured.calls[0]?.prompt),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -2,14 +2,18 @@ import { z } from "zod";
|
||||
import type { FirstAgentContext } from "@getpaseo/protocol/messages";
|
||||
import type { AgentManager } from "./agent/agent-manager.js";
|
||||
import {
|
||||
DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
StructuredAgentFallbackError,
|
||||
StructuredAgentResponseError,
|
||||
generateStructuredAgentResponseWithFallback,
|
||||
} from "./agent/agent-response-loop.js";
|
||||
import {
|
||||
resolveStructuredGenerationProviders,
|
||||
type StructuredGenerationDaemonConfig,
|
||||
} from "./agent/structured-generation-providers.js";
|
||||
import { buildAgentBranchNameSeed } from "./agent/prompt-attachments.js";
|
||||
import { buildMetadataPrompt } from "../utils/build-metadata-prompt.js";
|
||||
import type { WorkspaceGitService } from "./workspace-git-service.js";
|
||||
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
|
||||
|
||||
interface BranchNameGeneratorLogger {
|
||||
info: (obj: object, msg?: string) => void;
|
||||
@@ -21,6 +25,13 @@ export interface GenerateBranchNameFromFirstAgentContextOptions {
|
||||
agentManager: AgentManager;
|
||||
cwd: string;
|
||||
workspaceGitService?: Pick<WorkspaceGitService, "resolveRepoRoot">;
|
||||
providerSnapshotManager?: Pick<ProviderSnapshotManager, "listProviders">;
|
||||
daemonConfig?: StructuredGenerationDaemonConfig | null;
|
||||
currentSelection?: {
|
||||
provider?: string | null;
|
||||
model?: string | null;
|
||||
thinkingOptionId?: string | null;
|
||||
};
|
||||
firstAgentContext: FirstAgentContext | undefined;
|
||||
logger: BranchNameGeneratorLogger;
|
||||
deps?: {
|
||||
@@ -66,6 +77,14 @@ export async function generateBranchNameFromFirstAgentContext(
|
||||
generateStructuredAgentResponseWithFallback;
|
||||
|
||||
try {
|
||||
const providers = options.providerSnapshotManager
|
||||
? await resolveStructuredGenerationProviders({
|
||||
cwd: options.cwd,
|
||||
providerSnapshotManager: options.providerSnapshotManager,
|
||||
daemonConfig: options.daemonConfig,
|
||||
currentSelection: options.currentSelection,
|
||||
})
|
||||
: [];
|
||||
const result = await generator({
|
||||
manager: options.agentManager,
|
||||
cwd: options.cwd,
|
||||
@@ -76,7 +95,7 @@ export async function generateBranchNameFromFirstAgentContext(
|
||||
schema: BranchNameSchema,
|
||||
schemaName: "BranchName",
|
||||
maxRetries: 2,
|
||||
providers: DEFAULT_STRUCTURED_GENERATION_PROVIDERS,
|
||||
providers,
|
||||
persistSession: false,
|
||||
logger: options.logger,
|
||||
agentConfigOverrides: {
|
||||
|
||||
Reference in New Issue
Block a user