




















@@ -39,7 +39,11 @@ import {
3939} from "../session-transcript-repair.js";
4040import type { SessionManager } from "../sessions/index.js";
4141import { STREAM_ERROR_FALLBACK_TEXT } from "../stream-message-shared.js";
42-import { sanitizeToolCallIdsForCloudCodeAssist } from "../tool-call-id.js";
42+import {
43+extractToolCallsFromAssistant,
44+extractToolResultId,
45+sanitizeToolCallIdsForCloudCodeAssist,
46+} from "../tool-call-id.js";
4347import type { TranscriptPolicy } from "../transcript-policy.js";
4448import {
4549providerRequiresSignedThinking,
@@ -588,6 +592,78 @@ function isSameModelSnapshot(a: ModelSnapshotEntry, b: ModelSnapshotEntry): bool
588592);
589593}
590594595+function formatOpenAIResponsesReplayInvariantError(params: {
596+reason: "dangling_tool_call" | "orphan_tool_result";
597+toolCallId?: string;
598+messageIndex: number;
599+}): Error {
600+const toolCallId = params.toolCallId ? ` toolCallId=${params.toolCallId}` : "";
601+return new Error(
602+`invalid_replay_transcript: OpenAI Responses replay contains ${params.reason}${toolCallId} at message index ${params.messageIndex}`,
603+);
604+}
605+606+function assertOpenAIResponsesToolUseResultInvariant(messages: AgentMessage[]): AgentMessage[] {
607+const pending = new Map<string, { messageIndex: number }>();
608+609+for (let i = 0; i < messages.length; i += 1) {
610+const message = messages[i];
611+const role = (message as { role?: unknown } | undefined)?.role;
612+613+if (pending.size > 0 && role !== "toolResult") {
614+const [toolCallId, meta] = pending.entries().next().value as [
615+string,
616+{ messageIndex: number },
617+];
618+throw formatOpenAIResponsesReplayInvariantError({
619+reason: "dangling_tool_call",
620+ toolCallId,
621+messageIndex: meta.messageIndex,
622+});
623+}
624+625+if (!message || typeof message !== "object") {
626+continue;
627+}
628+629+if (role === "toolResult") {
630+const toolCallId = extractToolResultId(
631+message as Extract<AgentMessage, { role: "toolResult" }>,
632+);
633+if (!toolCallId || !pending.has(toolCallId)) {
634+throw formatOpenAIResponsesReplayInvariantError({
635+reason: "orphan_tool_result",
636+ ...(toolCallId ? { toolCallId } : {}),
637+messageIndex: i,
638+});
639+}
640+pending.delete(toolCallId);
641+continue;
642+}
643+644+if (role !== "assistant") {
645+continue;
646+}
647+648+for (const toolCall of extractToolCallsFromAssistant(
649+message as Extract<AgentMessage, { role: "assistant" }>,
650+)) {
651+pending.set(toolCall.id, { messageIndex: i });
652+}
653+}
654+655+if (pending.size > 0) {
656+const [toolCallId, meta] = pending.entries().next().value as [string, { messageIndex: number }];
657+throw formatOpenAIResponsesReplayInvariantError({
658+reason: "dangling_tool_call",
659+ toolCallId,
660+messageIndex: meta.messageIndex,
661+});
662+}
663+664+return messages;
665+}
666+591667/**
592668 * Applies the generic replay-history cleanup pipeline before provider-owned
593669 * replay hooks run.
@@ -749,6 +825,9 @@ export async function sanitizeSessionHistory(params: {
749825providerSanitized = providerResult ?? undefined;
750826}
751827const sanitizedWithProvider = providerSanitized ?? sanitizedCompactionUsage;
828+const responsesInvariantChecked = isOpenAIResponsesApi
829+ ? assertOpenAIResponsesToolUseResultInvariant(sanitizedWithProvider)
830+ : sanitizedWithProvider;
752831753832if (hasSnapshot && (!priorSnapshot || modelChanged)) {
754833appendModelSnapshot(params.sessionManager, {
@@ -760,7 +839,7 @@ export async function sanitizeSessionHistory(params: {
760839}
761840762841if (!policy.applyGoogleTurnOrdering) {
763-return sanitizedWithProvider;
842+return responsesInvariantChecked;
764843}
765844766845// Strict OpenAI-compatible providers (vLLM, Gemma, etc.) also reject
@@ -769,7 +848,10 @@ export async function sanitizeSessionHistory(params: {
769848// provider-owned ordering rewrite above; keep this generic fallback for the
770849// strict OpenAI-compatible path and for any provider that leaves assistant-
771850// first repair to core. See #38962.
772-return sanitizeGoogleTurnOrdering(sanitizedWithProvider);
851+const googleOrdered = sanitizeGoogleTurnOrdering(responsesInvariantChecked);
852+return isOpenAIResponsesApi
853+ ? assertOpenAIResponsesToolUseResultInvariant(googleOrdered)
854+ : googleOrdered;
773855}
774856775857/**
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。