diff --git a/open-sse/executors/codex.ts b/open-sse/executors/codex.ts index 2e7b6f35fe7..32a9b08803a 100644 --- a/open-sse/executors/codex.ts +++ b/open-sse/executors/codex.ts @@ -26,6 +26,7 @@ import { getRememberedResponseConversationItems, getRememberedResponseFunctionCalls, } from "../services/responsesToolCallState.ts"; +import { sanitizeResponsesInputItems } from "../services/responsesInputSanitizer.ts"; import { getThinkingBudgetConfig, ThinkingMode } from "../services/thinkingBudget.ts"; import { CORS_HEADERS } from "../utils/cors.ts"; import { createRequire } from "module"; @@ -45,6 +46,11 @@ type WreqWebSocket = { onclose: (() => void) | null; }; type WebsocketFn = (url: string, opts?: Record) => Promise; +type ResponsesMessageInput = { + role?: unknown; + phase?: unknown; + content?: unknown; +}; let _websocketFn: WebsocketFn | null = null; let _wreqChecked = false; @@ -1185,16 +1191,27 @@ export class CodexExecutor extends BaseExecutor { // Issue #1832 & #1853: Map messages to input for clients like Cursor 5.5 that use responses/compact but send messages instead of input. // This MUST run before convertSystemToDeveloperRole and stripStoredItemReferences. if (!body.input && Array.isArray(body.messages)) { - body.input = body.messages.map((msg: any) => ({ + body.input = body.messages.map((msg: ResponsesMessageInput) => ({ type: "message", role: typeof msg.role === "string" ? msg.role : "user", + ...(typeof msg.phase === "string" ? { phase: msg.phase } : {}), content: typeof msg.content === "string" ? [{ type: "input_text", text: msg.content }] : Array.isArray(msg.content) - ? msg.content.map((c: any) => { - if (c && c.type === "text") return { type: "input_text", text: c.text }; - return c; + ? msg.content.map((contentPart: unknown) => { + if ( + contentPart && + typeof contentPart === "object" && + !Array.isArray(contentPart) && + (contentPart as Record).type === "text" + ) { + return { + type: "input_text", + text: (contentPart as Record).text, + }; + } + return contentPart; }) : [], })); @@ -1215,6 +1232,10 @@ export class CodexExecutor extends BaseExecutor { })); } + if (Array.isArray(body.input)) { + body.input = sanitizeResponsesInputItems(body.input, false); + } + // ── Cache-aware system prompt handling (both paths) ── // // Convert system → developer role IN-PLACE so system prompts remain in the diff --git a/open-sse/services/responsesInputSanitizer.ts b/open-sse/services/responsesInputSanitizer.ts new file mode 100644 index 00000000000..31a540feb86 --- /dev/null +++ b/open-sse/services/responsesInputSanitizer.ts @@ -0,0 +1,36 @@ +type JsonRecord = Record; + +function toRecord(value: unknown): JsonRecord | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; +} + +function isResponsesMessageItem(record: JsonRecord): boolean { + return record.type === "message" || (!record.type && typeof record.role === "string"); +} + +export function isInternalAssistantMessage(record: JsonRecord): boolean { + if (!isResponsesMessageItem(record)) return false; + if (record.role !== "assistant") return false; + + const phase = typeof record.phase === "string" ? record.phase.trim().toLowerCase() : ""; + if (!phase) return false; + + // OpenCode can send assistant-side commentary/analysis frames in Responses + // shape. Those frames are local runtime state, not durable conversation turns. + return phase !== "final"; +} + +export function sanitizeResponsesInputItems(items: readonly unknown[], clone = true): unknown[] { + const sanitized: unknown[] = []; + + for (const item of items) { + const record = toRecord(item); + if (record && isInternalAssistantMessage(record)) { + continue; + } + + sanitized.push(clone ? structuredClone(item) : item); + } + + return sanitized; +} diff --git a/open-sse/services/responsesToolCallState.ts b/open-sse/services/responsesToolCallState.ts index 5160f02ec2d..9ded2ecefee 100644 --- a/open-sse/services/responsesToolCallState.ts +++ b/open-sse/services/responsesToolCallState.ts @@ -1,3 +1,5 @@ +import { sanitizeResponsesInputItems } from "./responsesInputSanitizer.ts"; + type JsonRecord = Record; type RememberedFunctionCall = { @@ -28,36 +30,8 @@ function toRecord(value: unknown): JsonRecord | null { return value && typeof value === "object" && !Array.isArray(value) ? (value as JsonRecord) : null; } -function isResponsesMessageItem(record: JsonRecord): boolean { - return record.type === "message" || (!record.type && typeof record.role === "string"); -} - -function isInternalAssistantMessage(record: JsonRecord): boolean { - if (!isResponsesMessageItem(record)) return false; - if (record.role !== "assistant") return false; - - const phase = typeof record.phase === "string" ? record.phase.trim().toLowerCase() : ""; - if (!phase) return false; - - // OpenCode can send assistant-side commentary/analysis frames in Responses - // shape. Those frames are local runtime state, not durable conversation turns - // for Codex replay. Re-injecting them makes the model continue hidden notes. - return phase !== "final"; -} - function sanitizeRememberedConversationItems(items: readonly unknown[]): unknown[] { - const sanitized: unknown[] = []; - - for (const item of items) { - const record = toRecord(item); - if (record && isInternalAssistantMessage(record)) { - continue; - } - - sanitized.push(structuredClone(item)); - } - - return sanitized; + return sanitizeResponsesInputItems(items); } function cleanupRememberedResponseToolCalls(now: number = Date.now()) { diff --git a/tests/unit/executor-codex.test.ts b/tests/unit/executor-codex.test.ts index 468c63f28eb..3474b527f55 100644 --- a/tests/unit/executor-codex.test.ts +++ b/tests/unit/executor-codex.test.ts @@ -468,6 +468,109 @@ test("CodexExecutor.transformRequest does not replay internal assistant commenta assert.equal(result.input[3].type, "function_call_output"); }); +test("CodexExecutor.transformRequest strips raw internal assistant commentary without dropping useful Responses items", () => { + const executor = new CodexExecutor(); + const body = { + _nativeCodexPassthrough: true, + input: [ + { + type: "message", + role: "user", + content: [{ type: "input_text", text: "Use the tool result." }], + }, + { + type: "message", + role: "assistant", + phase: "commentary", + content: [{ type: "output_text", text: "Need maybe inspect tool output first." }], + }, + { + type: "message", + role: "assistant", + phase: "final", + content: [{ type: "output_text", text: "Visible final assistant answer." }], + }, + { + type: "message", + role: "assistant", + content: [{ type: "output_text", text: "Visible assistant history without phase." }], + }, + { + type: "reasoning", + summary: [{ type: "summary_text", text: "formal reasoning item" }], + }, + { + type: "function_call", + call_id: "call_keep_123", + name: "workspace_read_file", + arguments: '{"path":"README.md"}', + }, + { + type: "function_call_output", + call_id: "call_keep_123", + output: '{"ok":true}', + }, + ], + stream: false, + }; + + const result = executor.transformRequest("gpt-5.5-low", body, false, { + requestEndpointPath: "/responses", + }); + + assert.equal( + result.input.some((item) => JSON.stringify(item).includes("Need maybe inspect tool output")), + false + ); + assert.equal( + result.input.some((item) => JSON.stringify(item).includes("Visible final assistant answer")), + true + ); + assert.equal( + result.input.some((item) => JSON.stringify(item).includes("Visible assistant history without phase")), + true + ); + assert.equal(result.input.some((item) => item.type === "reasoning"), true); + assert.equal(result.input.some((item) => item.type === "function_call"), true); + assert.equal(result.input.some((item) => item.type === "function_call_output"), true); +}); + +test("CodexExecutor.transformRequest strips internal assistant commentary before mapping messages to input", () => { + const executor = new CodexExecutor(); + const result = executor.transformRequest( + "gpt-5.5-low", + { + _nativeCodexPassthrough: true, + messages: [ + { role: "user", content: "Continue." }, + { + role: "assistant", + phase: "commentary", + content: "Need maybe update PR body first.", + }, + { + role: "assistant", + phase: "final", + content: "Visible final assistant answer.", + }, + ], + stream: false, + }, + false, + { requestEndpointPath: "/responses" } + ); + + assert.equal( + result.input.some((item) => JSON.stringify(item).includes("Need maybe update PR body")), + false + ); + assert.equal( + result.input.some((item) => JSON.stringify(item).includes("Visible final assistant answer")), + true + ); + assert.equal(result.messages, undefined); +}); + test("CodexExecutor.transformRequest rehydrates missing function_call items for stateful tool outputs", () => { const executor = new CodexExecutor(); rememberResponseFunctionCalls("resp_prev_tool_123", [