From 642e99af9439c71ec0b9f4e0ff4f34224020c815 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:18:44 +0200 Subject: [PATCH 1/3] fix(codex): keep collaboration guidance before conversation --- src/server/responses/collaboration.ts | 62 +++++++++++++---- tests/multi-agent-compat.test.ts | 99 +++++++++++++++++++++++---- tests/responses-state.test.ts | 2 +- 3 files changed, 136 insertions(+), 27 deletions(-) diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 17e28b245..78a659edb 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -396,11 +396,30 @@ function isRecord(value: unknown): value is Record { return !!value && typeof value === "object" && !Array.isArray(value); } -function isGeneratedDeveloperItem(item: unknown, text: string): boolean { - if (!isRecord(item) || item.type !== "message" || item.role !== "developer") return false; - if (!Array.isArray(item.content) || item.content.length !== 1) return false; +function generatedDeveloperText(item: unknown): string | undefined { + if (!isRecord(item) || item.type !== "message" || item.role !== "developer") return undefined; + if (!Array.isArray(item.content) || item.content.length !== 1) return undefined; const [part] = item.content; - return isRecord(part) && part.type === "input_text" && part.text === text; + return isRecord(part) && part.type === "input_text" && typeof part.text === "string" + ? part.text + : undefined; +} + +function isGeneratedDeveloperItem(item: unknown, text: string): boolean { + return generatedDeveloperText(item) === text; +} + +function isDeveloperPrefixItem(item: unknown): boolean { + if (!isRecord(item)) return false; + if (item.type === "additional_tools") return item.role === "developer"; + const type = item.type ?? (typeof item.role === "string" ? "message" : undefined); + return type === "message" && (item.role === "system" || item.role === "developer"); +} + +function leadingDeveloperPrefixLength(items: readonly unknown[]): number { + let index = 0; + while (index < items.length && isDeveloperPrefixItem(items[index])) index += 1; + return index; } export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): void { @@ -408,20 +427,39 @@ export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): const devItem = { type: "message", role: "developer", content: [{ type: "input_text", text }] }; if (raw && Array.isArray(raw.input)) { const replayPrefixLen = Math.min(parsed._replayPrefixLen ?? 0, raw.input.length); - if (raw.input.slice(0, replayPrefixLen).some(item => isGeneratedDeveloperItem(item, text))) { + const replayPrefix = raw.input.slice(0, replayPrefixLen); + const taggedGuidance = text.startsWith("") && text.endsWith(""); + const lastTaggedGuidance = taggedGuidance + ? replayPrefix.map(generatedDeveloperText) + .filter(item => item?.startsWith("") && item.endsWith("")) + .at(-1) + : undefined; + if (taggedGuidance ? lastTaggedGuidance === text : replayPrefix.some(item => isGeneratedDeveloperItem(item, text))) { return; } } - parsed.context.messages.push({ role: "developer", content: text, timestamp: Date.now() }); + const statefulContinuation = parsed.previousResponseId !== undefined; + const message = { role: "developer" as const, content: text, timestamp: Date.now() }; + + // A previous_response_id delta can begin with a tool result, and changed replayed guidance must + // remain earlier than its replacement. Stateless requests can keep guidance in the prefix. + if (statefulContinuation) { + parsed.context.messages.push(message); + } else { + const prefixLen = parsed.context.messages.findIndex(item => item.role !== "developer"); + parsed.context.messages.splice(prefixLen < 0 ? parsed.context.messages.length : prefixLen, 0, message); + } + if (raw && Array.isArray(raw.input)) { - // compaction_trigger must remain the final input item (codex-rs + ChatGPT backend both - // validate this). Insert the developer message BEFORE the trigger when present. - const last = raw.input[raw.input.length - 1]; - if (last && typeof last === "object" && (last as { type?: string }).type === "compaction_trigger") { - raw.input.splice(raw.input.length - 1, 0, devItem); + if (statefulContinuation) { + const last = raw.input[raw.input.length - 1]; + const index = isRecord(last) && last.type === "compaction_trigger" + ? raw.input.length - 1 + : raw.input.length; + raw.input.splice(index, 0, devItem); } else { - raw.input.push(devItem); + raw.input.splice(leadingDeveloperPrefixLength(raw.input), 0, devItem); } } } diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index 8fa717dd0..ab6631c1e 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -887,28 +887,35 @@ describe("injectDeveloperMessage", () => { && (part as Record).text === text; }).length; - test("appends to both the parsed messages and the raw passthrough input", () => { - const parsed = parsedFixture({ reasoning: "max" }); + test("inserts after leading developer metadata and before conversation", () => { + const parsed = parseRequest({ + model: "gpt-5.5", + input: [ + { type: "message", role: "system", content: [{ type: "input_text", text: "system" }] }, + { type: "message", role: "developer", content: [{ type: "input_text", text: "native mode" }] }, + { type: "additional_tools", role: "developer", tools: [] }, + { type: "message", role: "user", content: [{ type: "input_text", text: "work" }] }, + ], + }); injectDeveloperMessage(parsed, "hello there"); - const last = parsed.context.messages.at(-1)!; - expect(last.role).toBe("developer"); - expect(last.content).toBe("hello there"); + + expect(parsed.context.systemPrompt).toEqual(["system"]); + expect(parsed.context.messages.map(message => message.role)).toEqual(["developer", "developer", "user"]); + expect(parsed.context.messages[1]!.content).toBe("hello there"); const rawInput = (parsed._rawBody as { input: unknown[] }).input; - expect(rawInput.at(-1)).toEqual({ - type: "message", - role: "developer", - content: [{ type: "input_text", text: "hello there" }], - }); + expect(rawInput[2]).toMatchObject({ type: "additional_tools", role: "developer" }); + expect(rawInput[3]).toEqual(generatedItem("hello there")); + expect(rawInput[4]).toMatchObject({ type: "message", role: "user" }); }); test("string raw input is left alone", () => { const parsed = parsedFixture({ reasoning: "max", rawInput: "plain" }); injectDeveloperMessage(parsed, "note"); expect((parsed._rawBody as { input: unknown }).input).toBe("plain"); - expect(parsed.context.messages.at(-1)!.content).toBe("note"); + expect(parsed.context.messages[0]!.content).toBe("note"); }); - test("inserts BEFORE compaction_trigger so it stays the final input item", () => { + test("inserts before conversation while compaction_trigger stays final", () => { const parsed = parsedFixture({ reasoning: "max" }); const rawBody = parsed._rawBody as { input: unknown[] }; rawBody.input = [ @@ -918,11 +925,75 @@ describe("injectDeveloperMessage", () => { injectDeveloperMessage(parsed, "guidance text"); const input = rawBody.input; expect(input).toHaveLength(3); - expect((input[1] as { type: string }).type).toBe("message"); - expect((input[1] as { role: string }).role).toBe("developer"); + expect((input[0] as { type: string }).type).toBe("message"); + expect((input[0] as { role: string }).role).toBe("developer"); + expect((input[1] as { role: string }).role).toBe("user"); expect((input[2] as { type: string }).type).toBe("compaction_trigger"); }); + test("consecutive stateless requests keep one fresh guidance item before conversation", async () => { + const dir = codexHomeFixture(V2_ON); + catalogFixture(dir, [{ + slug: "anthropic/claude-sonnet-5", + efforts: ["low", "medium", "high", "xhigh"], + multiAgentVersion: "v2", + }]); + const fixture = parsedFixture({ reasoning: "medium" }); + const text = await multiAgentGuidanceText( + fixture, + { + injectionModel: "anthropic/claude-sonnet-5", + }, + { collectCatalogState: () => ({ state: "fresh" }) }, + ); + + expect(text).toContain("Preferred sub-agent"); + for (const content of ["first", "second"]) { + const parsed = parsedFixture({ + reasoning: "medium", + rawInput: [{ type: "message", role: "user", content }], + }); + injectDeveloperMessage(parsed, text!); + const rawInput = (parsed._rawBody as { input: unknown[] }).input; + expect(countExact(rawInput, text!)).toBe(1); + expect(rawInput).toEqual([generatedItem(text!), { type: "message", role: "user", content }]); + } + }); + + test("keeps an unexpanded previous_response_id tool delta first", () => { + const parsed = parsedFixture({ + reasoning: "max", + rawInput: [{ type: "function_call_output", call_id: "call_1", output: "ok" }], + }); + parsed.previousResponseId = "resp_remote"; + injectDeveloperMessage(parsed, guidance); + + const rawInput = (parsed._rawBody as { input: unknown[] }).input; + expect(rawInput[0]).toMatchObject({ type: "function_call_output", call_id: "call_1" }); + expect(rawInput[1]).toEqual(generatedItem()); + expect(parsed.context.messages.at(-1)).toMatchObject({ role: "developer", content: guidance }); + }); + + test("stateful guidance dedup uses the latest tagged item across A-B-A transitions", () => { + const guidanceA = "A"; + const guidanceB = "B"; + const parsed = parsedFixture({ rawInput: [generatedItem(guidanceA), { role: "user", content: "work" }] }); + parsed.previousResponseId = "resp_1"; + parsed._replayPrefixLen = 2; + injectDeveloperMessage(parsed, guidanceB); + + const replay = parsedFixture({ rawInput: [ + ...(parsed._rawBody as { input: unknown[] }).input, + { role: "assistant", content: "done" }, + ] }); + replay.previousResponseId = "resp_2"; + replay._replayPrefixLen = 4; + injectDeveloperMessage(replay, guidanceB); + expect((replay._rawBody as { input: unknown[] }).input).toHaveLength(4); + injectDeveloperMessage(replay, guidanceA); + expect((replay._rawBody as { input: unknown[] }).input.at(-1)).toEqual(generatedItem(guidanceA)); + }); + test("exact-guidance predicate rejects every near-match replay-prefix shape (#326)", () => { const nearMatches: Array<[string, unknown]> = [ ["non-record item", null], diff --git a/tests/responses-state.test.ts b/tests/responses-state.test.ts index 2eff46e7b..9554ae616 100644 --- a/tests/responses-state.test.ts +++ b/tests/responses-state.test.ts @@ -423,7 +423,7 @@ describe("Responses previous_response_id state", () => { const parsed2 = parseRequest(request2); expect(parsed2._replayPrefixLen).toBe(3); const request2Input = (request2 as { input: Array> }).input; - expect(request2Input[1]).toMatchObject({ role: "developer" }); + expect(request2Input[0]).toMatchObject({ role: "developer" }); expect(request2Input[2]).toMatchObject({ type: "function_call" }); injectDeveloperMessage(parsed2, guidance); expect(countRawGuidance(request2)).toBe(1); From 7932d0443db336406e7b0b3425663e363beab3c6 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:19:01 +0200 Subject: [PATCH 2/3] fix(codex): order changed stateful guidance --- .../content/docs/guides/sub-agent-surface.md | 6 ++ src/responses/parser.ts | 22 +++++++ src/server/responses/collaboration.ts | 49 +++++++++++---- src/types.ts | 2 + tests/multi-agent-compat.test.ts | 62 +++++++++++++++++++ 5 files changed, 128 insertions(+), 13 deletions(-) diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index 00afb8d21..ff544aec5 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -51,6 +51,12 @@ The dashboard's **Sub-agent delegation** controls three related settings: `multiAgentGuidanceEnabled` defaults to on and is the master switch for opencodex-authored guidance on both surfaces. Turning it off suppresses both the v2 designation block and v1 proactive text. +For array-form stateless Responses requests, opencodex places generated guidance after leading +system and developer metadata, including developer `additional_tools`, and before conversational +input. Stateful `previous_response_id` continuations reuse guidance only when it matches the latest +tagged item in their trusted replay prefix. When guidance changes, leading tool protocol stays first and +the replacement is inserted before current conversational input. + These are instructions to the main agent, not a proxy-side spawn router. On v2, a full-history fork inherits the parent model and rejects model or effort overrides. Guidance therefore tells Codex to use `fork_turns: "none"` (or a positive partial turn count such as `"3"`) when passing `model` or diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 6afe4057c..72204ea4d 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -324,17 +324,33 @@ export function parseRequest(body: unknown): OcxParsedRequest { // synthetic `{type:"compaction"}` output item (src/responses/compaction.ts). Flagged for the server. let compactionRequest = false; let contextCompactionBoundary = false; + let continuationConversationMessageIndex: number | undefined; if (typeof data.instructions === "string" && data.instructions.length > 0) { systemPrompt.push(data.instructions); } if (typeof data.input === "string") { + if (data.previous_response_id) continuationConversationMessageIndex = messages.length; messages.push({ role: "user", content: data.input, timestamp: now }); } else if (data.input) { for (let inputIndex = 0; inputIndex < data.input.length; inputIndex++) { const item = data.input[inputIndex]; const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined); + const itemRole = (item as { role?: string }).role; + // Raw protocol items do not map one-to-one onto context messages. Capture the boundary while + // both representations are available so later metadata can stay before conversation in both. + if ( + data.previous_response_id + && inputIndex >= replayedInputPrefixLength + && continuationConversationMessageIndex === undefined + && ( + effectiveType === "agent_message" + || (effectiveType === "message" && (itemRole === "user" || itemRole === "assistant")) + ) + ) { + continuationConversationMessageIndex = messages.length; + } if (effectiveType === "compaction_trigger") { compactionRequest = true; @@ -614,6 +630,9 @@ export function parseRequest(body: unknown): OcxParsedRequest { } } } + if (data.previous_response_id && continuationConversationMessageIndex === undefined) { + continuationConversationMessageIndex = messages.length; + } const declaredTools = buildTools(data.tools as unknown[] | undefined) ?? []; const loadedTools = buildTools(loadedToolSpecs) ?? []; @@ -683,6 +702,9 @@ export function parseRequest(body: unknown): OcxParsedRequest { options, _rawBody: body, ...(replayedInputPrefixLength > 0 ? { _replayPrefixLen: replayedInputPrefixLength } : {}), + ...(continuationConversationMessageIndex !== undefined + ? { _continuationConversationMessageIndex: continuationConversationMessageIndex } + : {}), ...(webSearch ? { _webSearch: webSearch } : {}), ...(imageGen ? { _imageGeneration: imageGen } : {}), ...(textFormat ? { _structuredOutput: true } : {}), diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 78a659edb..21579f766 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -422,12 +422,32 @@ function leadingDeveloperPrefixLength(items: readonly unknown[]): number { return index; } +function isConversationalItem(item: unknown): boolean { + if (!isRecord(item)) return false; + if (item.type === "agent_message") return true; + const type = item.type ?? (typeof item.role === "string" ? "message" : undefined); + return type === "message" && (item.role === "user" || item.role === "assistant"); +} + +function statefulRawInsertionIndex(items: readonly unknown[], replayPrefixLen: number): number { + for (let index = replayPrefixLen; index < items.length; index += 1) { + if (isConversationalItem(items[index])) return index; + } + const last = items[items.length - 1]; + return isRecord(last) && last.type === "compaction_trigger" + ? items.length - 1 + : items.length; +} + export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): void { const raw = parsed._rawBody as { input?: unknown } | undefined; + const rawInput = raw && Array.isArray(raw.input) ? raw.input : undefined; + const replayPrefixLen = rawInput + ? Math.min(parsed._replayPrefixLen ?? 0, rawInput.length) + : 0; const devItem = { type: "message", role: "developer", content: [{ type: "input_text", text }] }; - if (raw && Array.isArray(raw.input)) { - const replayPrefixLen = Math.min(parsed._replayPrefixLen ?? 0, raw.input.length); - const replayPrefix = raw.input.slice(0, replayPrefixLen); + if (rawInput) { + const replayPrefix = rawInput.slice(0, replayPrefixLen); const taggedGuidance = text.startsWith("") && text.endsWith(""); const lastTaggedGuidance = taggedGuidance ? replayPrefix.map(generatedDeveloperText) @@ -441,25 +461,28 @@ export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): const statefulContinuation = parsed.previousResponseId !== undefined; const message = { role: "developer" as const, content: text, timestamp: Date.now() }; + const statefulRawIndex = statefulContinuation && rawInput + ? statefulRawInsertionIndex(rawInput, replayPrefixLen) + : undefined; - // A previous_response_id delta can begin with a tool result, and changed replayed guidance must - // remain earlier than its replacement. Stateless requests can keep guidance in the prefix. + // A previous_response_id delta can begin with tool/protocol items. Keep those first, then place + // changed guidance before the current conversation. Stateless requests keep guidance in the prefix. if (statefulContinuation) { - parsed.context.messages.push(message); + const index = Math.min( + parsed._continuationConversationMessageIndex ?? parsed.context.messages.length, + parsed.context.messages.length, + ); + parsed.context.messages.splice(index, 0, message); } else { const prefixLen = parsed.context.messages.findIndex(item => item.role !== "developer"); parsed.context.messages.splice(prefixLen < 0 ? parsed.context.messages.length : prefixLen, 0, message); } - if (raw && Array.isArray(raw.input)) { + if (rawInput) { if (statefulContinuation) { - const last = raw.input[raw.input.length - 1]; - const index = isRecord(last) && last.type === "compaction_trigger" - ? raw.input.length - 1 - : raw.input.length; - raw.input.splice(index, 0, devItem); + rawInput.splice(statefulRawIndex!, 0, devItem); } else { - raw.input.splice(leadingDeveloperPrefixLength(raw.input), 0, devItem); + rawInput.splice(leadingDeveloperPrefixLength(rawInput), 0, devItem); } } } diff --git a/src/types.ts b/src/types.ts index dd4ba68fb..33a6e3e6d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,8 @@ export interface OcxParsedRequest { _rawBody?: unknown; /** Number of leading raw input items restored from local previous_response_id state. */ _replayPrefixLen?: number; + /** Parsed-message index before the first conversational item in a continuation's current delta. */ + _continuationConversationMessageIndex?: number; /** True when the proxy expanded a previous_response_id request into a full input replay. */ _previousResponseInputExpanded?: boolean; /** Provider-private stable Cursor conversation id resolved from the Responses previous_response_id chain. */ diff --git a/tests/multi-agent-compat.test.ts b/tests/multi-agent-compat.test.ts index ab6631c1e..fb31293eb 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -974,6 +974,68 @@ describe("injectDeveloperMessage", () => { expect(parsed.context.messages.at(-1)).toMatchObject({ role: "developer", content: guidance }); }); + test("inserts changed stateful guidance before an ordinary new user delta", () => { + const guidanceA = "A"; + const guidanceB = "B"; + const rawInput = [ + generatedItem(guidanceA), + { type: "message", role: "user", content: "previous turn" }, + { type: "message", role: "assistant", content: "done" }, + { type: "message", role: "user", content: "current turn" }, + ]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput }); + parsed.previousResponseId = "resp_1"; + parsed._replayPrefixLen = 3; + parsed._continuationConversationMessageIndex = 3; + + injectDeveloperMessage(parsed, guidanceB); + + expect(rawInput).toEqual([ + generatedItem(guidanceA), + { type: "message", role: "user", content: "previous turn" }, + { type: "message", role: "assistant", content: "done" }, + generatedItem(guidanceB), + { type: "message", role: "user", content: "current turn" }, + ]); + expect(parsed.context.messages.map(message => message.role)).toEqual([ + "developer", + "user", + "assistant", + "developer", + "user", + ]); + }); + + test("keeps leading stateful protocol items before changed guidance and conversation", () => { + const rawInput = [ + { type: "function_call_output", call_id: "call_1", output: "ok" }, + { type: "message", role: "user", content: "current turn" }, + ]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput, previous_response_id: "resp_remote" }); + + injectDeveloperMessage(parsed, guidance); + + expect(rawInput).toEqual([ + { type: "function_call_output", call_id: "call_1", output: "ok" }, + generatedItem(), + { type: "message", role: "user", content: "current turn" }, + ]); + expect(parsed.context.messages.map(message => message.role)).toEqual(["toolResult", "developer", "user"]); + }); + + test("keeps raw and parsed stateful placement aligned across reconstructed compaction history", () => { + const rawInput = [ + { type: "message", role: "user", content: "current turn" }, + { type: "compaction", encrypted_content: "ocx1:c3VtbWFyeQ==" }, + ]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput, previous_response_id: "resp_remote" }); + + injectDeveloperMessage(parsed, guidance); + + expect(rawInput[0]).toEqual(generatedItem()); + expect(parsed.context.messages.map(message => message.role)).toEqual(["developer", "user", "user"]); + }); + test("stateful guidance dedup uses the latest tagged item across A-B-A transitions", () => { const guidanceA = "A"; const guidanceB = "B"; From 7912cff93cd21f1d40c97cd3cefd78f404b4d3c7 Mon Sep 17 00:00:00 2001 From: Wibias <37517432+Wibias@users.noreply.github.com> Date: Tue, 11 Aug 2026 20:34:42 +0200 Subject: [PATCH 3/3] Update docs-site/src/content/docs/guides/sub-agent-surface.md Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- docs-site/src/content/docs/guides/sub-agent-surface.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs-site/src/content/docs/guides/sub-agent-surface.md b/docs-site/src/content/docs/guides/sub-agent-surface.md index ff544aec5..4c48e8f39 100644 --- a/docs-site/src/content/docs/guides/sub-agent-surface.md +++ b/docs-site/src/content/docs/guides/sub-agent-surface.md @@ -53,8 +53,9 @@ on both surfaces. Turning it off suppresses both the v2 designation block and v1 For array-form stateless Responses requests, opencodex places generated guidance after leading system and developer metadata, including developer `additional_tools`, and before conversational -input. Stateful `previous_response_id` continuations reuse guidance only when it matches the latest -tagged item in their trusted replay prefix. When guidance changes, leading tool protocol stays first and +input. Stateful `previous_response_id` continuations reuse tagged guidance only when it matches the latest +tagged item in their trusted replay prefix. Other generated guidance is reused when an exact generated +developer item exists in that prefix. When guidance changes, leading tool protocol stays first and the replacement is inserted before current conversational input. These are instructions to the main agent, not a proxy-side spawn router. On v2, a full-history fork