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 ab690bf6a..e51600029 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,10 @@ 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 the leading +developer metadata and before conversational input. Stateful `previous_response_id` continuations +reuse an exact item from their trusted replay prefix instead of adding another copy. + 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/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 459da3973..404ee1367 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -378,11 +378,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 { @@ -390,20 +409,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 8e9740cb1..73b1f9bae 100644 --- a/tests/multi-agent-compat.test.ts +++ b/tests/multi-agent-compat.test.ts @@ -822,28 +822,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 = [ @@ -853,11 +860,81 @@ 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 stale or 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 cases = [ + ["stale", await multiAgentGuidanceText( + fixture, + { injectionModel: "anthropic/claude-sonnet-5" }, + { collectCatalogState: () => ({ state: "stale" }) }, + )], + ["fresh", await multiAgentGuidanceText(fixture, { + injectionModel: "anthropic/claude-sonnet-5", + })], + ] as const; + + expect(cases[0][1]).toContain("The model catalog changed"); + expect(cases[1][1]).toContain("Preferred sub-agent"); + for (const [label, text] of cases) { + 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!), label).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 changes remain latest across stale-fresh-stale transitions", () => { + const stale = "stale"; + const fresh = "fresh"; + const parsed = parsedFixture({ rawInput: [generatedItem(stale), { role: "user", content: "work" }] }); + parsed.previousResponseId = "resp_1"; + parsed._replayPrefixLen = 2; + injectDeveloperMessage(parsed, fresh); + + const replay = parsedFixture({ rawInput: [ + ...(parsed._rawBody as { input: unknown[] }).input, + { role: "assistant", content: "done" }, + ] }); + replay.previousResponseId = "resp_2"; + replay._replayPrefixLen = 4; + injectDeveloperMessage(replay, fresh); + expect((replay._rawBody as { input: unknown[] }).input).toHaveLength(4); + injectDeveloperMessage(replay, stale); + expect((replay._rawBody as { input: unknown[] }).input.at(-1)).toEqual(generatedItem(stale)); + }); + 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);