diff --git a/open-sse/executors/chatgpt-session.ts b/open-sse/executors/chatgpt-session.ts index bad8d46500..f61599123e 100644 --- a/open-sse/executors/chatgpt-session.ts +++ b/open-sse/executors/chatgpt-session.ts @@ -224,11 +224,13 @@ export class ChatGptSessionExecutor extends BaseExecutor { messages ); + // No `rawBody` here on purpose: the adapter reads `_rawBody` as a native Codex Responses + // body, so handing it the OpenAI chat-completions body failed every turn before any browser + // work. `buildParsedRequest` synthesizes that envelope itself. const parsed = buildParsedRequest({ route, messages: effectiveMessages, stream: Boolean(input.stream) && !hasTools, - rawBody: input.body, }); const provider = buildChatGptSessionProviderConfig({ diff --git a/open-sse/executors/chatgpt-session/messages.ts b/open-sse/executors/chatgpt-session/messages.ts index a19c854c84..c8e5035281 100644 --- a/open-sse/executors/chatgpt-session/messages.ts +++ b/open-sse/executors/chatgpt-session/messages.ts @@ -5,8 +5,25 @@ * Tools are deliberately never placed in `context.tools`: this provider uses the shared * prompt-emulated tool contract (translator/webTools.ts), so the adapter must not try to * attach the turn-bound Codex connector capability. + * + * `_rawBody` is SYNTHESIZED here, never passed through from the OpenAI request. The vendored + * adapter reads `_rawBody` as a native Codex *Responses* body and refuses to run a turn without + * turn identity in it — an OpenAI chat-completions body makes every request fail with + * "ChatGPT web requires native Codex turn_id metadata for browser-session replay" before any + * browser work starts. Three things are load-bearing (see + * vendor/codex-chatgpt-web/adapters/chatgpt-web/environment.ts): + * 1. `client_metadata["x-codex-turn-metadata"]` carrying `thread_id` / `turn_id` + * (`clientTurnMetadata`), + * 2. `input` as an array of Responses items (`latestChatGptTurnUserRevision`, + * `chatGptTurnRoundKey`, the Luna rolling checkpoint), + * 3. `internal_chat_message_metadata_passthrough.turn_id` on the CURRENT user item + * (`itemTurnId`). + * This provider serves stateless chat completions, so a fresh thread/turn pair per request is + * the truthful identity: every request genuinely is its own turn. */ +import { randomUUID } from "node:crypto"; + import type { CodexMessage, CodexParsedRequest, @@ -29,6 +46,18 @@ interface OpenAiMessage { content: unknown; } +/** + * One Responses-shaped `input` item. Only the fields the vendored adapter actually reads are + * emitted: `type`/`role`/`content[].text` (`rawMessageText`, `inputContentParts`) and the + * current-turn marker (`itemTurnId`). + */ +interface ResponsesInputItem { + type: "message"; + role: "user" | "assistant"; + content: Array<{ type: "input_text" | "output_text"; text: string }>; + internal_chat_message_metadata_passthrough?: { turn_id: string }; +} + function textFromContent(content: unknown): string { if (typeof content === "string") return content; if (!Array.isArray(content)) return ""; @@ -76,11 +105,11 @@ export function buildParsedRequest(input: { route: ChatGptSessionRoute; messages: ReadonlyArray; stream: boolean; - rawBody?: unknown; }): CodexParsedRequest { const systemPrompt: string[] = []; const messages: CodexMessage[] = []; - let sawUser = false; + const inputItems: ResponsesInputItem[] = []; + let lastUserItem = -1; for (const message of input.messages) { const role = typeof message.role === "string" ? message.role : ""; @@ -93,6 +122,11 @@ export function buildParsedRequest(input: { const content = assistantParts(message.content); if (content.length > 0) { messages.push({ role: "assistant", content, timestamp: Date.now() }); + inputItems.push({ + type: "message", + role: "assistant", + content: content.map((part) => ({ type: "output_text", text: part.text })), + }); } continue; } @@ -100,17 +134,31 @@ export function buildParsedRequest(input: { const text = textFromContent(message.content); if (!text) continue; messages.push({ role: "user", content: text, timestamp: Date.now() }); - sawUser = true; + lastUserItem = inputItems.length; + inputItems.push({ + type: "message", + role: "user", + content: [{ type: "input_text", text }], + }); } } - if (!sawUser) { + if (lastUserItem < 0) { throw new ChatGptSessionInputError( "no_user_message", "ChatGPT Session requires at least one user message" ); } + const threadId = `thread_omniroute_${randomUUID()}`; + const turnId = `turn_omniroute_${randomUUID()}`; + // Only the LAST user item is the current turn. Marking an earlier one would make the adapter + // replay stale history as the live instruction. + inputItems[lastUserItem] = { + ...inputItems[lastUserItem]!, + internal_chat_message_metadata_passthrough: { turn_id: turnId }, + }; + return { modelId: input.route.backendModel, context: { @@ -119,6 +167,19 @@ export function buildParsedRequest(input: { }, stream: input.stream, options: { reasoning: input.route.effort }, - ...(input.rawBody !== undefined ? { _rawBody: input.rawBody } : {}), + _rawBody: { + model: input.route.backendModel, + // The adapter's Luna checkpoint path re-parses `_rawBody` through the vendored Responses + // parser, which reads system prompts from `instructions` — keep the two representations + // equivalent so a re-parse reproduces this same context. + ...(systemPrompt.length > 0 ? { instructions: systemPrompt.join("\n") } : {}), + input: inputItems, + // The real Codex client sends this metadata as a JSON string; the vendor accepts a plain + // object too, but matching the client's wire shape keeps us on the path the vendor's own + // tests and future tightening cover. + client_metadata: { + "x-codex-turn-metadata": JSON.stringify({ thread_id: threadId, turn_id: turnId }), + }, + }, }; } diff --git a/tests/unit/chatgpt-session-messages.test.ts b/tests/unit/chatgpt-session-messages.test.ts index 4805decc2e..f919eb1343 100644 --- a/tests/unit/chatgpt-session-messages.test.ts +++ b/tests/unit/chatgpt-session-messages.test.ts @@ -181,3 +181,132 @@ test("a refusal part whose refusal field is not a string is still rejected", () ); } }); + +/** + * The synthesized `_rawBody` envelope. Without it the vendored adapter refuses every turn + * ("ChatGPT web requires native Codex turn_id metadata for browser-session replay") before any + * browser work, so these assertions guard a live-fatal defect that adapter mocking cannot see. + */ +function rawBody(parsed: { _rawBody?: unknown }): Record { + const body = parsed._rawBody; + assert.ok(body && typeof body === "object" && !Array.isArray(body), "_rawBody must be an object"); + return body as Record; +} + +function turnMetadata(parsed: { _rawBody?: unknown }): Record { + const clientMetadata = rawBody(parsed).client_metadata; + assert.ok( + clientMetadata && typeof clientMetadata === "object" && !Array.isArray(clientMetadata), + "client_metadata must be an object" + ); + const raw = (clientMetadata as Record)["x-codex-turn-metadata"]; + assert.equal( + typeof raw, + "string", + "x-codex-turn-metadata must be the JSON string the client sends" + ); + const decoded: unknown = JSON.parse(raw as string); + assert.ok(decoded && typeof decoded === "object" && !Array.isArray(decoded)); + return decoded as Record; +} + +function inputItems(parsed: { _rawBody?: unknown }): Array> { + const input = rawBody(parsed).input; + assert.ok(Array.isArray(input), "_rawBody.input must be an array"); + return input.map((item) => { + assert.ok(item && typeof item === "object" && !Array.isArray(item)); + return item as Record; + }); +} + +function passthroughTurnId(item: Record): unknown { + const passthrough = item.internal_chat_message_metadata_passthrough; + if (passthrough === undefined) return undefined; + assert.ok(passthrough && typeof passthrough === "object" && !Array.isArray(passthrough)); + return (passthrough as Record).turn_id; +} + +test("the turn metadata is a JSON string carrying a thread id and a turn id", () => { + const parsed = buildParsedRequest({ + route, + messages: [{ role: "user", content: "Hi" }], + stream: true, + }); + const metadata = turnMetadata(parsed); + assert.equal(typeof metadata.thread_id, "string"); + assert.equal(typeof metadata.turn_id, "string"); + assert.match(String(metadata.thread_id), /^thread_omniroute_/); + assert.match(String(metadata.turn_id), /^turn_omniroute_/); +}); + +test("_rawBody.input mirrors the parsed messages, in order and in Responses item shape", () => { + const parsed = buildParsedRequest({ + route, + messages: [ + { role: "system", content: "Be terse." }, + { role: "user", content: "one" }, + { role: "assistant", content: "two" }, + { role: "user", content: "three" }, + ], + stream: true, + }); + const items = inputItems(parsed); + assert.equal(items.length, parsed.context.messages.length); + assert.deepEqual( + items.map((item) => item.role), + ["user", "assistant", "user"] + ); + assert.deepEqual( + items.map((item) => item.type), + ["message", "message", "message"] + ); + assert.deepEqual(items[0].content, [{ type: "input_text", text: "one" }]); + assert.deepEqual(items[1].content, [{ type: "output_text", text: "two" }]); + assert.deepEqual(items[2].content, [{ type: "input_text", text: "three" }]); + // System prompts stay out of `input`; the vendored parser reads them from `instructions`. + assert.equal(rawBody(parsed).instructions, "Be terse."); + assert.equal(rawBody(parsed).model, parsed.modelId); +}); + +test("only the last user item carries the current-turn passthrough id", () => { + const parsed = buildParsedRequest({ + route, + messages: [ + { role: "user", content: "one" }, + { role: "assistant", content: "two" }, + { role: "user", content: "three" }, + ], + stream: true, + }); + const items = inputItems(parsed); + const turnId = turnMetadata(parsed).turn_id; + assert.equal(passthroughTurnId(items[0]), undefined); + assert.equal(passthroughTurnId(items[1]), undefined); + assert.equal(passthroughTurnId(items[2]), turnId); +}); + +test("a trailing assistant turn leaves the marker on the last USER item", () => { + const parsed = buildParsedRequest({ + route, + messages: [ + { role: "user", content: "one" }, + { role: "assistant", content: "two" }, + ], + stream: true, + }); + const items = inputItems(parsed); + const turnId = turnMetadata(parsed).turn_id; + assert.equal(passthroughTurnId(items[0]), turnId); + assert.equal(passthroughTurnId(items[1]), undefined); +}); + +test("every request gets its own thread id and turn id", () => { + const first = turnMetadata( + buildParsedRequest({ route, messages: [{ role: "user", content: "Hi" }], stream: true }) + ); + const second = turnMetadata( + buildParsedRequest({ route, messages: [{ role: "user", content: "Hi" }], stream: true }) + ); + assert.notEqual(first.thread_id, second.thread_id); + assert.notEqual(first.turn_id, second.turn_id); +});