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:
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user