diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 30921283..874edb83 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -21,6 +21,9 @@ import { shouldDeferFollowupWakeForSameIssue, stripHostWorkspaceProvisionForLowTrustSandbox, stripWorkspaceRuntimeFromExecutionRunConfig, + shouldResetTaskSessionForModelChange, + stripConfiguredModelFromSessionParams, + normalizeSessionParams, shouldResetTaskSessionForWake, type ResolvedWorkspaceForRun, } from "../services/heartbeat.ts"; @@ -926,6 +929,113 @@ describe("shouldDeferFollowupWakeForSameIssue", () => { }); }); +describe("shouldResetTaskSessionForModelChange", () => { + it("resets when configured model differs from persisted session model", () => { + expect( + shouldResetTaskSessionForModelChange({ + configuredModel: "gpt-5.4-mini", + taskSessionParams: { + sessionId: "thread-1", + __paperclipConfiguredModel: "opencode/mimo-v2-pro-free", + }, + }), + ).toBe(true); + }); + + it("does not reset when models match", () => { + expect( + shouldResetTaskSessionForModelChange({ + configuredModel: "gpt-5.4-mini", + taskSessionParams: { + sessionId: "thread-1", + __paperclipConfiguredModel: "gpt-5.4-mini", + }, + }), + ).toBe(false); + }); + + it("does not reset when persisted session model is missing", () => { + expect( + shouldResetTaskSessionForModelChange({ + configuredModel: "gpt-5.4-mini", + taskSessionParams: { + sessionId: "thread-1", + }, + }), + ).toBe(false); + }); + + it("does not reset when configured model is missing", () => { + expect( + shouldResetTaskSessionForModelChange({ + configuredModel: null, + taskSessionParams: { + sessionId: "thread-1", + __paperclipConfiguredModel: "gpt-5.4-mini", + }, + }), + ).toBe(false); + }); + + it("does not reset when task session params are missing", () => { + expect( + shouldResetTaskSessionForModelChange({ + configuredModel: "gpt-5.4-mini", + taskSessionParams: null, + }), + ).toBe(false); + }); +}); + +describe("stripConfiguredModelFromSessionParams", () => { + it("removes the internal model key from persisted session params", () => { + expect( + stripConfiguredModelFromSessionParams({ + sessionId: "thread-1", + __paperclipConfiguredModel: "gpt-5.4-mini", + }), + ).toEqual({ sessionId: "thread-1" }); + }); + + it("returns null when session params are missing", () => { + expect(stripConfiguredModelFromSessionParams(null)).toBeNull(); + expect(stripConfiguredModelFromSessionParams(undefined)).toBeNull(); + }); + + it("returns a copy without mutating the input", () => { + const input = { sessionId: "thread-1", __paperclipConfiguredModel: "gpt-5.4-mini" }; + const result = stripConfiguredModelFromSessionParams(input); + expect(result).not.toBe(input); + expect(input.__paperclipConfiguredModel).toBe("gpt-5.4-mini"); + }); + + it("returns an empty object when only the internal model key is present (caller must normalize)", () => { + const stripped = stripConfiguredModelFromSessionParams({ + __paperclipConfiguredModel: "gpt-5.4-mini", + }); + expect(stripped).toEqual({}); + // Callers that forward params to adapters must normalize {} back to null so + // the pre-PR null contract is preserved (adapters distinguishing {} from null). + expect(normalizeSessionParams(stripped)).toBeNull(); + }); +}); + +describe("normalizeSessionParams", () => { + it("collapses an empty object to null", () => { + expect(normalizeSessionParams({})).toBeNull(); + }); + + it("returns null for null or undefined inputs", () => { + expect(normalizeSessionParams(null)).toBeNull(); + expect(normalizeSessionParams(undefined)).toBeNull(); + }); + + it("preserves a non-empty object", () => { + const params = { sessionId: "thread-1" }; + expect(normalizeSessionParams(params)).toBe(params); + }); +}); + describe("deriveTaskKeyWithHeartbeatFallback", () => { it("returns explicit taskKey when present", () => { expect(deriveTaskKeyWithHeartbeatFallback({ taskKey: "issue-123" }, null)).toBe("issue-123"); diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index b988dcb3..b88d6995 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2131,6 +2131,49 @@ export function shouldDeferFollowupWakeForSameIssue(input: { return false; } +const SESSION_CONFIGURED_MODEL_KEY = "__paperclipConfiguredModel"; + +function readConfiguredModelFromAdapterConfig( + adapterConfig: Record | null | undefined, +) { + return readNonEmptyString(adapterConfig?.model); +} + +function attachConfiguredModelToSessionParams( + sessionParams: Record | null | undefined, + configuredModel: string | null, +) { + if (!configuredModel) return sessionParams ?? null; + const next = { ...(sessionParams ?? {}) }; + next[SESSION_CONFIGURED_MODEL_KEY] = configuredModel; + return next; +} + +function readConfiguredModelFromSessionParams( + sessionParams: Record | null | undefined, +) { + return readNonEmptyString(sessionParams?.[SESSION_CONFIGURED_MODEL_KEY]); +} + +export function shouldResetTaskSessionForModelChange(input: { + configuredModel: string | null; + taskSessionParams: Record | null | undefined; +}) { + const { configuredModel, taskSessionParams } = input; + if (!configuredModel || !taskSessionParams) return false; + const sessionModel = readConfiguredModelFromSessionParams(taskSessionParams); + return !!sessionModel && sessionModel !== configuredModel; +} + +export function stripConfiguredModelFromSessionParams( + sessionParams: Record | null | undefined, +) { + if (!sessionParams) return null; + const next = { ...sessionParams }; + delete next[SESSION_CONFIGURED_MODEL_KEY]; + return next; +} + function shouldAutoCheckoutIssueForWake(input: { contextSnapshot: Record | null | undefined; issueStatus: string | null; @@ -2846,7 +2889,7 @@ function getAdapterSessionCodec(adapterType: string) { return adapter.sessionCodec ?? defaultSessionCodec; } -function normalizeSessionParams(params: Record | null | undefined) { +export function normalizeSessionParams(params: Record | null | undefined) { if (!params) return null; return Object.keys(params).length > 0 ? params : null; } @@ -7833,11 +7876,27 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) } : null, }); + const config = parseObject(agent.adapterConfig); + const configuredModel = readConfiguredModelFromAdapterConfig(config); const taskSession = taskKey ? await getTaskSession(agent.companyId, agent.id, agent.adapterType, taskKey) : null; - const resetTaskSession = shouldResetTaskSessionForWake(context); - const sessionResetReason = describeSessionResetReason(context); + const taskSessionDecodedParams = normalizeSessionParams( + sessionCodec.deserialize(taskSession?.sessionParamsJson ?? null), + ); + const modelChangedSinceTaskSession = shouldResetTaskSessionForModelChange({ + configuredModel, + taskSessionParams: taskSessionDecodedParams, + }); + const resetTaskSession = shouldResetTaskSessionForWake(context) || modelChangedSinceTaskSession; + const wakeSessionResetReason = describeSessionResetReason(context); + const taskSessionConfiguredModel = readConfiguredModelFromSessionParams(taskSessionDecodedParams); + const modelSessionResetReason = modelChangedSinceTaskSession && taskSessionConfiguredModel + ? `configured model changed from "${taskSessionConfiguredModel}" to "${configuredModel}"` + : null; + const sessionResetReason = [modelSessionResetReason, wakeSessionResetReason] + .filter((value): value is string => Boolean(value)) + .join("; ") || null; const taskSessionForRun = resetTaskSession ? null : taskSession; const explicitResumeSessionParams = normalizeResumeParamsForAdapter( agent.adapterType, @@ -7855,9 +7914,10 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) : null) ?? normalizeResumeParamsForAdapter( agent.adapterType, - sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null), + stripConfiguredModelFromSessionParams( + sessionCodec.deserialize(taskSessionForRun?.sessionParamsJson ?? null), + ), ); - const config = parseObject(agent.adapterConfig); const resolvedExecutionWorkspaceMode = resolveExecutionWorkspaceMode({ projectPolicy: projectExecutionWorkspacePolicy, issueSettings: issueExecutionWorkspaceSettings, @@ -8490,7 +8550,9 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) : runtimeSessionDisplayId; let runtimeSessionIdForAdapter = readNonEmptyString(runtimeSessionParams?.sessionId) ?? runtimeSessionFallback; - let runtimeSessionParamsForAdapter = runtimeSessionParams; + let runtimeSessionParamsForAdapter = normalizeSessionParams( + stripConfiguredModelFromSessionParams(runtimeSessionParams), + ); const sessionCompaction = await evaluateSessionCompaction({ agent, @@ -9159,7 +9221,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agentId: agent.id, adapterType: agent.adapterType, taskKey, - sessionParamsJson: nextSessionState.params, + sessionParamsJson: attachConfiguredModelToSessionParams(nextSessionState.params, configuredModel), sessionDisplayId: nextSessionState.displayId, lastRunId: finalizedRun.id, lastError: outcome === "succeeded" ? null : (adapterResult.errorMessage ?? "run_failed"), @@ -9242,7 +9304,7 @@ export function heartbeatService(db: Db, options: HeartbeatServiceOptions = {}) agentId: agent.id, adapterType: agent.adapterType, taskKey, - sessionParamsJson: previousSessionParams, + sessionParamsJson: attachConfiguredModelToSessionParams(previousSessionParams, configuredModel), sessionDisplayId: previousSessionDisplayId, lastRunId: failedRun.id, lastError: message,