fix(insights): preserve custom model selection across retries

Stores modelProvider/modelId in the run's inputMetadata.metadata at
creation time, then recovers it in the retry handler so the user's
explicit model choice survives a transient failure and retry.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Timothy Laurent
2026-05-06 08:10:35 -07:00
parent 41af820f1d
commit 6e81daf98f
2 changed files with 112 additions and 1 deletions

View File

@@ -385,6 +385,94 @@ describe("Insights routes", () => {
expect(retried.lifecycle.retryOfRunId).toBe(retryableRun.id);
});
it("POST /api/insights/runs/:id/retry preserves the original run's custom model", async () => {
// Create a run with a custom model that fails with a retryable error
piMocks.promptWithFallback.mockRejectedValue(new Error("HTTP 503"));
const failedRes = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({
trigger: "manual",
modelProvider: "openai",
modelId: "gpt-4o",
}),
{ "Content-Type": "application/json" },
);
expect(failedRes.status).toBe(201);
const failedRun = failedRes.body as { id: string; inputMetadata: Record<string, unknown> };
// The failed run should have persisted the model info in inputMetadata
expect(failedRun.inputMetadata?.metadata).toMatchObject({
modelProvider: "openai",
modelId: "gpt-4o",
});
// Reset mock so retry succeeds
piMocks.promptWithFallback.mockResolvedValue(undefined);
piMocks.createFnAgent.mockClear();
const retriedRes = await request(app, "POST", `/api/insights/runs/${failedRun.id}/retry`, JSON.stringify({}), {
"Content-Type": "application/json",
});
expect(retriedRes.status).toBe(201);
// The retry should pass the original model to createFnAgent
expect(piMocks.createFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
}),
);
});
it("POST /api/insights/run passes explicit model override to createFnAgent", async () => {
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({
trigger: "manual",
modelProvider: "openai",
modelId: "gpt-4o",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
// createFnAgent should have been called with the explicit override
expect(piMocks.createFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
defaultProvider: "openai",
defaultModelId: "gpt-4o",
fallbackProvider: undefined,
fallbackModelId: undefined,
}),
);
});
it("POST /api/insights/run without model override uses settings resolution", async () => {
const res = await request(
app,
"POST",
"/api/insights/run",
JSON.stringify({
trigger: "manual",
}),
{ "Content-Type": "application/json" },
);
expect(res.status).toBe(201);
// Without override, provider/model come from settings resolution
// (which returns undefined when no planning settings are configured)
expect(piMocks.createFnAgent).toHaveBeenCalledWith(
expect.objectContaining({
fallbackProvider: undefined,
fallbackModelId: undefined,
}),
);
});
it("POST /api/insights/:id/create-task returns task-conversion payload", async () => {
const insight = storeA.getInsightStore().createInsight("", {
title: "Refactor parser",

View File

@@ -332,12 +332,24 @@ export function createInsightsRouter(store: TaskStore): Router {
const modelId = typeof req.body.modelId === "string" ? req.body.modelId : undefined;
const controller = new AbortController();
// Stash model selection in inputMetadata.metadata so retries can recover it
const inputMetadata = typeof req.body.inputMetadata === "object" && req.body.inputMetadata !== null
? { ...req.body.inputMetadata }
: {};
if (modelProvider || modelId) {
inputMetadata.metadata = {
...(typeof inputMetadata.metadata === "object" && inputMetadata.metadata !== null ? inputMetadata.metadata : {}),
...(modelProvider ? { modelProvider } : {}),
...(modelId ? { modelId } : {}),
};
}
const run = await executeInsightRunLifecycle({
store: insightStore,
projectId,
input: {
trigger,
inputMetadata: req.body.inputMetadata,
inputMetadata,
},
signal: controller.signal,
timeoutMs: typeof req.body.timeoutMs === "number" ? req.body.timeoutMs : 120_000,
@@ -501,6 +513,15 @@ export function createInsightsRouter(store: TaskStore): Router {
const settings = await taskStore.getSettings();
const controller = new AbortController();
// Recover model selection from the original run's inputMetadata
const originalMetadata = existing.inputMetadata?.metadata;
const retryModelProvider = typeof (originalMetadata as Record<string, unknown> | undefined)?.modelProvider === "string"
? (originalMetadata as Record<string, unknown>).modelProvider as string
: undefined;
const retryModelId = typeof (originalMetadata as Record<string, unknown> | undefined)?.modelId === "string"
? (originalMetadata as Record<string, unknown>).modelId as string
: undefined;
const { run } = await retryInsightRunLifecycle({
store,
runId: id,
@@ -517,6 +538,8 @@ export function createInsightsRouter(store: TaskStore): Router {
signal,
insightStore: store,
settings,
modelProvider: retryModelProvider,
modelId: retryModelId,
});
},
});