From 604abea3fda922b57aaf0a88178a47e81668c55e Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 04:49:56 +0800 Subject: [PATCH 1/2] fix(protocols): condition input content on its message role Keep refusals on assistant history and use role-specific content contracts in translation and provider consumers. Preserve retained refusal text when compaction restates history as input content. --- .../items/normalize-assistant-content.ts | 6 +++--- .../src/openai-chat-completions/index.ts | 1 - .../protocols/src/openai-responses/index.ts | 13 +++++++++---- packages/provider-codex/src/responses-lite.ts | 4 +++- .../__tests__/compaction_test.ts | 10 ++++++++++ .../openai-responses/compress-images_test.ts | 4 ++-- packages/provider-copilot/src/compaction.ts | 14 +++++++++----- .../openai-chat-completions/compress-images.ts | 6 +++--- .../openai-responses/compress-images.ts | 8 ++++---- .../via-openai-responses/content.ts | 12 +++++------- .../via-openai-responses/request.ts | 6 +++--- .../via-anthropic-messages/request.ts | 2 -- .../via-openai-chat-completions/content.ts | 17 ++++++++--------- 13 files changed, 59 insertions(+), 44 deletions(-) diff --git a/packages/gateway/src/data-plane/chat/openai-responses/items/normalize-assistant-content.ts b/packages/gateway/src/data-plane/chat/openai-responses/items/normalize-assistant-content.ts index 3a238dace2..bc02c811b5 100644 --- a/packages/gateway/src/data-plane/chat/openai-responses/items/normalize-assistant-content.ts +++ b/packages/gateway/src/data-plane/chat/openai-responses/items/normalize-assistant-content.ts @@ -12,9 +12,9 @@ // content type. Only `role: 'assistant'` is rewritten; user/system/developer // messages keep `input_text` because that IS the correct type on those roles. -import type { OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIResponsesAssistantInputContent, CanonicalOpenAIResponsesInputItem } from '@floway-dev/protocols/openai-responses'; -const normalizeAssistantContentBlocks = (content: string | OpenAIResponsesInputContent[]): string | OpenAIResponsesInputContent[] => { +const normalizeAssistantContentBlocks = (content: string | OpenAIResponsesAssistantInputContent[]): string | OpenAIResponsesAssistantInputContent[] => { if (typeof content === 'string') return content; let mutated = false; const next = content.map(block => { @@ -28,7 +28,7 @@ const normalizeAssistantContentBlocks = (content: string | OpenAIResponsesInputC const normalizeItem = (item: CanonicalOpenAIResponsesInputItem): CanonicalOpenAIResponsesInputItem => { if (item.type !== 'message' || item.role !== 'assistant') return item; const next = normalizeAssistantContentBlocks(item.content); - return next === item.content ? item : { ...item, content: next }; + return next === item.content ? item : { ...item, content: next } as CanonicalOpenAIResponsesInputItem; }; export const normalizeAssistantInputText = (input: CanonicalOpenAIResponsesInputItem[]): CanonicalOpenAIResponsesInputItem[] => { diff --git a/packages/protocols/src/openai-chat-completions/index.ts b/packages/protocols/src/openai-chat-completions/index.ts index 17bfc4fc53..e9f9284a2b 100644 --- a/packages/protocols/src/openai-chat-completions/index.ts +++ b/packages/protocols/src/openai-chat-completions/index.ts @@ -102,7 +102,6 @@ export interface OpenAIChatCompletionsFilePart { type: 'file'; file: { file_data export interface OpenAIChatCompletionsRefusalPart { type: 'refusal'; refusal: string } export type OpenAIChatCompletionsUserContentPart = OpenAIChatCompletionsTextPart | OpenAIChatCompletionsImagePart | OpenAIChatCompletionsInputAudioPart | OpenAIChatCompletionsFilePart; export type OpenAIChatCompletionsAssistantContentPart = OpenAIChatCompletionsTextPart | OpenAIChatCompletionsRefusalPart; -export type OpenAIChatCompletionsContentPart = OpenAIChatCompletionsUserContentPart | OpenAIChatCompletionsRefusalPart; // Provider extension types belong only at the assistant boundary. export interface OpenAIChatCompletionsAssistantExtensions { diff --git a/packages/protocols/src/openai-responses/index.ts b/packages/protocols/src/openai-responses/index.ts index 4b750d5384..ed6828cf73 100644 --- a/packages/protocols/src/openai-responses/index.ts +++ b/packages/protocols/src/openai-responses/index.ts @@ -140,18 +140,21 @@ export type CanonicalOpenAIResponsesInputItem = export type OpenAIResponsesMessagePhase = 'commentary' | 'final_answer' | (string & {}) | null; -export interface OpenAIResponsesInputMessageEx { +interface OpenAIResponsesInputMessageFields { type: 'message'; id?: string; status?: string; - role: 'user' | 'assistant' | 'system' | 'developer'; - content: string | OpenAIResponsesInputContent[]; phase?: OpenAIResponsesMessagePhase; // Codex marks base-instruction fragments on ordinary developer messages. // https://github.com/openai/codex/blob/0462dcc062b822bb8fff16cc31ce6eeab69823b9/codex-rs/core/tests/suite/responses_lite.rs#L136-L155 internal_chat_message_metadata_passthrough?: Record; } +export type OpenAIResponsesInputMessageEx = OpenAIResponsesInputMessageFields & ( + | { role: 'user' | 'system' | 'developer'; content: string | OpenAIResponsesInputContent[] } + | { role: 'assistant'; content: string | OpenAIResponsesAssistantInputContent[] } +); + // The OpenAI Responses request schema's EasyInputMessage makes the constant // `type: "message"` discriminator optional. Wire-facing payloads accept that // shorthand; gateway and translator boundaries normalize it before internal @@ -178,7 +181,9 @@ export type CanonicalOpenAIResponsesPayload = Omit(parts: T[]): T[] => const removeLiteImageDetail = (item: CanonicalOpenAIResponsesInputItem): CanonicalOpenAIResponsesInputItem => { if (item.type === 'message' && Array.isArray(item.content)) { const content = removeInputImageDetails(item.content); - return content === item.content ? item : { ...item, content }; + // The role conditions `content`'s shape, so an assistant turn keeps its + // refusal arm while a client role cannot gain one here. + return content === item.content ? item : { ...item, content } as CanonicalOpenAIResponsesInputItem; } if ((item.type === 'function_call_output' || item.type === 'custom_tool_call_output') && Array.isArray(item.output)) { const output = removeInputImageDetails(item.output); diff --git a/packages/provider-copilot/__tests__/compaction_test.ts b/packages/provider-copilot/__tests__/compaction_test.ts index a99e48bdf7..851af1227c 100644 --- a/packages/provider-copilot/__tests__/compaction_test.ts +++ b/packages/provider-copilot/__tests__/compaction_test.ts @@ -55,6 +55,16 @@ test('normalizes every retained text part to input_text — assistant output_tex expect(userPart.type).toBe('input_text'); }); +test('retains assistant refusal text in the compacted input spelling', () => { + const result = compactionResponse([ + { type: 'message', role: 'assistant', content: [{ type: 'refusal', refusal: 'Cannot provide that.' }] }, + ], generatedResult([compaction])); + + expect(result.output[0]).toMatchObject({ + type: 'message', role: 'assistant', content: [{ type: 'input_text', text: 'Cannot provide that.' }], + }); +}); + test('assigns final random ids to retained messages instead of reusing input ids', () => { const result = compactionResponse( [{ type: 'message', id: 'msg_input', role: 'user', content: 'hello' }], diff --git a/packages/provider-copilot/__tests__/interceptors/openai-responses/compress-images_test.ts b/packages/provider-copilot/__tests__/interceptors/openai-responses/compress-images_test.ts index 3e1965f3d9..108f449441 100644 --- a/packages/provider-copilot/__tests__/interceptors/openai-responses/compress-images_test.ts +++ b/packages/provider-copilot/__tests__/interceptors/openai-responses/compress-images_test.ts @@ -4,7 +4,7 @@ import { withInlineImagesCompressed } from '../../../src/interceptors/openai-res import type { OpenAIResponsesBoundaryCtx } from '../../../src/interceptors/openai-responses/types.ts'; import { type ImageProcessor, initImageProcessor } from '@floway-dev/platform'; import type { ProtocolFrame } from '@floway-dev/protocols/common'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, OpenAIResponsesInputImage, CanonicalOpenAIResponsesInputItem, OpenAIResponsesStreamEventEx, OpenAIResponsesToolOutputContent } from '@floway-dev/protocols/openai-responses'; +import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, OpenAIResponsesAssistantInputContent, OpenAIResponsesInputImage, CanonicalOpenAIResponsesInputItem, OpenAIResponsesStreamEventEx, OpenAIResponsesToolOutputContent } from '@floway-dev/protocols/openai-responses'; import type { ExecuteResult } from '@floway-dev/provider'; import { eventResult } from '@floway-dev/provider'; import { assert, assertEquals, stubProviderModel, testTelemetryModelIdentity } from '@floway-dev/test-utils'; @@ -38,7 +38,7 @@ const imageUrlOf = (item: CanonicalOpenAIResponsesInputItem): string | null | un return Array.isArray(content) ? content.find(part => part.type === 'input_image')?.image_url : undefined; }; -const contentOf = (item: CanonicalOpenAIResponsesInputItem): string | OpenAIResponsesInputContent[] | undefined => +const contentOf = (item: CanonicalOpenAIResponsesInputItem): string | OpenAIResponsesAssistantInputContent[] | undefined => item.type === 'message' ? item.content : item.type === 'function_call_output' || item.type === 'custom_tool_call_output' ? item.output : undefined; diff --git a/packages/provider-copilot/src/compaction.ts b/packages/provider-copilot/src/compaction.ts index 63acfa1a6c..f9598745e5 100644 --- a/packages/provider-copilot/src/compaction.ts +++ b/packages/provider-copilot/src/compaction.ts @@ -8,7 +8,7 @@ // https://github.com/openai/codex/blob/3d805abdf09093bfa806f359a5adc6514766c420/codex-rs/core/src/compact_remote_v2.rs#L439-L501 // https://github.com/openai/codex/blob/3d805abdf09093bfa806f359a5adc6514766c420/codex-rs/utils/string/src/truncate.rs#L71-L74 -import { isOpenAIResponsesCompactionItem, createRandomOpenAIResponsesItemId, type OpenAIResponsesCompactionResultEx, type OpenAIResponsesCompactionTriggerItem, type OpenAIResponsesInputContent, type CanonicalOpenAIResponsesInputItem, type OpenAIResponsesInputMessageEx, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; +import { isOpenAIResponsesCompactionItem, createRandomOpenAIResponsesItemId, type OpenAIResponsesCompactionResultEx, type OpenAIResponsesCompactionTriggerItem, type OpenAIResponsesInputContent, type OpenAIResponsesAssistantInputContent, type CanonicalOpenAIResponsesInputItem, type OpenAIResponsesInputMessageEx, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; export const COMPACTION_TRIGGER: OpenAIResponsesCompactionTriggerItem = { type: 'compaction_trigger' }; @@ -28,11 +28,15 @@ const encoder = new TextEncoder(); // Native compact echoes every text part — including assistant `output_text` — // as `input_text` so the client can resend `output` verbatim as next-turn -// `input`. Normalize unconditionally; non-text content passes through and costs -// 0 tokens against the retained budget. -const normalizeContent = (content: OpenAIResponsesInputMessageEx['content']): OpenAIResponsesInputContent[] => { +// `input`. A replayed refusal has no input spelling, so its text is retained as +// ordinary input text; dropping it would shorten the history the client resends. +const normalizeContent = (content: string | OpenAIResponsesAssistantInputContent[]): OpenAIResponsesInputContent[] => { if (typeof content === 'string') return [{ type: 'input_text', text: content }]; - return content.map(part => (part.type === 'output_text' ? { ...part, type: 'input_text' } : part)); + return content.map(part => { + if (part.type === 'output_text') return { ...part, type: 'input_text' as const }; + if (part.type === 'refusal') return { type: 'input_text' as const, text: part.refusal }; + return part; + }); }; const isRetainedMessage = (item: CanonicalOpenAIResponsesInputItem): item is OpenAIResponsesInputMessageEx => diff --git a/packages/provider-copilot/src/interceptors/openai-chat-completions/compress-images.ts b/packages/provider-copilot/src/interceptors/openai-chat-completions/compress-images.ts index d1922a4b88..6d43b035c6 100644 --- a/packages/provider-copilot/src/interceptors/openai-chat-completions/compress-images.ts +++ b/packages/provider-copilot/src/interceptors/openai-chat-completions/compress-images.ts @@ -1,10 +1,10 @@ import { memoizedDataUrlCompressor } from '../image-compression.ts'; import { targetSizeForOpenAIResponsesChat } from '../image-size.ts'; import type { OpenAIChatCompletionsBoundaryCtx, CopilotOpenAIChatCompletionsBoundaryInterceptor } from './types.ts'; -import type { OpenAIChatCompletionsContentPart, OpenAIChatCompletionsMessage } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIChatCompletionsUserContentPart, OpenAIChatCompletionsMessage } from '@floway-dev/protocols/openai-chat-completions'; import { isBase64ImageDataUrl } from '@floway-dev/provider'; -type OpenAIChatCompletionsImagePart = Extract; +type OpenAIChatCompletionsImagePart = Extract; const compressInlineImages = async (ctx: OpenAIChatCompletionsBoundaryCtx): Promise => { const targets: OpenAIChatCompletionsImagePart[] = []; @@ -24,7 +24,7 @@ const compressInlineImages = async (ctx: OpenAIChatCompletionsBoundaryCtx): Prom compressedUrls.set(target, await compress(target.image_url.url)); }), ); - const hasCompressedImage = (part: OpenAIChatCompletionsContentPart): part is OpenAIChatCompletionsImagePart => + const hasCompressedImage = (part: OpenAIChatCompletionsUserContentPart): part is OpenAIChatCompletionsImagePart => part.type === 'image_url' && compressedUrls.has(part); const rewriteImage = (part: OpenAIChatCompletionsImagePart): OpenAIChatCompletionsImagePart => { const url = compressedUrls.get(part); diff --git a/packages/provider-copilot/src/interceptors/openai-responses/compress-images.ts b/packages/provider-copilot/src/interceptors/openai-responses/compress-images.ts index 55dcb79ee4..6d5a0146c4 100644 --- a/packages/provider-copilot/src/interceptors/openai-responses/compress-images.ts +++ b/packages/provider-copilot/src/interceptors/openai-responses/compress-images.ts @@ -1,7 +1,7 @@ import { memoizedDataUrlCompressor } from '../image-compression.ts'; import { targetSizeForOpenAIResponsesChat } from '../image-size.ts'; import type { OpenAIResponsesBoundaryCtx } from './types.ts'; -import type { OpenAIResponsesInputContent, OpenAIResponsesInputImage, OpenAIResponsesToolOutputContent } from '@floway-dev/protocols/openai-responses'; +import type { CanonicalOpenAIResponsesInputItem, OpenAIResponsesInputContent, OpenAIResponsesInputImage, OpenAIResponsesOutputRefusal, OpenAIResponsesToolOutputContent } from '@floway-dev/protocols/openai-responses'; import { isBase64ImageDataUrl } from '@floway-dev/provider'; // A single client request can reach this boundary more than once — a @@ -38,7 +38,7 @@ const compressInlineImages = async (ctx: OpenAIResponsesBoundaryCtx): Promise + const hasCompressedImage = (part: OpenAIResponsesInputContent | OpenAIResponsesOutputRefusal): part is CompressibleImagePart => part.type === 'input_image' && compressedUrls.has(part as CompressibleImagePart); const rewriteImage = (part: CompressibleImagePart): CompressibleImagePart => { const imageUrl = compressedUrls.get(part); @@ -51,7 +51,7 @@ const compressInlineImages = async (ctx: OpenAIResponsesBoundaryCtx): Promise(parts: TPart[]): TPart[] => + const rewriteParts = (parts: TPart[]): TPart[] => parts.map(part => hasCompressedImage(part) ? rewriteImage(part) as TPart : part); ctx.payload = { @@ -59,7 +59,7 @@ const compressInlineImages = async (ctx: OpenAIResponsesBoundaryCtx): Promise { if (item.type === 'message' && Array.isArray(item.content)) { return item.content.some(hasCompressedImage) - ? { ...item, content: rewriteParts(item.content) } + ? { ...item, content: rewriteParts(item.content) } as CanonicalOpenAIResponsesInputItem : item; } if ((item.type === 'function_call_output' || item.type === 'custom_tool_call_output') && Array.isArray(item.output)) { diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts index 5c901126c2..59890ae6b3 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/content.ts @@ -1,20 +1,20 @@ import { TranslatorInputError } from '../../../translator-input-error.ts'; -import type { OpenAIChatCompletionsContentPart } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIChatCompletionsUserContentPart } from '@floway-dev/protocols/openai-chat-completions'; import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses'; // OpenAI Chat Completions and OpenAI Responses text arrays are transport fragments of one message, not // paragraph blocks. Preserve the existing no-separator flattening. -const contentPartText = (part: OpenAIChatCompletionsContentPart | OpenAIResponsesInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); +const contentPartText = (part: OpenAIChatCompletionsUserContentPart | OpenAIResponsesInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); -const contentPartsToText = (parts: readonly (OpenAIChatCompletionsContentPart | OpenAIResponsesInputContent)[]): string => +const contentPartsToText = (parts: readonly (OpenAIChatCompletionsUserContentPart | OpenAIResponsesInputContent)[]): string => parts .map(contentPartText) .filter((text): text is string => text !== null) .join(''); -export const openaiChatCompletionsContentToText = (content: string | OpenAIChatCompletionsContentPart[] | null | undefined): string => (typeof content === 'string' ? content : Array.isArray(content) ? contentPartsToText(content) : ''); +export const openaiChatCompletionsContentToText = (content: string | OpenAIChatCompletionsUserContentPart[] | null | undefined): string => (typeof content === 'string' ? content : Array.isArray(content) ? contentPartsToText(content) : ''); -export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (content: string | OpenAIChatCompletionsContentPart[] | null | undefined): string | OpenAIResponsesInputContent[] => { +export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (content: string | OpenAIChatCompletionsUserContentPart[] | null | undefined): string | OpenAIResponsesInputContent[] => { if (typeof content === 'string') return content; if (!Array.isArray(content) || content.length === 0) return ''; @@ -23,8 +23,6 @@ export const openaiChatCompletionsContentToOpenAIResponsesInputContent = (conten switch (part.type) { case 'text': return { type: 'input_text', text: part.text }; - case 'refusal': - return { type: 'refusal', refusal: part.refusal }; case 'input_audio': throw new TranslatorInputError('Cannot translate input_audio content to OpenAI Responses.'); case 'file': diff --git a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts index 66a4928365..e345fbf574 100644 --- a/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts +++ b/packages/translate/src/protocols/openai-chat-completions/via-openai-responses/request.ts @@ -7,7 +7,7 @@ import type { SidecarCodec } from '../../../types.ts'; import { partitionOpenAIChatCompletionsTurns } from '../as-source/conversation-turns.ts'; import { collectOpenAIChatCompletionsReplayContents, hasOpenAIChatCompletionsReasoning, findOpenAIChatCompletionsSidecarCarriers, verifyOpenAIChatCompletionsTurnDigest } from '../as-source/round-trip.ts'; import type { OpenAIChatCompletionsAssistantMessage, OpenAIChatCompletionsMessage, OpenAIChatCompletionsPayload, OpenAIChatCompletionsTool } from '@floway-dev/protocols/openai-chat-completions'; -import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; +import type { CanonicalOpenAIResponsesPayload, OpenAIResponsesAssistantInputContent, CanonicalOpenAIResponsesInputItem, OpenAIResponsesTool, OpenAIResponsesToolChoice } from '@floway-dev/protocols/openai-responses'; const translateChatTools = (tools?: OpenAIChatCompletionsTool[] | null): OpenAIResponsesTool[] => tools?.length @@ -36,8 +36,8 @@ const translateChatToolChoice = (choice: NonNullable { - const content: OpenAIResponsesInputContent[] = []; +const translateAssistantContent = (message: OpenAIChatCompletionsAssistantMessage): OpenAIResponsesAssistantInputContent[] => { + const content: OpenAIResponsesAssistantInputContent[] = []; let hasRefusalPart = false; if (typeof message.content === 'string') { diff --git a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts index ae2be7981b..dc14613a82 100644 --- a/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts +++ b/packages/translate/src/protocols/openai-responses/via-anthropic-messages/request.ts @@ -136,8 +136,6 @@ const translateToolOutput = async (output: string | OpenAIResponsesInputContent[ blocks.push(image); } else if (part.type === 'input_file') { throw new TranslatorInputError('Cannot translate input_file tool output to Anthropic Messages.'); - } else if (part.type === 'refusal') { - throw new TranslatorInputError('Cannot translate refusal content in a tool output to Anthropic Messages.'); } else { blocks.push({ type: 'text', text: part.text }); } diff --git a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts index 472fb53df5..53433ed362 100644 --- a/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts +++ b/packages/translate/src/protocols/openai-responses/via-openai-chat-completions/content.ts @@ -1,28 +1,28 @@ import { TranslatorInputError } from '../../../translator-input-error.ts'; -import type { OpenAIChatCompletionsContentPart } from '@floway-dev/protocols/openai-chat-completions'; -import type { OpenAIResponsesInputContent } from '@floway-dev/protocols/openai-responses'; +import type { OpenAIChatCompletionsUserContentPart } from '@floway-dev/protocols/openai-chat-completions'; +import type { OpenAIResponsesInputContent, OpenAIResponsesAssistantInputContent } from '@floway-dev/protocols/openai-responses'; // OpenAI Chat Completions and OpenAI Responses text arrays are transport fragments of one message, not // paragraph blocks. Preserve the existing no-separator flattening. -const contentPartText = (part: OpenAIChatCompletionsContentPart | OpenAIResponsesInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); +const contentPartText = (part: OpenAIChatCompletionsUserContentPart | OpenAIResponsesAssistantInputContent): string | null => (part.type === 'text' || part.type === 'input_text' || part.type === 'output_text' ? part.text : null); -const contentPartsToText = (parts: readonly (OpenAIChatCompletionsContentPart | OpenAIResponsesInputContent)[]): string => +const contentPartsToText = (parts: readonly (OpenAIChatCompletionsUserContentPart | OpenAIResponsesAssistantInputContent)[]): string => parts .map(contentPartText) .filter((text): text is string => text !== null) .join(''); -export const openaiResponsesContentToText = (content: string | OpenAIResponsesInputContent[]): string => (typeof content === 'string' ? content : contentPartsToText(content)); +export const openaiResponsesContentToText = (content: string | OpenAIResponsesAssistantInputContent[]): string => (typeof content === 'string' ? content : contentPartsToText(content)); -export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: string | OpenAIResponsesInputContent[]): string | OpenAIChatCompletionsContentPart[] => { +export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: string | OpenAIResponsesInputContent[]): string | OpenAIChatCompletionsUserContentPart[] => { if (typeof content === 'string') return content; if (!content.every((part): part is Exclude => part.type !== 'input_file')) { throw new TranslatorInputError('Cannot translate input_file content to OpenAI Chat Completions.'); } - return content.some(part => part.type === 'input_image' || part.type === 'refusal') + return content.some(part => part.type === 'input_image') ? content.map( - (part): OpenAIChatCompletionsContentPart => { + (part): OpenAIChatCompletionsUserContentPart => { if (part.type === 'input_image') { if (typeof part.image_url !== 'string') { throw new TranslatorInputError('Cannot translate file_id-only image content to OpenAI Chat Completions.'); @@ -41,7 +41,6 @@ export const openaiResponsesContentToOpenAIChatCompletionsContent = (content: st }, }; } - if (part.type === 'refusal') return { type: 'refusal', refusal: part.refusal }; return { type: 'text', text: part.text }; }, ) From a6f49571566902f14ce10f3e81aa336ace4c697a Mon Sep 17 00:00:00 2001 From: Menci Date: Thu, 12 Nov 2026 15:40:52 +0800 Subject: [PATCH 2/2] fix(copilot): preserve refusals in retained compaction history Keep assistant refusal content and its existing retention-budget treatment while applying role-specific content contracts. Replace the unsupported text-normalization expectation with a mixed-content regression that also checks retained history. --- .../__tests__/compaction_test.ts | 12 ++++++---- packages/provider-copilot/src/compaction.ts | 23 +++++++------------ 2 files changed, 16 insertions(+), 19 deletions(-) diff --git a/packages/provider-copilot/__tests__/compaction_test.ts b/packages/provider-copilot/__tests__/compaction_test.ts index 851af1227c..d984be3740 100644 --- a/packages/provider-copilot/__tests__/compaction_test.ts +++ b/packages/provider-copilot/__tests__/compaction_test.ts @@ -55,13 +55,17 @@ test('normalizes every retained text part to input_text — assistant output_tex expect(userPart.type).toBe('input_text'); }); -test('retains assistant refusal text in the compacted input spelling', () => { +test('preserves retained assistant refusals without charging them as text', () => { + const refusal = { type: 'refusal' as const, refusal: 'x'.repeat(64_001 * 4) }; const result = compactionResponse([ - { type: 'message', role: 'assistant', content: [{ type: 'refusal', refusal: 'Cannot provide that.' }] }, + { type: 'message', role: 'user', content: 'older' }, + { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'reply' }, refusal] }, + { type: 'message', role: 'user', content: 'recent' }, ], generatedResult([compaction])); - expect(result.output[0]).toMatchObject({ - type: 'message', role: 'assistant', content: [{ type: 'input_text', text: 'Cannot provide that.' }], + expect(shape(result)).toEqual(['message:user', 'message:assistant', 'message:user', 'compaction']); + expect(result.output[1]).toMatchObject({ + type: 'message', role: 'assistant', content: [{ type: 'input_text', text: 'reply' }, refusal], }); }); diff --git a/packages/provider-copilot/src/compaction.ts b/packages/provider-copilot/src/compaction.ts index f9598745e5..dc87ff8733 100644 --- a/packages/provider-copilot/src/compaction.ts +++ b/packages/provider-copilot/src/compaction.ts @@ -8,7 +8,7 @@ // https://github.com/openai/codex/blob/3d805abdf09093bfa806f359a5adc6514766c420/codex-rs/core/src/compact_remote_v2.rs#L439-L501 // https://github.com/openai/codex/blob/3d805abdf09093bfa806f359a5adc6514766c420/codex-rs/utils/string/src/truncate.rs#L71-L74 -import { isOpenAIResponsesCompactionItem, createRandomOpenAIResponsesItemId, type OpenAIResponsesCompactionResultEx, type OpenAIResponsesCompactionTriggerItem, type OpenAIResponsesInputContent, type OpenAIResponsesAssistantInputContent, type CanonicalOpenAIResponsesInputItem, type OpenAIResponsesInputMessageEx, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; +import { isOpenAIResponsesCompactionItem, createRandomOpenAIResponsesItemId, type OpenAIResponsesCompactionResultEx, type OpenAIResponsesCompactionTriggerItem, type OpenAIResponsesAssistantInputContent, type CanonicalOpenAIResponsesInputItem, type CanonicalOpenAIResponsesText, type OpenAIResponsesInputMessageEx, type OpenAIResponsesOutputItemEx, type OpenAIResponsesResultEx } from '@floway-dev/protocols/openai-responses'; export const COMPACTION_TRIGGER: OpenAIResponsesCompactionTriggerItem = { type: 'compaction_trigger' }; @@ -26,17 +26,9 @@ const RETAINED_BUDGET_TOKENS = 64_000; const APPROX_BYTES_PER_TOKEN = 4; const encoder = new TextEncoder(); -// Native compact echoes every text part — including assistant `output_text` — -// as `input_text` so the client can resend `output` verbatim as next-turn -// `input`. A replayed refusal has no input spelling, so its text is retained as -// ordinary input text; dropping it would shorten the history the client resends. -const normalizeContent = (content: string | OpenAIResponsesAssistantInputContent[]): OpenAIResponsesInputContent[] => { +const normalizeContent = (content: string | Content[]): (Content | CanonicalOpenAIResponsesText)[] => { if (typeof content === 'string') return [{ type: 'input_text', text: content }]; - return content.map(part => { - if (part.type === 'output_text') return { ...part, type: 'input_text' as const }; - if (part.type === 'refusal') return { type: 'input_text' as const, text: part.refusal }; - return part; - }); + return content.map(part => (part.type === 'output_text' ? { ...part, type: 'input_text' } : part)); }; const isRetainedMessage = (item: CanonicalOpenAIResponsesInputItem): item is OpenAIResponsesInputMessageEx => @@ -58,8 +50,10 @@ export const compactionResponse = (input: CanonicalOpenAIResponsesInputItem[], g const item = input[i]; if (!isRetainedMessage(item)) continue; - const content = normalizeContent(item.content); - const tokens = content.reduce((sum, part) => + const message = item.role === 'assistant' + ? { role: item.role, content: normalizeContent(item.content) } + : { role: item.role, content: normalizeContent(item.content) }; + const tokens = message.content.reduce((sum, part) => part.type === 'input_text' ? sum + Math.ceil(encoder.encode(part.text).length / APPROX_BYTES_PER_TOKEN) : sum, 0); @@ -70,8 +64,7 @@ export const compactionResponse = (input: CanonicalOpenAIResponsesInputItem[], g type: 'message', id: createRandomOpenAIResponsesItemId('message'), status: item.status ?? 'completed', - role: item.role, - content, + ...message, ...(item.phase !== undefined ? { phase: item.phase } : {}), }); }