fix(sse): synthesize the native Codex turn envelope for chatgpt-session

The executor passed the OpenAI chat-completions body through as `_rawBody`, but the
vendored browser adapter reads `_rawBody` as a native Codex Responses body and demands
turn identity from it. Every request therefore failed with "ChatGPT web requires native
Codex turn_id metadata for browser-session replay" before any browser work started; no
unit test caught it because they all mock the adapter call.

`buildParsedRequest` now builds the envelope itself: a fresh thread/turn id pair per
request (this provider serves stateless chat completions, so each request genuinely is
its own turn), the turn metadata as the JSON string the real client sends, an `input`
array mirroring the parsed messages in Responses item shape, and the current-turn
passthrough marker on the last user item only. The `rawBody` parameter is gone so no
caller can reintroduce the passthrough.

Verified against the real adapter through the production code path: the turn now reaches
stage=browser_page and fails only on the missing login state.
This commit is contained in:
diegosouzapw
2026-09-02 18:32:31 -03:00
parent baf81d8343
commit e833555075
3 changed files with 198 additions and 6 deletions

View File

@@ -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({

View File

@@ -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<OpenAiMessage>;
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 }),
},
},
};
}

View File

@@ -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<string, unknown> {
const body = parsed._rawBody;
assert.ok(body && typeof body === "object" && !Array.isArray(body), "_rawBody must be an object");
return body as Record<string, unknown>;
}
function turnMetadata(parsed: { _rawBody?: unknown }): Record<string, unknown> {
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<string, unknown>)["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<string, unknown>;
}
function inputItems(parsed: { _rawBody?: unknown }): Array<Record<string, unknown>> {
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<string, unknown>;
});
}
function passthroughTurnId(item: Record<string, unknown>): 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<string, unknown>).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);
});