fix(agents): anchor replayable-reasoning drop to the model-switch timestamp (#115272) (#115287)

This commit is contained in:
Peter Steinberger
2026-07-28 13:13:58 -04:00
committed by GitHub
parent 4ebaf846d9
commit f43064d48b
5 changed files with 202 additions and 34 deletions
@@ -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<typeof downgradeOpenAIReasoningBlocks>[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<typeof downgradeOpenAIReasoningBlocks>[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<typeof downgradeOpenAIReasoningBlocks>[0],
{ dropReplayableReasoning: true },
{ dropReplayableReasoningBefore: 2 },
),
).toEqual([
{
role: "assistant",
timestamp: 1,
content: [
{
type: "text",
+19 -2
View File
@@ -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<AgentMessage, { role: "assistant" }>["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;
@@ -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<Saniti
export function makeReasoningAssistantMessages(opts?: {
thinkingSignature?: "object" | "json";
includeText?: boolean;
timestamp?: number;
}): AgentMessage[] {
const thinkingSignature: unknown =
opts?.thinkingSignature === "json"
@@ -136,6 +146,7 @@ export function makeReasoningAssistantMessages(opts?: {
{
role: "assistant",
content,
...(opts?.timestamp === undefined ? {} : { timestamp: opts.timestamp }),
},
];
@@ -185,7 +196,10 @@ function makeSnapshotChangedOpenAIReasoningScenario() {
];
return {
sessionManager: makeInMemorySessionManager(sessionEntries),
messages: makeReasoningAssistantMessages({ thinkingSignature: "object", includeText: true }),
messages: makeReasoningAssistantMessages({
thinkingSignature: "object",
includeText: true,
}),
modelId: "gpt-5.4",
};
}
@@ -1241,6 +1241,106 @@ describe("sanitizeSessionHistory", () => {
]);
});
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({
@@ -79,6 +79,10 @@ type ModelSnapshotEntry = {
modelApi?: string | null;
modelId?: string;
};
type ModelSnapshotState = {
lastSnapshot: ModelSnapshotEntry | null;
latestSwitchTimestamp: number | null;
};
type AssistantReplayMessage = Extract<AgentMessage, { role: "assistant" }>;
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) {