From 9d74c7a8fb0608171fb368e1fef0f5e40bba0916 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 11:19:35 +0900 Subject: [PATCH 1/6] fix(xai): lower plaintext string child-result messages Refs #3907. Enable exact string preservation through the existing agent-message normalizer only for non-forward xAI destinations. Keep array conversion and mixed-ciphertext fail-closed behavior unchanged. Add adapter destination controls and mocked parent/child/result continuation coverage for SSE and JSON. This is an isolated WP2 preparation candidate; local product tests, typecheck, build, install and runtime probes were NOT RUN. Main owns phase adoption, independent audit and hosted CI. (cherry picked from commit 339e42c1e388db1ca01cda9d960368ad9335d48c) --- .../src/content/docs/reference/adapters.md | 9 +- .../docs/reference/configuration/providers.md | 9 +- .../src/content/docs/ru/reference/adapters.md | 14 ++- src/adapters/openai-responses.ts | 5 +- src/adapters/routed-agent-messages.ts | 17 +++- structure/04_transports-and-sidecars.md | 16 ++++ tests/adapters/routed-agent-messages.test.ts | 88 ++++++++++++++++++ .../server-xai-responses-streaming.test.ts | 93 +++++++++++++++++++ 8 files changed, 239 insertions(+), 12 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index bc3bab2c82..596fc2255f 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -133,14 +133,17 @@ collision-safe public function tool. Matching request history and JSON/SSE funct translated back to the private `tool_search` lifecycle for the client. Canonical OpenAI forward keeps the native private type unchanged. -Requests with `authMode` other than `"forward"` convert plaintext Codex `agent_message` -items into public user messages, preserving content parts and readable author/recipient +Requests with `authMode` other than `"forward"` convert Codex `agent_message` +items containing nonempty arrays of supported plaintext parts into public user messages, preserving those parts and readable author/recipient metadata. `agent_message` is private to the ChatGPT Codex backend, and the routed destinations reported so far reject the entire body with `422 unknown item type "agent_message"` — and because Codex replays sub-agent history on every turn, that failure repeats for the rest of the thread. This conversion leaves encrypted or unknown content unchanged. Providers using `authMode: "forward"` retain -these items unchanged. +these items unchanged. For xAI Responses on HTTPS `api.x.ai` or `cli-chat-proxy.grok.com` +using the standard port, a nonblank string child result is also converted into an `input_text` +part with its exact whitespace and newlines. Other destinations retain string-valued items; +blank strings and mixed encrypted/unknown parts are not partially converted. See [agent messages](/reference/configuration/providers/#routed-agent-messages) for the separate opt-in encrypted-task recovery behavior. diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 632adf16c9..f32a2fdc09 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -968,14 +968,19 @@ their previous behavior. See the ## Routed agent messages -With the [`openai-responses` adapter](/reference/adapters/#openai-responses), plaintext -Codex `agent_message` items become user messages when `authMode` is not `"forward"` +With the [`openai-responses` adapter](/reference/adapters/#openai-responses), Codex +`agent_message` items containing nonempty arrays of supported plaintext parts become user messages when `authMode` is not `"forward"` (for example, `"key"`). Providers using `authMode: "forward"` retain these items unchanged. `agent_message` is private to the ChatGPT Codex backend, and the routed destinations reported so far answer the whole request with `422 unknown item type "agent_message"`; Codex replays sub-agent history on every subsequent turn, so the thread keeps failing until the item is converted. Author and recipient remain explicit text metadata, and the content parts are preserved. +For HTTPS `api.x.ai` and `cli-chat-proxy.grok.com` on the standard port, non-forward +Responses dispatch also accepts a nonblank string child result and turns it into one +`input_text` part. The original string, including leading/trailing whitespace and newlines, +is preserved. Other destinations keep string-valued agent messages unchanged. Empty or +whitespace-only strings remain unchanged, as do incomplete and mixed encrypted/unknown shapes. Encrypted and unknown content is not normalized; native encrypted tasks still require the separate opt-in [task recovery](/reference/configuration/agents/#encrypted-v2-task-recovery). diff --git a/docs-site/src/content/docs/ru/reference/adapters.md b/docs-site/src/content/docs/ru/reference/adapters.md index 548192f357..0ec59367b7 100644 --- a/docs-site/src/content/docs/ru/reference/adapters.md +++ b/docs-site/src/content/docs/ru/reference/adapters.md @@ -94,10 +94,20 @@ interface ProviderAdapter { ## `openai-responses` -**Назначение:** OpenAI **Responses API**. **`passthrough: true`** — пересылает исходное тело -запроса и стримит ответ обратно **без преобразования**. +**Назначение:** OpenAI **Responses API**. **`passthrough: true`** — пересылает тело +запроса и ответ с преобразованиями совместимости для выбранного провайдера. **Аутентификация:** `forward` (ретрансляция заголовков вызывающей стороны) или `key`. +При `authMode`, отличном от `"forward"`, элементы Codex `agent_message` с непустым +массивом поддерживаемых открытых частей преобразуются в обычные сообщения пользователя. +Содержимое и читаемые поля author/recipient сохраняются. Для HTTPS `api.x.ai` и +`cli-chat-proxy.grok.com` на стандартном порту также поддерживается непустой строковый +результат дочерней задачи: он становится частью `input_text` без удаления пробелов и +переносов строк. Другие адреса сохраняют строковые элементы без изменений. Пустые строки, +зашифрованное содержимое и смешанные массивы с неизвестными или зашифрованными частями +не преобразуются частично. При `authMode: "forward"` элементы `agent_message` остаются +без изменений. + При `key`-аутентификации [`retryOn429`](/ru/reference/configuration/) действует и здесь: 429 до начала потока ждёт и, до любой другой обработки или фейловера, повторяет идентичный запрос на том же ключе, как и в переводимом пути `openai-chat`/Anthropic. Пользовательские транспорты diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index ebfc25bfc0..2900fd58b2 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,5 +1,6 @@ import { normalizeRoutedAgentMessages } from "./routed-agent-messages"; import { normalizeOpenCodeGoAdditionalTools } from "./opencode-go-additional-tools"; +import { isXaiResponsesDestination } from "../providers/xai-transport"; import { createHash } from "node:crypto"; import type { IncomingMeta, ProviderAdapter } from "./base"; import { namespacedToolName, type AdapterEvent, type OcxParsedRequest, type OcxProviderConfig, type OcxUsage, type TierDecision } from "../types"; @@ -2366,7 +2367,9 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed._rawBody, forward || parsed._previousResponseInputExpanded === true, ); - if (!forward) outBody = normalizeRoutedAgentMessages(outBody); + if (!forward) outBody = normalizeRoutedAgentMessages(outBody, { + allowStringContent: isXaiResponsesDestination(provider), + }); outBody = mapRoutedResponsesReasoningEffort(outBody, provider, parsed.modelId); // stripPreviousResponseId() intentionally returns its input on a no-op. Detach before the // tier write so a force-fast/default decision can never mutate parsed._rawBody. diff --git a/src/adapters/routed-agent-messages.ts b/src/adapters/routed-agent-messages.ts index 7e4b8ecf7b..2ca67162a7 100644 --- a/src/adapters/routed-agent-messages.ts +++ b/src/adapters/routed-agent-messages.ts @@ -9,7 +9,10 @@ * encrypted v2 task surface owns those, through `unreadable_encrypted_agent_task` and the * opt-in recovery route. Providers using `authMode: "forward"` never reach this function. */ -export function normalizeRoutedAgentMessages(body: unknown): unknown { +export function normalizeRoutedAgentMessages( + body: unknown, + { allowStringContent = false }: { allowStringContent?: boolean } = {}, +): unknown { if (!body || typeof body !== "object" || Array.isArray(body)) return body; const record = body as Record; if (!Array.isArray(record.input)) return body; @@ -17,9 +20,15 @@ export function normalizeRoutedAgentMessages(body: unknown): unknown { const input = record.input.map((item: unknown) => { if (!item || typeof item !== "object" || Array.isArray(item)) return item; const message = item as Record; - if (message.type !== "agent_message" || !Array.isArray(message.content) || message.content.length === 0) return item; + if (message.type !== "agent_message") return item; + // xAI rejects the private item even when a complete child result is a plain string. + // Trimming decides emptiness only; the original result bytes remain caller-owned. + const content = allowStringContent && typeof message.content === "string" && message.content.trim().length > 0 + ? [{ type: "input_text", text: message.content }] + : message.content; + if (!Array.isArray(content) || content.length === 0) return item; // Genuine ciphertext and unknown part types must retain their existing fail-closed path. - if (!message.content.every(part => part && typeof part === "object" + if (!content.every(part => part && typeof part === "object" && ["input_text", "input_image", "input_file"].includes(part.type))) return item; const identities = Object.fromEntries(["author", "recipient"] .filter(key => typeof message[key] === "string") @@ -29,7 +38,7 @@ export function normalizeRoutedAgentMessages(body: unknown): unknown { type: "message", role: "user", content: [ ...(Object.keys(identities).length ? [{ type: "input_text", text: `Agent message ${JSON.stringify(identities)}` }] : []), - ...message.content, + ...content, ], }; }); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index b1b70c3295..c98c837cec 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -373,6 +373,22 @@ have no exec-result seam today and are not annotated. - 다른 대안 대신 이 방식을 선택한 이유: One-field stripping exposes the next schema mismatch and turning `external_web_access:false` into xAI live search widens the caller's network policy; destination scoping leaves custom gateways and canonical OpenAI byte-shape native. - 장점, 단점 및 영향: Grok 4.5/4.6 no longer fail every default Codex turn with an unsupported-argument 400; live search remains available when explicitly enabled, while cached search degrades to no hosted search on xAI rather than silently going live. +### xAI string agent-message continuation + +`normalizeRoutedAgentMessages` owns raw Responses `agent_message` lowering. Its existing +nonempty all-readable array behavior remains shared by non-forward destinations. The optional +`allowStringContent` argument defaults to false and is enabled only by the non-forward adapter +call when `isXaiResponsesDestination` recognizes HTTPS `api.x.ai` or `cli-chat-proxy.grok.com` +on the standard port. A nonblank string becomes one `input_text` part with the original text; +the same author/recipient attribution is retained and the private transport item id is removed. + +This addresses readable child-result delivery (#3907), not scheduling or decryption. Blank, +malformed, ciphertext-only and mixed unknown/encrypted content retains the existing fail-closed +path. Forward destinations never enable the option. The parser and encrypted-task recovery +owners are unchanged, and no broad content-schema validation or adapter-wide string conversion +is introduced. Mocked server fixtures cover parent, child, and parent-result continuation over +SSE and JSON while preserving actual tool-call/result pairs. + OpenCode Go documents `gpt-5.6-luna` on `/zen/go/v1/responses` while sibling models use its Chat or Anthropic endpoints. The built-in preset therefore selects `openai-responses` only for Luna and keeps the provider-wide `openai-chat` default for other non-pinned models. This endpoint correction diff --git a/tests/adapters/routed-agent-messages.test.ts b/tests/adapters/routed-agent-messages.test.ts index cbbb7151d2..2a44d11862 100644 --- a/tests/adapters/routed-agent-messages.test.ts +++ b/tests/adapters/routed-agent-messages.test.ts @@ -30,6 +30,94 @@ test("ciphertext and unknown content are never reclassified as plaintext", () => } }); +test("string agent messages require an explicit opt-in and preserve exact text", () => { + const text = " Child result\nwith a trailing line.\n "; + const message = Object.freeze({ type: "agent_message", id: "amsg_string", content: text }); + const raw = Object.freeze({ input: Object.freeze([message]) }); + expect(normalizeRoutedAgentMessages(raw)).toBe(raw); + expect(normalizeRoutedAgentMessages(raw, { allowStringContent: false })).toBe(raw); + expect(normalizeRoutedAgentMessages(raw, { allowStringContent: true })).toEqual({ input: [{ + type: "message", role: "user", content: [{ type: "input_text", text }], + }] }); + expect(raw.input[0]).toBe(message); + expect(message.content).toBe(text); +}); + +for (const baseUrl of ["https://api.x.ai/v1", "https://cli-chat-proxy.grok.com/v1"]) { + test.each(["key", "oauth"] as const)(`${baseUrl} lowers string child results with %s auth`, async authMode => { + const raw = { model: "grok-4.6", stream: true, input: [{ + type: "agent_message", id: "amsg_string", author: "/root/worker", recipient: "/root", + content: " Complete child result\nSecond line.\n ", + }] }; + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl, authMode }).buildRequest(parsed, { + headers: new Headers(), translatorBudget: budget, + }); + const sent = JSON.parse(request.body as string); + expect(sent.input).toEqual([{ + type: "message", role: "user", content: [ + { type: "input_text", text: 'Agent message {"author":"/root/worker","recipient":"/root"}' }, + { type: "input_text", text: original.input[0]!.content }, + ], + }]); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } + }); +} + +test.each([ + { baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }, + { baseUrl: "https://api.x.ai/v1", authMode: "forward" as const }, + { baseUrl: "https://cli-chat-proxy.grok.com/v1", authMode: "forward" as const }, + { baseUrl: "https://custom.test/v1", authMode: "forward" as const }, + { baseUrl: "https://opencode.ai/zen/go/v1", authMode: "key" as const }, + { baseUrl: "https://example.test/v1", authMode: "key" as const }, + { baseUrl: "https://api.x.ai.evil.test/v1", authMode: "key" as const }, + { baseUrl: "https://cli-chat-proxy.grok.com.evil.test/v1", authMode: "key" as const }, + { baseUrl: "http://api.x.ai/v1", authMode: "key" as const }, + { baseUrl: "https://api.x.ai:444/v1", authMode: "key" as const }, +])("preserves string messages for $authMode at $baseUrl", async destination => { + const raw = { model: "grok-4.6", input: [{ type: "agent_message", content: "Child result" }] }; + const original = structuredClone(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, ...destination }).buildRequest(parseRequest(raw), { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input).toEqual(original.input); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + +test.each([ + "", " \n\t", null, 42, { text: "not a content string" }, [], + [{ type: "encrypted_content", encrypted_content: "opaque" }], + [{ type: "input_text", text: "Routing header" }, { type: "encrypted_content", encrypted_content: "opaque" }], + [{ type: "input_text", text: "Known prefix" }, { type: "future_type", text: "Unknown suffix" }], +].map(content => ({ content })))("xAI string opt-in leaves incomplete or unreadable content unchanged: %j", async ({ content }) => { + const raw = { model: "grok-4.6", input: [{ type: "agent_message", content }] }; + const original = structuredClone(raw); + expect(normalizeRoutedAgentMessages(raw, { allowStringContent: true })).toBe(raw); + const budget = createTranslatorBudget(); + try { + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://api.x.ai/v1" }).buildRequest(parseRequest(raw), { + headers: new Headers(), translatorBudget: budget, + }); + expect(JSON.parse(request.body as string).input).toEqual(original.input); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + test("image parts stay intact beside the assignment", () => { const image = { type: "input_image", image_url: "data:image/png;base64,AAAA", detail: "high" }; const raw = { input: [{ type: "agent_message", content: [{ type: "input_text", text: "Inspect image" }, image] }] }; diff --git a/tests/server/server-xai-responses-streaming.test.ts b/tests/server/server-xai-responses-streaming.test.ts index ef0f1e5313..316ee56b91 100644 --- a/tests/server/server-xai-responses-streaming.test.ts +++ b/tests/server/server-xai-responses-streaming.test.ts @@ -72,6 +72,99 @@ function sse(payload: unknown): Uint8Array { } describe("xAI OAuth Responses streaming opt-in", () => { + test.each([true, false])("continues a routed parent after a string child result (stream=%s)", async stream => { + const captured: Array> = []; + let privateItemRejections = 0; + const childText = " Synthetic worker result\nAll requested observations returned.\n "; + const call = { type: "function_call", id: "fc_parent_probe", status: "completed", + call_id: "call_parent_probe", name: "probe", arguments: "{}", + }; + globalThis.fetch = (async (input, init) => { + const url = input instanceof Request ? input.url : String(input); + // The fixture never falls through to a real OAuth or inference endpoint. + if (url !== RESPONSES_ENDPOINT) throw new Error(`Unexpected fixture destination: ${url}`); + const body = JSON.parse(String(init?.body)) as Record; + captured.push(body); + const items = body.input as Array<{ type?: string }>; + if (items.some(item => item.type === "agent_message")) { + privateItemRejections += 1; + return Response.json({ error: 'unknown item type "agent_message"' }, { status: 422 }); + } + const output = captured.length === 1 ? [call] : [{ + type: "message", id: `msg_child_result_${captured.length}`, status: "completed", role: "assistant", + content: [{ type: "output_text", text: captured.length === 2 ? childText : "Parent continued", annotations: [] }], + }]; + const response = { id: `resp_child_result_${captured.length}`, object: "response", status: "completed", + model: "grok-4.6", output, + }; + if (!stream) return Response.json(response); + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(sse({ type: "response.created", sequence_number: 0, + response: { ...response, status: "in_progress", output: [] }, + })); + controller.enqueue(sse({ type: "response.output_item.added", sequence_number: 1, output_index: 0, item: output[0] })); + controller.enqueue(sse({ type: "response.output_item.done", sequence_number: 2, output_index: 0, item: output[0] })); + controller.enqueue(sse({ type: "response.completed", sequence_number: 3, response })); + controller.close(); + }, + }), { headers: { "content-type": "text/event-stream" } }); + }) as typeof fetch; + + saveConfig({ ...config(), multiAgentMode: "v2" }); + const server = startServer(0); + const send = async (session: string, input: unknown[], parentSession?: string) => { + const response = await originalFetch(new URL("/v1/responses", server.url), { + method: "POST", headers: { "content-type": "application/json", "session-id": session, + ...(parentSession ? { "x-codex-parent-thread-id": parentSession } : {}), + }, + body: JSON.stringify({ model: "xai/grok-4.6", stream, store: false, input, + tools: [{ type: "function", name: "probe", parameters: { type: "object", properties: {} } }], + }), + }); + expect(response.status).toBe(200); + if (!stream) return await response.json() as { output: Array> }; + const text = await response.text(); + const events = text.split(/\r?\n/).filter(line => line.startsWith("data: ") && line !== "data: [DONE]") + .map(line => JSON.parse(line.slice(6))); + const terminal = events.find(event => event.type === "response.completed"); + expect(terminal).toBeDefined(); + return terminal.response as { output: Array> }; + }; + try { + const initial = { type: "message", role: "user", content: [{ type: "input_text", text: "Collect a worker result" }] }; + const parent = await send("fixture-parent", [initial]); + expect(parent.output[0]).toMatchObject(call); + const child = await send("fixture-worker", [ + { type: "message", role: "user", content: [{ type: "input_text", text: "Return the synthetic observations" }] }, + ], "fixture-parent"); + const childContent = child.output[0]!.content as Array<{ type: string; text: string }>; + expect(childContent[0]).toMatchObject({ type: "output_text", text: childText }); + // Codex-client envelope simulation only: no scheduler or real child process is run. + const toolResult = { type: "function_call_output", call_id: call.call_id, output: "Probe completed" }; + const agentMessage = { type: "agent_message", id: "amsg_worker_result", author: "/root/worker", recipient: "/root", + content: childContent[0]!.text, + }; + const resumed = await send("fixture-parent", [initial, ...parent.output, toolResult, agentMessage]); + expect(resumed.output[0]).toMatchObject({ type: "message", content: [{ type: "output_text", text: "Parent continued" }] }); + expect(privateItemRejections).toBe(0); + expect(captured).toHaveLength(3); + const input = captured[2]!.input as Array>; + expect(input.some(item => item.type === "agent_message")).toBe(false); + expect(input.filter(item => item.type === "function_call")).toEqual([ + expect.objectContaining({ call_id: call.call_id, name: "probe", arguments: "{}" }), + ]); + expect(input.filter(item => item.type === "function_call_output")).toEqual([toolResult]); + expect(input).toContainEqual({ type: "message", role: "user", content: [ + { type: "input_text", text: 'Agent message {"author":"/root/worker","recipient":"/root"}' }, + { type: "input_text", text: childText }, + ] }); + expect(agentMessage.content).toBe(childText); + } finally { + await server.stop(true); + } + }, 10_000); + test("uses the native Responses wire and relays the first delta before completion", async () => { let releaseCompletion!: () => void; const completionGate = new Promise(resolve => { releaseCompletion = resolve; }); From 00eb47886690e7b24b0eed69b6d870c33ceade62 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 11:36:50 +0900 Subject: [PATCH 2/6] docs(devlog): revalidate xAI string continuation layer --- devlog/_plan/260908_bug6_manual_stack/020_xai_continuation.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/devlog/_plan/260908_bug6_manual_stack/020_xai_continuation.md b/devlog/_plan/260908_bug6_manual_stack/020_xai_continuation.md index 3fb6fbd99c..f7571bca27 100644 --- a/devlog/_plan/260908_bug6_manual_stack/020_xai_continuation.md +++ b/devlog/_plan/260908_bug6_manual_stack/020_xai_continuation.md @@ -17,3 +17,7 @@ Before the raw-body outbound normalizer requires array content and leaves the is ## Verification Pin parent/child fixtures to synthetic input. The strict upstream stub must reject the pre-fix request shape and accept the normalized one; destination-negative controls prove the guard is active. Hosted PR CI and final full dispatch execute adapter/server regressions. Local tests/install/typecheck/build remain NOT RUN. Source audit checks raw-body call placement and all consumers of the added option. There is no serialized configuration field or migration: option creation and consumption are both in-memory adapter calls. + +## wp2 P refresh + +Previous wp1 D: PR3986 at d1f61e933 passed run34178540141 and independent source/security audit, with18Go replay scenarios and remote docs425pages. Proceed to xAI string residual. Candidate339e42c1e was prepared in an isolated worktree under the owner-authorized parallel-preparation amendment; it is not yet adopted. Its base exactly equals the certified preceding layer, and the eight-file diff matches this plan. Existing xAI predicate remains the destination owner; no account changes. Issue3907 is still open. Main revalidates candidate before B and retains fresh hosted CI before wp2 closure. From 888fbf4a4986d3a8bf41b2100963efca3114b23f Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 8 Sep 2026 03:35:18 +0900 Subject: [PATCH 3/6] fix(codex): separate proxy v2 guidance from native mode (cherry picked from commit 481edbbd8a853c4155db9c40f76e432f9001b2d2) (cherry picked from commit 24977adf223210dbf68cfb5d626916f97b47ed9a) --- .../docs/ja/reference/configuration/agents.md | 6 +- .../docs/ko/reference/configuration/agents.md | 6 +- .../docs/reference/configuration/agents.md | 22 ++- .../docs/ru/reference/configuration/agents.md | 28 ++-- .../zh-cn/reference/configuration/agents.md | 6 +- src/server/responses/collaboration.ts | 35 +++-- src/types/config.ts | 2 +- .../multi-agent-compat.test.ts | 125 +++++++++++++++--- 8 files changed, 181 insertions(+), 49 deletions(-) diff --git a/docs-site/src/content/docs/ja/reference/configuration/agents.md b/docs-site/src/content/docs/ja/reference/configuration/agents.md index 2b185b81c4..964d729d73 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ja/reference/configuration/agents.md @@ -28,13 +28,15 @@ description: マルチエージェント サーフェス、委任ガイダンス ## ロスターとガイダンス -有効な v2 ロスターは、v2 と互換性があり、挿入されたカタログに存在する、構成され、ピッカーに表示され、優先順位で並べ替えられた最初の 5 つのモデルです。 V2 の適格性は、明示的な `"v2"`、`null`、または欠落しているアップストリーム ピンを適格なものとして扱います。実際の `"v1"` ピンは除外されます。除外されたエントリは設定に残るため、後で適格になる可能性があります。 +有効な v2 ロスターは、設定済みでピッカーに表示され、優先順位で並べ替えられた最初の 5 つのモデルのうち、挿入されたカタログに存在し、明示的に `"disabled"` とされていないモデルです。明示的な `"v2"` ピンは再帰的なワーカーをサポートし、`"v1"`、`null`、ピンの省略はリーフワーカーとして引き続き適格です。除外されたエントリは設定に残るため、後で適格になる可能性があります。 表面検出はツール形状を使用します。 `send_input`、`resume_agent`、または `close_agent` を持つ名前空間付き `spawn_agent` は v1 です。 `send_message`、`followup_task`、`interrupt_agent`、または `list_agents` を備えたフラット `spawn_agent` は v2 です。 V1 ガイダンスは、`max` または `ultra` でのみプロアクティブ テキストです。 V2 は、優先モデル、適格なロスター、またはフォールバック チェーンが存在する場合にのみ、プロキシ作成の開発者メッセージを受信します。組み込みの v2 ガイダンスには 700 文字のバジェットがあり、必要に応じて最初にロスターが削除されます。ガイダンスはリプレイ プレフィックス全体で重複排除され、後続の `compaction_trigger` の前に挿入されます。 -`injectionModel` および `injectionEffort` は、ネイティブデフォルト同期が有効になっていない限り、推奨事項です。組み込みの v2 テキストは、サポートされているモデル/エフォートのオーバーライドを `fork_turns: "none"` を使用して `spawn_agent` に渡すように Codex に要求します。カスタム `injectionPrompt` は、欠落している値を空の文字列に置き換えます。 +組み込みの v2 サブエージェントガイダンスとカスタム `injectionPrompt` 本文は、どちらも `` を使用し、Codex ネイティブの `` メッセージとは区別されます。組み込みテキストは、解決済みの優先モデル、ロスター、フォールバックチェーンを示しますが、委任、モデルのオーバーライド、`fork_turns` は指示しません。カスタム本文のプレースホルダー置換と内容は維持されます。`injectionModel` および `injectionEffort` は、ネイティブデフォルト同期が有効になっていない限り推奨事項であり、カスタムプレースホルダーの欠落値は引き続き空の文字列に置き換えられます。 + +リプレイの重複排除では、タグの種類ごとに最新のテキストとの完全一致を確認します。両方の値が新しいプロキシのタグを使用する場合、カスタムガイダンスから組み込み形式へ戻すと、その時点の内容が追加されます。途中でネイティブモードが変わっても、変更のないプロキシガイダンスは重複追加されません。既存のネイティブメッセージと旧タグ付きの履歴は保持されます。ラッパーの変更によって過去のメッセージの作成者が判明したり、以前の指示が取り消されたりするわけではありません。複数バージョンが混在する履歴は、旧タグだけでは分類できず、そのような履歴での設定変更の検出は保証されません。 ## ネイティブ Codex のデフォルト同期 diff --git a/docs-site/src/content/docs/ko/reference/configuration/agents.md b/docs-site/src/content/docs/ko/reference/configuration/agents.md index 1c999536f2..104393d28a 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ko/reference/configuration/agents.md @@ -28,13 +28,15 @@ description: 멀티 에이전트 표면, 위임 안내, 선호 모델, 대체 ## 로스터와 안내 -실제 v2 로스터는 설정되어 있고, 선택기에 보이며, 우선순위로 정렬된 상위 다섯 모델 중 v2와 호환되고 주입된 카탈로그에 존재하는 모델입니다. v2 적격성은 명시적인 `"v2"`, `null`, 또는 생략된 상위 고정값을 적격으로 보고, 실제 `"v1"` 고정값은 제외합니다. 제외된 항목은 나중에 적격이 될 수 있도록 설정에 그대로 남습니다. +실제 v2 로스터는 설정되어 있고, 선택기에 보이며, 우선순위로 정렬된 상위 다섯 모델 중 주입된 카탈로그에 존재하고 명시적으로 `"disabled"`로 표시되지 않은 모델입니다. 명시적인 `"v2"` 고정값은 재귀 작업자를 지원하며, `"v1"`, `null`, 생략된 고정값도 하위 작업을 다시 위임하지 않는 작업자로 참여할 수 있습니다. 제외된 항목은 나중에 적격이 될 수 있도록 설정에 그대로 남습니다. 표면 판별은 도구 형태를 기준으로 합니다. 네임스페이스가 붙은 `spawn_agent`에 `send_input`, `resume_agent`, `close_agent`가 있으면 v1입니다. 평평한 `spawn_agent`에 `send_message`, `followup_task`, `interrupt_agent`, `list_agents`가 있으면 v2입니다. V1 안내는 `max` 또는 `ultra`에서만 선제 텍스트로 제공됩니다. V2는 선호 모델, 적격 로스터, 대체 체인 중 하나가 있을 때만 프록시가 작성한 개발자 메시지를 받습니다. 내장 v2 안내에는 700자 예산이 있고, 필요하면 로스터를 먼저 줄입니다. 안내는 replay prefix 전반에서 중복 제거되며, 뒤에 오는 `compaction_trigger` 앞에 삽입됩니다. -`injectionModel`과 `injectionEffort`는 네이티브 기본값 동기화가 활성화되지 않으면 권고 수준입니다. 내장 v2 텍스트는 Codex에게 지원되는 모델/노력 오버라이드를 `fork_turns: "none"`과 함께 `spawn_agent`로 전달하라고 요청합니다. 사용자 지정 `injectionPrompt`는 누락된 값을 빈 문자열로 대체합니다. +내장 v2 서브에이전트 안내와 사용자 지정 `injectionPrompt` 본문은 모두 ``를 사용하며, Codex 네이티브 `` 메시지와 구분됩니다. 내장 텍스트는 결정된 선호 모델, 모델 목록, 대체 체인을 알리지만 위임, 모델 오버라이드, `fork_turns`를 지시하지는 않습니다. 사용자 지정 본문의 자리표시자 치환과 내용은 유지됩니다. `injectionModel`과 `injectionEffort`는 네이티브 기본값 동기화가 활성화되지 않으면 계속 권고 수준이며, 사용자 지정 자리표시자의 누락된 값은 빈 문자열로 대체됩니다. + +replay 중복 제거는 각 태그 계열의 가장 최근 텍스트와 정확히 일치하는지 비교합니다. 두 값 모두 새 프록시 태그 계열을 사용하는 경우, 사용자 지정 안내에서 내장 형식으로 돌아오면 현재 안내가 추가됩니다. 그 사이에 네이티브 모드가 바뀌어도 변경되지 않은 프록시 안내가 중복 추가되지는 않습니다. 기존 네이티브 메시지와 예전 태그가 붙은 이력은 보존됩니다. 래퍼 변경으로 과거 메시지의 작성자가 판별되거나 이전 지침이 철회되는 것은 아닙니다. 여러 버전이 섞인 이력은 예전 태그만으로 분류할 수 없으며, 이러한 이력에서 설정 전환이 감지된다고 보장하지 않습니다. ## Codex 기본값 동기화 diff --git a/docs-site/src/content/docs/reference/configuration/agents.md b/docs-site/src/content/docs/reference/configuration/agents.md index f3caea02f2..57e9ab7d8e 100644 --- a/docs-site/src/content/docs/reference/configuration/agents.md +++ b/docs-site/src/content/docs/reference/configuration/agents.md @@ -75,9 +75,9 @@ loudly when the installed Codex build does not know the flag yet. ## Roster and guidance The effective v2 roster is the configured, picker-visible, priority-sorted first five models that -are compatible with v2 and present in the injected catalog. V2 eligibility treats an explicit `"v2"`, -`null`, or absent upstream pin as eligible; a real `"v1"` pin is excluded. Excluded entries remain in -configuration so they can become eligible later. +are present in the injected catalog and are not explicitly marked `"disabled"`. An explicit `"v2"` +pin supports recursive workers; `"v1"`, `null`, and absent pins remain eligible as leaf workers. +Excluded entries remain in configuration so they can become eligible later. Surface detection uses tool shape. A namespaced `spawn_agent` with `send_input`, `resume_agent`, or `close_agent` is v1. A flat `spawn_agent` with `send_message`, `followup_task`, `interrupt_agent`, or @@ -88,9 +88,19 @@ message only when a preferred model, eligible roster, or fallback chain exists. has a 700-character budget and drops the roster first if necessary. Guidance is deduplicated across replay prefixes and inserted before a trailing `compaction_trigger`. -`injectionModel` and `injectionEffort` are advisory unless native-default sync is enabled. The built-in -v2 text asks Codex to pass supported model/effort overrides to `spawn_agent` with -`fork_turns: "none"`. A custom `injectionPrompt` substitutes missing values with an empty string. +Both built-in v2 subagent guidance and custom `injectionPrompt` bodies use +``, separate from Codex's native `` messages. +Built-in text reports the resolved preferred model, roster, and fallback chain without prescribing +delegation, model overrides, or `fork_turns`. Custom bodies retain their placeholder substitution +and content. `injectionModel` and `injectionEffort` remain advisory unless native-default sync is +enabled; missing custom placeholder values are still replaced with an empty string. + +Replay deduplication compares the latest exact text in each tag family. When both values use the +new proxy family, switching custom guidance back to the built-in form appends the current value; +intervening native mode changes do not duplicate unchanged proxy guidance. Existing native and +legacy-tagged history is preserved. This wrapper change does not identify the author of old +messages or revoke prior instructions. Mixed-version histories cannot be classified from the +legacy tag alone, and transition detection across such histories is not guaranteed. ## Native Codex default sync diff --git a/docs-site/src/content/docs/ru/reference/configuration/agents.md b/docs-site/src/content/docs/ru/reference/configuration/agents.md index a3a0bc434b..b33ac85841 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/agents.md +++ b/docs-site/src/content/docs/ru/reference/configuration/agents.md @@ -36,10 +36,10 @@ custom prompt на этом API передаётся полем `prompt`. ## Roster и guidance Эффективный ростер v2 — это настроенные, видимые в picker'е, отсортированные по priority первые -пять моделей, совместимых с v2 и присутствующих во внедряемом каталоге. Для v2 запись считается -допустимой, если upstream pin равен `"v2"`, `null` либо вовсе отсутствует; реальный pin `"v1"` -исключает модель. Исключённые записи всё равно остаются в конфигурации, чтобы позже снова стать -допустимыми. +пять моделей, присутствующих во внедряемом каталоге и не отмеченных явно как `"disabled"`. +Явный pin `"v2"` поддерживает рекурсивных подагентов; `"v1"`, `null` и отсутствующий pin +остаются допустимыми для подагентов без дальнейшего делегирования. Исключённые записи остаются +в конфигурации, чтобы позже снова стать допустимыми. Определение surface основано на форме tool'ов. Namespaced `spawn_agent` вместе с `send_input`, `resume_agent` или `close_agent` — это v1. Плоский `spawn_agent` вместе с `send_message`, @@ -51,10 +51,22 @@ roster или fallback chain. Встроенное guidance v2 ограниче сначала удаляет roster. Guidance дедуплицируется по replay-prefix и вставляется перед завершающим `compaction_trigger`. -`injectionModel` и `injectionEffort` носят рекомендательный характер, если только не включён -native-default sync. Встроенный текст v2 просит Codex передавать поддерживаемые override'ы model -и effort в `spawn_agent` с `fork_turns: "none"`. В custom `injectionPrompt` отсутствующие значения -подставляются как пустая строка. +И встроенные указания v2 для подагентов, и пользовательские тела `injectionPrompt` используют +``, отдельно от нативных сообщений Codex ``. +Встроенный текст сообщает итоговую предпочтительную модель, список моделей и цепочку резервных +моделей, но не предписывает делегирование, переопределение модели или `fork_turns`. Подстановка +значений в плейсхолдеры и содержимое пользовательских тел сохраняются. `injectionModel` и +`injectionEffort` остаются рекомендациями, если не включена синхронизация нативных значений по +умолчанию; отсутствующие значения пользовательских плейсхолдеров заменяются пустой строкой. + +Дедупликация replay проверяет точное совпадение с последним текстом в каждой группе тегов. +Если оба значения используют новую группу тегов прокси, при возврате от пользовательских указаний +к встроенной форме добавляется её текущее содержимое; промежуточные изменения нативного режима +не дублируют неизменившиеся указания прокси. +Существующая история нативных сообщений и сообщений со старым тегом сохраняется. Изменение +обёртки не устанавливает автора старых сообщений и не отменяет прежние инструкции; историю +со смешанными версиями нельзя классифицировать только по старому тегу, и обнаружение переходов +в такой истории не гарантируется. ## Синхронизация native default'ов Codex diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md index 1c8d71eeca..ec67917fd0 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/agents.md @@ -28,13 +28,15 @@ description: 多代理界面、委派引导、首选模型、回退链、原生 ## 名单与引导 -有效的 v2 名单,是已配置、在选择器中可见、按优先级排序的前五个模型中,和 v2 兼容且存在于注入目录中的那些模型。v2 资格判定会把显式的 `"v2"`、`null`,或缺失的上游固定值视为可用;真正的 `"v1"` 固定值会被排除。被排除的条目仍会保留在配置中,以便将来重新变为可用。 +有效的 v2 名单,是已配置、在选择器中可见、按优先级排序的前五个模型中,存在于注入目录且未明确标记为 `"disabled"` 的模型。显式的 `"v2"` 标记支持递归子代理;`"v1"`、`null` 和缺失的标记仍可作为叶子子代理。被排除的条目仍会保留在配置中,以便将来重新变为可用。 界面检测使用工具形状来判断。带命名空间的 `spawn_agent`,如果具有 `send_input`、`resume_agent` 或 `close_agent`,就是 v1。平铺的 `spawn_agent`,如果具有 `send_message`、`followup_task`、`interrupt_agent` 或 `list_agents`,就是 v2。 V1 引导只会在 `max` 或 `ultra` 时以主动文本形式出现。V2 只有在存在首选模型、可用名单或回退链时,才会收到代理生成的开发者消息。内置 v2 引导有 700 个字符的预算,必要时会先删减名单。引导会在 replay prefix 之间去重,并插入到末尾的 `compaction_trigger` 之前。 -除非启用了原生默认值同步,`injectionModel` 和 `injectionEffort` 都只是建议。内置 v2 文本会要求 Codex 使用 `fork_turns: "none"` 将受支持的模型/effort 覆盖传给 `spawn_agent`。自定义 `injectionPrompt` 会把缺失值替换为空字符串。 +内置 v2 子代理引导和自定义 `injectionPrompt` 正文都使用 ``,与 Codex 原生的 `` 消息区分开来。内置文本会说明解析后的首选模型、名单和回退链,但不会指示委派、模型覆盖或 `fork_turns`。自定义正文的占位符替换和内容保持不变。除非启用了原生默认值同步,`injectionModel` 和 `injectionEffort` 仍只是建议;自定义占位符的缺失值仍替换为空字符串。 + +replay 去重会分别与每类标签的最新文本进行精确比较。当两个值都使用新的代理标签时,从自定义引导切回内置形式会追加当前的引导内容;期间原生模式的变化不会导致未改变的代理引导被重复添加。现有的原生消息历史和带旧标签的历史都会保留。更换包装标签并不能确定旧消息的作者,也不会撤销先前的指令;对于混合版本的历史,不能仅凭旧标签进行分类,也不保证检测到这类历史中的设置切换。 ## Codex 原生默认值同步 diff --git a/src/server/responses/collaboration.ts b/src/server/responses/collaboration.ts index 8d8ce42e7f..e01a79cea8 100644 --- a/src/server/responses/collaboration.ts +++ b/src/server/responses/collaboration.ts @@ -241,6 +241,9 @@ export const PROACTIVE_MULTI_AGENT_MODE_TEXT = [ "This mode remains active until a later multi-agent mode developer message changes it.", ].join(" "); +const OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG = ""; +const OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG = ""; + export function isV1CollabSurface(parsed: OcxParsedRequest): boolean { return collabSurface(parsed) === "v1"; } @@ -468,18 +471,15 @@ export async function multiAgentGuidanceText( // fallback only for explicit routed/account-qualified ids. const promptModel = preferred?.model ?? (injectionModel?.includes("/") ? injectionModel : undefined); - return `${applyInjectionPlaceholders(injectionPrompt, promptModel, injectionEffort, roster, fallbackGuidance)}`; + return `${OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG}${applyInjectionPlaceholders(injectionPrompt, promptModel, injectionEffort, roster, fallbackGuidance)}${OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG}`; } if (!preferred && roster === "" && fallbackGuidance === "") return null; - let text = "When the active spawn_agent tool supports optional \"model\" or \"reasoning_effort\" overrides, " - + "use only models listed for this collaboration surface. " - + "When setting either override, set fork_turns to \"none\" " - + "(or a positive turn count such as \"3\"; full-history forks reject overrides) " - + "and make the task message self-contained."; + let text = "OpenCodex sub-agent routing metadata for this collaboration surface. " + + "This metadata does not override Codex delegation or model-selection rules."; if (preferred) { text += ` Preferred sub-agent: model "${preferred.model}"` + (injectionEffort ? `, reasoning_effort "${injectionEffort}"` : "") - + " — use it unless the user names another."; + + "."; } text += fallbackGuidance; text += roster; @@ -487,7 +487,7 @@ export async function multiAgentGuidanceText( // Roster is the only unbounded part — drop it before breaking the budget. text = text.slice(0, text.length - roster.length); } - return `${text}`; + return `${OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG}${text}${OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG}`; } const effort = parsed.options.reasoning; @@ -544,6 +544,17 @@ function isGeneratedDeveloperItem(item: unknown, text: string): boolean { return generatedDeveloperText(item) === text; } +function generatedGuidanceFamily(text: string): "multi_agent_mode" | "opencodex_subagent_guidance" | undefined { + if (text.startsWith("") && text.endsWith("")) { + return "multi_agent_mode"; + } + if (text.startsWith(OPENCODEX_SUBAGENT_GUIDANCE_OPEN_TAG) + && text.endsWith(OPENCODEX_SUBAGENT_GUIDANCE_CLOSE_TAG)) { + return "opencodex_subagent_guidance"; + } + return undefined; +} + function isDeveloperPrefixItem(item: unknown): boolean { if (!isRecord(item)) return false; if (item.type === "additional_tools") return item.role === "developer"; @@ -583,13 +594,13 @@ export function injectDeveloperMessage(parsed: OcxParsedRequest, text: string): const devItem = { type: "message", role: "developer", content: [{ type: "input_text", text }] }; if (rawInput) { const replayPrefix = rawInput.slice(0, replayPrefixLen); - const taggedGuidance = text.startsWith("") && text.endsWith(""); - const lastTaggedGuidance = taggedGuidance + const guidanceFamily = generatedGuidanceFamily(text); + const lastTaggedGuidance = guidanceFamily ? replayPrefix.map(generatedDeveloperText) - .filter(item => item?.startsWith("") && item.endsWith("")) + .filter(item => item !== undefined && generatedGuidanceFamily(item) === guidanceFamily) .at(-1) : undefined; - if (taggedGuidance ? lastTaggedGuidance === text : replayPrefix.some(item => isGeneratedDeveloperItem(item, text))) { + if (guidanceFamily ? lastTaggedGuidance === text : replayPrefix.some(item => isGeneratedDeveloperItem(item, text))) { return; } } diff --git a/src/types/config.ts b/src/types/config.ts index 017d01a94a..0399ee7be2 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -513,7 +513,7 @@ export interface OcxConfig { streamMode?: "auto" | "legacy-tee" | "eager-relay"; /** * Custom override for the injected v2 multi-agent guidance body (the text inside - * the tags). After guidance is enabled and the v2 surface and + * the tags). After guidance is enabled and the v2 surface and * catalog-state gates pass, a configured injectionModel is sufficient to render it; * otherwise an eligible roster or fallback is required. Placeholders: `{{model}}` -> the * effective preferred model for the request (a bare native model is account-qualified diff --git a/tests/codex-integration/multi-agent-compat.test.ts b/tests/codex-integration/multi-agent-compat.test.ts index 9430ab657a..6b3d1374b2 100644 --- a/tests/codex-integration/multi-agent-compat.test.ts +++ b/tests/codex-integration/multi-agent-compat.test.ts @@ -267,7 +267,7 @@ describe("multiAgentGuidanceText", () => { } }); - test("v2 built-in guidance is schema-agnostic and keeps fork rules", async () => { + test("v2 built-in guidance reports routing metadata without replacing native delegation rules", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [{ slug: "anthropic/claude-sonnet-5", @@ -279,10 +279,13 @@ describe("multiAgentGuidanceText", () => { { injectionModel: "anthropic/claude-sonnet-5" }, ); - expect(text).toContain("When the active spawn_agent tool supports optional"); - expect(text).toContain("use only models listed for this collaboration surface"); - expect(text).toContain("fork_turns"); - expect(text).toContain('"none"'); + expect(text).toStartWith(""); + expect(text).toEndWith(""); + expect(text).toContain("OpenCodex sub-agent routing metadata"); + expect(text).toContain("does not override Codex delegation or model-selection rules"); + expect(text).not.toContain("fork_turns"); + expect(text).not.toContain("use it unless"); + expect(text).not.toContain(""); expect(text).not.toMatch(/hidden/i); expect(text).not.toMatch(/not in the schema/i); expect(text).not.toMatch(/never claim/i); @@ -384,7 +387,7 @@ describe("multiAgentGuidanceText", () => { injectionPrompt: "Use {{model}}.", }, ); - expect(custom).toBe('Use team/gpt-5.6-sol.'); + expect(custom).toBe('Use team/gpt-5.6-sol.'); const exactBare = await multiAgentGuidanceText( parsedFixture({ tools: [{ name: "spawn_agent" }] }), @@ -403,7 +406,7 @@ describe("multiAgentGuidanceText", () => { injectionPrompt: "Use {{model}}.", }, ); - expect(exactBareCustom).toBe("Use local-fast."); + expect(exactBareCustom).toBe("Use local-fast."); const bareParent = await multiAgentGuidanceText( parsedFixture({ tools: [{ name: "spawn_agent" }] }), @@ -449,7 +452,7 @@ describe("multiAgentGuidanceText", () => { injectionPrompt: "Use {{model}}.", }, ); - expect(ambiguousCustom).toBe("Use ."); + expect(ambiguousCustom).toBe("Use ."); expect(ambiguousCustom).not.toContain("gpt-5.6-sol"); }); @@ -508,7 +511,7 @@ describe("multiAgentGuidanceText", () => { injectionModel: "gpt-5.6-sol", injectionPrompt: "Use {{model}}.", }, - )).toBe("Use ."); + )).toBe("Use ."); }); test("effective roster applies alias, visibility, v2 compatibility, stable priority, cap, and diagnostics", async () => { @@ -612,7 +615,7 @@ describe("multiAgentGuidanceText", () => { { injectionModel: "anthropic/claude-sonnet-5" }, ); expect(text).toContain('"anthropic/claude-sonnet-5"'); - expect(text).toContain("fork_turns"); + expect(text).toContain("OpenCodex sub-agent routing metadata"); expect(text).not.toContain("Proactive multi-agent delegation is active"); // and WITHOUT an injectionModel it stays silent (codex-rs owns the v2 Proactive text) expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "ultra", tools: nativeV2 }))).toBeNull(); @@ -654,7 +657,7 @@ describe("multiAgentGuidanceText", () => { injectionEffort: "xhigh", subagentModels: ["gpt-5.6-terra"], }); - expect(text).toContain("When the active spawn_agent tool supports optional"); + expect(text).toContain("OpenCodex sub-agent routing metadata"); expect(text).not.toMatch(/hidden|not in the schema|never claim/i); expect(text).toContain('(reasoning_effort high/max/ultra): "gpt-5.6-terra"'); }); @@ -738,8 +741,8 @@ describe("multiAgentGuidanceText", () => { // gpt-5.6-luna carries upstream's "v1" pin, which is now an eligible LEAF worker // (codex-rs 6d4d9442c), so it joins the substituted roster. expect(text).toBe( - 'CUSTOM model=raw/preferred-model effort=max' - + ' Available models (reasoning_effort high/max): "gpt-5.6-terra", "gpt-5.6-luna".', + 'CUSTOM model=raw/preferred-model effort=max' + + ' Available models (reasoning_effort high/max): "gpt-5.6-terra", "gpt-5.6-luna".', ); }); @@ -778,14 +781,14 @@ describe("multiAgentGuidanceText", () => { expect(await multiAgentGuidanceText(parsedFixture({ reasoning: "medium", tools: v2Tools }))).toBeNull(); }); - test("v2 surface + roster alone (no injectionModel) fires with the argument-acceptance preamble", async () => { + test("v2 surface + roster alone (no injectionModel) reports routing metadata", async () => { const dir = codexHomeFixture(V2_ON); catalogFixture(dir, [{ slug: "gpt-5.6-terra", efforts: ["high", "max", "ultra"] }]); const text = await multiAgentGuidanceText( parsedFixture({ reasoning: "medium", tools: [{ name: "spawn_agent" }] }), { subagentModels: ["gpt-5.6-terra"] }, ); - expect(text).toContain("When the active spawn_agent tool supports optional"); + expect(text).toContain("OpenCodex sub-agent routing metadata"); expect(text).not.toMatch(/hidden|not in the schema|never claim/i); expect(text).toContain('(reasoning_effort high/max/ultra): "gpt-5.6-terra"'); expect(text).not.toContain("Preferred sub-agent"); @@ -861,7 +864,7 @@ describe("multiAgentGuidanceText", () => { subagentModels: ["gpt-5.5", "opencode-go/glm-5.2", "anthropic/claude-opus-4-6", "gpt-5.6-sol", "gpt-5.6-terra"], }, ); - const body = text!.replace(/^/, "").replace(/<\/multi_agent_mode>$/, ""); + const body = text!.replace(/^/, "").replace(/<\/opencodex_subagent_guidance>$/, ""); expect(body.length).toBeLessThanOrEqual(700); expect(body).toContain("Available models"); // roster fits inside the budget }); @@ -1182,6 +1185,96 @@ describe("injectDeveloperMessage", () => { expect((replay._rawBody as { input: unknown[] }).input.at(-1)).toEqual(generatedItem(guidanceA)); }); + test("proxy guidance dedup records a metadata A-B-A transition", () => { + const metadataA = "A"; + const metadataB = "B"; + const current = { type: "message", role: "user", content: "current turn" }; + const rawInput = [generatedItem(metadataA), generatedItem(metadataB), current]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput, previous_response_id: "resp_1" }); + parsed._replayPrefixLen = 2; + parsed._continuationConversationMessageIndex = 2; + + injectDeveloperMessage(parsed, metadataA); + + expect(rawInput).toEqual([generatedItem(metadataA), generatedItem(metadataB), generatedItem(metadataA), current]); + expect(parsed.context.messages.map(message => message.content)).toEqual([metadataA, metadataB, metadataA, "current turn"]); + }); + + test("proxy guidance dedup preserves intervening native mode changes", () => { + const nativeA = "Native policy A"; + const nativeB = "Native policy B"; + const metadata = "Routing metadata"; + const rawInput = [generatedItem(nativeA), generatedItem(metadata), generatedItem(nativeB), { role: "user", content: "work" }]; + const before = structuredClone(rawInput); + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput }); + parsed._replayPrefixLen = 3; + + injectDeveloperMessage(parsed, metadata); + + expect(rawInput).toEqual(before); + expect(parsed.context.messages.map(message => message.content)).toEqual([nativeA, metadata, nativeB, "work"]); + }); + + test("native mode dedup ignores later proxy guidance", () => { + const native = "Native policy"; + const metadata = "Routing metadata"; + const rawInput = [generatedItem(native), generatedItem(metadata), { role: "user", content: "work" }]; + const before = structuredClone(rawInput); + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput }); + parsed._replayPrefixLen = 2; + + injectDeveloperMessage(parsed, native); + + expect(rawInput).toEqual(before); + expect(countExact(rawInput, native)).toBe(1); + }); + + test("restores default v2 guidance after a custom prompt without changing the custom body", async () => { + const dir = codexHomeFixture(V2_ON); + catalogFixture(dir, [{ slug: "gpt-5.6-terra", efforts: ["high", "max"], multiAgentVersion: "v2" }]); + const fixture = parsedFixture({ tools: [{ name: "spawn_agent" }] }); + const options = { injectionModel: "gpt-5.6-terra", injectionEffort: "high" }; + const metadata = await multiAgentGuidanceText(fixture, options); + const custom = await multiAgentGuidanceText(fixture, { + ...options, + injectionPrompt: "Custom {{model}} effort={{effort}}\nKeep {{unknown}}.", + }); + expect(metadata).not.toBeNull(); + const current = { type: "message", role: "user", content: "current turn" }; + const rawInput = [generatedItem(metadata!), generatedItem(custom!), current]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput, previous_response_id: "resp_1" }); + parsed._replayPrefixLen = 2; + parsed._continuationConversationMessageIndex = 2; + + injectDeveloperMessage(parsed, (await multiAgentGuidanceText(fixture, options))!); + + expect(rawInput).toEqual([generatedItem(metadata!), generatedItem(custom!), generatedItem(metadata!), current]); + expect(parsed.context.messages.map(message => message.content)).toEqual([metadata, custom, metadata, "current turn"]); + expect(custom).toBe("Custom gpt-5.6-terra effort=high\nKeep {{unknown}}."); + }); + + const legacyBuiltIn = 'When the active spawn_agent tool supports optional "model" or "reasoning_effort" overrides, ' + + 'use only models listed for this collaboration surface. When setting either override, set fork_turns to "none" ' + + '(or a positive turn count such as "3"; full-history forks reject overrides) and make the task message self-contained.' + + ' Preferred sub-agent: model "gpt-5.6-terra", reasoning_effort "high" — use it unless the user names another.'; + test.each([ + ["built-in", legacyBuiltIn], + ["custom", "Operator-authored legacy prompt."], + ])("preserves legacy %s and native policy when first injecting new proxy guidance", (_kind, legacy) => { + const native = "Native delegation policy"; + const metadata = "Routing metadata"; + const current = { type: "message", role: "user", content: "work" }; + const prefix = [generatedItem(legacy), generatedItem(native)]; + const rawInput = [...prefix, current]; + const parsed = parseRequest({ model: "gpt-5.5", input: rawInput }); + parsed._replayPrefixLen = prefix.length; + + injectDeveloperMessage(parsed, metadata); + + expect(rawInput).toEqual([...prefix, generatedItem(metadata), current]); + expect(parsed.context.messages.map(message => message.content)).toEqual([legacy, native, metadata, "work"]); + }); + test("exact-guidance predicate rejects every near-match replay-prefix shape (#326)", () => { const nearMatches: Array<[string, unknown]> = [ ["non-record item", null], From 74a60168d2b2aff8c584c28d9cbc515811e6e656 Mon Sep 17 00:00:00 2001 From: luvs01 <27862058+luvs01@users.noreply.github.com> Date: Tue, 8 Sep 2026 04:05:41 +0900 Subject: [PATCH 4/6] docs(codex): describe injection effort as advisory metadata (cherry picked from commit 6fb0fc6f1d34c77b98a74fe817e5bd90063a7d1a) (cherry picked from commit 21757b71a6007d217ef1f383c739cfc6618fd8e9) --- src/types/config.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/types/config.ts b/src/types/config.ts index 0399ee7be2..0b0a2b2b98 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -469,8 +469,8 @@ export interface OcxConfig { */ syncCodexSubagentDefaults?: boolean; /** - * Optional reasoning effort the delegation prompt tells the agent to pass in spawn_agent calls - * (`reasoning_effort` argument). Only meaningful while `injectionModel` is set; validated against + * Optional reasoning effort reported as advisory metadata in v2 sub-agent guidance. + * It does not prescribe spawn overrides. Only meaningful while `injectionModel` is set; validated against * the Codex ladder (src/reasoning-effort.ts CODEX_REASONING_LEVELS) at the API boundary. */ injectionEffort?: string; From 694c51991ab7e01dd9e483ac54dfeb04ff2703af Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 11:16:40 +0900 Subject: [PATCH 5/6] docs(codex): record proxy guidance policy boundary Document the carried v2 wrapper and replay contract while preserving native and legacy policy history. Co-authored-by: luvs01 <27862058+luvs01@users.noreply.github.com> (cherry picked from commit 8000e2482fb06567ac3d3e3474c54c5a4468d92f) --- structure/03_catalog-and-subagents.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index b64cb4bce1..8ff92157ad 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -465,6 +465,19 @@ cause delegation. The TOML edit owns only marker-tagged values, preserves existi user-owned `[agents]` defaults rather than overwriting them, and rejects ambiguous table shapes without changing the file. +V2 proxy guidance uses `` for both built-in metadata and +custom `injectionPrompt` bodies. The built-in text reports the resolved preferred model, +effort, roster and fallback chain without prescribing delegation, spawn overrides or +`fork_turns`. Custom bodies retain their placeholder behavior. The guidance switch and +catalog-state gates still apply; stale or unknown catalog state suppresses proxy guidance. +V1 retains its `` proactive text at `max` or `ultra`. + +Replay deduplication compares the latest exact generated developer text separately for +each tag family, preserving built-in → custom → built-in transitions without duplicating +unchanged proxy metadata after a native policy change. Native and legacy-tagged history +remain intact: tags do not establish historical authorship or revoke old instructions, +and mixed-version transition detection is not guaranteed. + Claude Code `ocx-*` agent definitions consume the same effective `claudeCode.blockedSkills` policy as inbound bundle elision. When the list is non-empty (default: `claude-api`), generated definitions whose marker-stripped model resolves to a routed id receive a preventive instruction not to invoke From 3ceef0121712b290c3d4443e9fc3f0a04cecead6 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 8 Sep 2026 11:49:28 +0900 Subject: [PATCH 6/6] docs(devlog): revalidate V2 guidance stack layer --- devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md b/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md index 2e5219c1a4..37da50117a 100644 --- a/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md +++ b/devlog/_plan/260908_bug6_manual_stack/030_v2_guidance.md @@ -47,3 +47,7 @@ Remote-only focused activation: `bun test tests/codex-integration/multi-agent-co Main decision: preserve the complete original diff. The optional extra server caller fixture is deferred unless source audit reveals an untested change; do not duplicate the existing replay matrix merely for volume. Sync structure/03_catalog-and-subagents.md to the new tag and policy boundary. + +## wp3 P refresh + +Previous wp2 D: PR3991 head00eb47886 passed run34180674115, source audit and remote docs425pages; proceed guidance carry. Prepared layer3 consists of24977adf2,21757b71a,8000e2482, based on d1f61e933. Intervening wp2 changes affect xAI adapter/tests, provider/adapters docs and structure04; none overlap the9layer3 files. Original #3944 remains open at6fb0fc6f. Independent prepared-source/security audit PASS in isolated v2GuidanceReviewer.md; actual adoption requires unchanged-delta/interdiff verification and own hostedCI.