diff --git a/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts b/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts index 37d096c749a3..cf785f0a5058 100644 --- a/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts +++ b/src/agents/embedded-agent-helpers.sanitizeuserfacingtext.test.ts @@ -754,10 +754,11 @@ describe("downgradeOpenAIReasoningBlocks", () => { ).toEqual(input); }); - it("drops replayable reasoning when requested even with following content", () => { + it("drops replayable reasoning at the switch boundary even with following content", () => { const input = [ { role: "assistant", + timestamp: 2, content: [ { type: "thinking", @@ -772,9 +773,9 @@ describe("downgradeOpenAIReasoningBlocks", () => { expect( downgradeOpenAIReasoningBlocks( input as Parameters[0], - { dropReplayableReasoning: true }, + { dropReplayableReasoningBefore: 2 }, ), - ).toEqual([{ role: "assistant", content: [{ type: "text", text: "answer" }] }]); + ).toEqual([{ role: "assistant", timestamp: 2, content: [{ type: "text", text: "answer" }] }]); }); it("drops the paired message id when replayable reasoning is dropped", () => { @@ -799,7 +800,7 @@ describe("downgradeOpenAIReasoningBlocks", () => { expect( downgradeOpenAIReasoningBlocks( input as Parameters[0], - { dropReplayableReasoning: true }, + { dropReplayableReasoningBefore: 2 }, ), ).toEqual([{ role: "assistant", content: [{ type: "text", text: "answer" }] }]); }); @@ -832,6 +833,7 @@ describe("downgradeOpenAIReasoningBlocks", () => { const input = [ { role: "assistant", + timestamp: 1, content: [ { type: "thinking", @@ -854,11 +856,12 @@ describe("downgradeOpenAIReasoningBlocks", () => { expect( downgradeOpenAIReasoningBlocks( input as Parameters[0], - { dropReplayableReasoning: true }, + { dropReplayableReasoningBefore: 2 }, ), ).toEqual([ { role: "assistant", + timestamp: 1, content: [ { type: "text", diff --git a/src/agents/embedded-agent-helpers/openai.ts b/src/agents/embedded-agent-helpers/openai.ts index a0721bfdcffc..f394aa9d8d13 100644 --- a/src/agents/embedded-agent-helpers/openai.ts +++ b/src/agents/embedded-agent-helpers/openai.ts @@ -21,7 +21,7 @@ type OpenAIReasoningSignature = { }; type DowngradeOpenAIReasoningBlocksOptions = { - dropReplayableReasoning?: boolean; + dropReplayableReasoningBefore?: number; }; const OPENAI_RESPONSES_ID_MAX_LENGTH = 64; @@ -60,6 +60,17 @@ function parseOpenAIReasoningSignature(value: unknown): OpenAIReasoningSignature return null; } +function parseTimestampMs(value: unknown): number | null { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + if (typeof value === "string") { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + return null; +} + function hasFollowingNonThinkingBlock( content: Extract["content"], index: number, @@ -434,6 +445,12 @@ export function downgradeOpenAIReasoningBlocks( out.push(msg); continue; } + const messageTimestamp = parseTimestampMs((assistantMsg as { timestamp?: unknown }).timestamp); + // Timestamp-less legacy entries cannot prove they belong to the new route; + // treat them as pre-switch so stale provider ids never re-enter replay. + const dropReplayableReasoning = + options.dropReplayableReasoningBefore !== undefined && + (messageTimestamp === null || messageTimestamp <= options.dropReplayableReasoningBefore); let changed = false; let droppedReplayableReasoning = false; @@ -459,7 +476,7 @@ export function downgradeOpenAIReasoningBlocks( nextContent.push(block); continue; } - if (options.dropReplayableReasoning) { + if (dropReplayableReasoning) { changed = true; droppedReplayableReasoning = true; continue; diff --git a/src/agents/embedded-agent-runner.sanitize-session-history.test-harness.ts b/src/agents/embedded-agent-runner.sanitize-session-history.test-harness.ts index 5736b31e30f3..1dfb3f41250d 100644 --- a/src/agents/embedded-agent-runner.sanitize-session-history.test-harness.ts +++ b/src/agents/embedded-agent-runner.sanitize-session-history.test-harness.ts @@ -31,11 +31,19 @@ export function makeModelSnapshotEntry(data: { }; } -export function makeInMemorySessionManager(entries: SessionEntry[]): SessionManager { +export function makeInMemorySessionManager( + entries: SessionEntry[], + activeBranchEntries: SessionEntry[] = entries, +): SessionManager { return { getEntries: vi.fn(() => entries), + getBranch: vi.fn(() => activeBranchEntries), appendCustomEntry: vi.fn((customType: string, data: unknown) => { - entries.push({ type: "custom", customType, data }); + const entry = { type: "custom", customType, data }; + entries.push(entry); + if (activeBranchEntries !== entries) { + activeBranchEntries.push(entry); + } }), } as unknown as SessionManager; } @@ -43,6 +51,7 @@ export function makeInMemorySessionManager(entries: SessionEntry[]): SessionMana export function makeMockSessionManager(): SessionManager { return { getEntries: vi.fn().mockReturnValue([]), + getBranch: vi.fn().mockReturnValue([]), appendCustomEntry: vi.fn(), } as unknown as SessionManager; } @@ -114,6 +123,7 @@ export async function loadSanitizeSessionHistoryWithCleanMocks(): Promise { ]); }); + it("keeps pre-switch reasoning dropped on the switch turn and the next turn", async () => { + const sessionEntries = [ + makeModelSnapshotEntry({ + timestamp: 100, + provider: "anthropic", + modelApi: "anthropic-messages", + modelId: "claude-3-7", + }), + ]; + const sessionManager = makeInMemorySessionManager(sessionEntries); + const messages = [ + makeAssistantMessage( + [ + { + type: "thinking", + thinking: "reasoning before the switch", + thinkingSignature: JSON.stringify({ id: "rs_old", type: "reasoning" }), + }, + { type: "text", text: "answer before the switch" }, + ], + { timestamp: 150 }, + ), + ]; + + const switchTurn = await sanitizeWithOpenAIResponses({ + sanitizeSessionHistory, + messages, + modelId: "gpt-5.4", + sessionManager, + }); + const nextTurn = await sanitizeWithOpenAIResponses({ + sanitizeSessionHistory, + messages, + modelId: "gpt-5.4", + sessionManager, + }); + + expect((switchTurn[0] as AssistantMessage).content).toEqual([ + { type: "text", text: "answer before the switch" }, + ]); + expect(JSON.stringify(nextTurn)).toBe(JSON.stringify(switchTurn)); + }); + + it("keeps reasoning newer than the latest actual model switch", async () => { + const sessionEntries = [ + makeModelSnapshotEntry({ + timestamp: 100, + provider: "anthropic", + modelApi: "anthropic-messages", + modelId: "claude-3-7", + }), + makeModelSnapshotEntry({ + timestamp: 200, + provider: "openai", + modelApi: "openai-responses", + modelId: "gpt-5.4", + }), + makeModelSnapshotEntry({ + timestamp: 300, + provider: "openai", + modelApi: "openai-responses", + modelId: "gpt-5.4", + }), + ]; + const makeReasoningMessage = (id: string, text: string, timestamp: number) => + makeAssistantMessage( + [ + { + type: "thinking", + thinking: `reasoning ${text}`, + thinkingSignature: JSON.stringify({ id: `rs_${id}`, type: "reasoning" }), + }, + { type: "text", text }, + ], + { timestamp }, + ); + const result = await sanitizeWithOpenAIResponses({ + sanitizeSessionHistory, + messages: [ + makeReasoningMessage("old", "before switch", 150), + makeUserMessage("after switch", 225), + makeReasoningMessage("new", "after switch", 250), + ], + modelId: "gpt-5.4", + sessionManager: makeInMemorySessionManager(sessionEntries), + }); + + expect((result[0] as AssistantMessage).content).toEqual([ + { type: "text", text: "before switch" }, + ]); + expect((result[2] as AssistantMessage).content).toEqual([ + { + type: "thinking", + thinking: "reasoning after switch", + thinkingSignature: JSON.stringify({ id: "rs_new", type: "reasoning" }), + }, + { type: "text", text: "after switch" }, + ]); + }); + it("drops the paired assistant message id when reasoning is dropped after a model switch", async () => { // Regression for issue #88019: a fallback from azure-openai-responses to a // non-Responses model and back must not leave an orphaned msg_* id (its @@ -1349,18 +1449,35 @@ describe("sanitizeSessionHistory", () => { ]); }); - it("keeps paired openai reasoning when the model snapshot stays the same", async () => { - const sessionEntries = [ + it("keeps paired openai reasoning when the active branch never switched", async () => { + const activeSnapshot = makeModelSnapshotEntry({ + timestamp: 100, + provider: "openai", + modelApi: "openai-responses", + modelId: "gpt-5.4", + }); + const abandonedBranchSnapshots = [ makeModelSnapshotEntry({ + timestamp: 200, + provider: "anthropic", + modelApi: "anthropic-messages", + modelId: "claude-3-7", + }), + makeModelSnapshotEntry({ + timestamp: 300, provider: "openai", modelApi: "openai-responses", modelId: "gpt-5.4", }), ]; - const sessionManager = makeInMemorySessionManager(sessionEntries); + const sessionManager = makeInMemorySessionManager( + [activeSnapshot, ...abandonedBranchSnapshots], + [activeSnapshot], + ); const messages = makeReasoningAssistantMessages({ thinkingSignature: "json", includeText: true, + timestamp: 1, }); const result = await sanitizeWithOpenAIResponses({ diff --git a/src/agents/embedded-agent-runner/replay-history.ts b/src/agents/embedded-agent-runner/replay-history.ts index 24bfcc22e539..6690652ebef2 100644 --- a/src/agents/embedded-agent-runner/replay-history.ts +++ b/src/agents/embedded-agent-runner/replay-history.ts @@ -79,6 +79,10 @@ type ModelSnapshotEntry = { modelApi?: string | null; modelId?: string; }; +type ModelSnapshotState = { + lastSnapshot: ModelSnapshotEntry | null; + latestSwitchTimestamp: number | null; +}; type AssistantReplayMessage = Extract; type ProviderReplayHookParams = { @@ -626,23 +630,31 @@ function createProviderReplaySessionState( }; } -function readLastModelSnapshot(sessionManager: SessionManager): ModelSnapshotEntry | null { +function readModelSnapshotState(sessionManager: SessionManager): ModelSnapshotState { + let lastSnapshot: ModelSnapshotEntry | null = null; + let latestSwitchTimestamp: number | null = null; try { - const entries = sessionManager.getEntries(); - for (let i = entries.length - 1; i >= 0; i -= 1) { - const entry = entries[i] as CustomEntryLike; + for (const rawEntry of sessionManager.getBranch()) { + const entry = rawEntry as CustomEntryLike; if (entry?.type !== "custom" || entry?.customType !== MODEL_SNAPSHOT_CUSTOM_TYPE) { continue; } const data = entry?.data as ModelSnapshotEntry | undefined; if (data && typeof data === "object") { - return data; + if ( + lastSnapshot && + !isSameModelSnapshot(lastSnapshot, data) && + Number.isFinite(data.timestamp) + ) { + latestSwitchTimestamp = data.timestamp; + } + lastSnapshot = data; } } } catch { - return null; + return { lastSnapshot: null, latestSwitchTimestamp: null }; } - return null; + return { lastSnapshot, latestSwitchTimestamp }; } function appendModelSnapshot(sessionManager: SessionManager, data: ModelSnapshotEntry): void { @@ -777,15 +789,23 @@ export async function sanitizeSessionHistory(params: { params.modelApi === "openai-chatgpt-responses" || params.modelApi === "azure-openai-responses"; const hasSnapshot = Boolean(params.provider || params.modelApi || params.modelId); - const priorSnapshot = hasSnapshot ? readLastModelSnapshot(params.sessionManager) : null; - const modelChanged = priorSnapshot - ? !isSameModelSnapshot(priorSnapshot, { - timestamp: 0, + const snapshotState = hasSnapshot + ? readModelSnapshotState(params.sessionManager) + : { lastSnapshot: null, latestSwitchTimestamp: null }; + const priorSnapshot = snapshotState.lastSnapshot; + const currentSnapshot: ModelSnapshotEntry | null = hasSnapshot + ? { + timestamp: Date.now(), provider: params.provider, modelApi: params.modelApi, modelId: params.modelId, - }) - : false; + } + : null; + const modelChanged = + priorSnapshot && currentSnapshot ? !isSameModelSnapshot(priorSnapshot, currentSnapshot) : false; + const latestModelSwitchTimestamp = modelChanged + ? currentSnapshot?.timestamp + : snapshotState.latestSwitchTimestamp; const normalizedAssistantReplay = normalizeAssistantReplayContent(withInterSessionMarkers); const sanitizedImages = await sanitizeSessionMessagesImages( normalizedAssistantReplay, @@ -851,8 +871,10 @@ export async function sanitizeSessionHistory(params: { const openAISafeToolCalls = isOpenAIResponsesApi ? downgradeOpenAIFunctionCallReasoningPairs( normalizeOpenAIResponsesToolCallIds( + // Keep the pre-switch prompt prefix byte-stable: once rs_*/msg_* ids are + // invalidated by a switch, every later replay must keep dropping them. downgradeOpenAIReasoningBlocks(openAIRepairedToolCalls, { - dropReplayableReasoning: modelChanged, + dropReplayableReasoningBefore: latestModelSwitchTimestamp ?? undefined, }), ), ) @@ -907,13 +929,8 @@ export async function sanitizeSessionHistory(params: { ? assertOpenAIResponsesToolUseResultInvariant(responsesProviderRepaired) : responsesProviderRepaired; - if (hasSnapshot && (!priorSnapshot || modelChanged)) { - appendModelSnapshot(params.sessionManager, { - timestamp: Date.now(), - provider: params.provider, - modelApi: params.modelApi, - modelId: params.modelId, - }); + if (currentSnapshot && (!priorSnapshot || modelChanged)) { + appendModelSnapshot(params.sessionManager, currentSnapshot); } if (!policy.applyGoogleTurnOrdering) {