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 00afb8d211..4c48e8f396 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,13 @@ 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 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 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 6afe4057c4..72204ea4d2 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 17e28b2459..21579f7667 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -396,32 +396,93 @@ 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; +} + +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); - if (raw.input.slice(0, replayPrefixLen).some(item => isGeneratedDeveloperItem(item, text))) { + if (rawInput) { + const replayPrefix = rawInput.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() }); - 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); + 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 tool/protocol items. Keep those first, then place + // changed guidance before the current conversation. Stateless requests keep guidance in the prefix. + if (statefulContinuation) { + 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 (rawInput) { + if (statefulContinuation) { + rawInput.splice(statefulRawIndex!, 0, devItem); } else { - raw.input.push(devItem); + rawInput.splice(leadingDeveloperPrefixLength(rawInput), 0, devItem); } } } diff --git a/src/types.ts b/src/types.ts index dd4ba68fb6..33a6e3e6d4 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 8fa717dd03..fb31293ebd 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,137 @@ 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("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"; + 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 2eff46e7b6..9554ae616e 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);