From 3dc64845cfa84f2c745b4aa3b0aee310fcba7be4 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 8 Sep 2026 02:55:23 +0900 Subject: [PATCH 1/9] docs(devlog): plan the workstream-A Responses compatibility stack Roadmap for landing four Responses-compatibility changes on dev as one dependent branch chain whose tip carries all of them, so a single CI run certifies the set: PR #3906 (Muse Spark Free web_search strip), PR #3886 (Spark Responses Lite header), issue #3922 (Claude tool strict default, new work), and PR #3917 (routed agent_message conversion). Each phase doc carries exact path:line anchors and before/after diffs. Three rounds of independent audit corrected the Layer 2 HTTP/WebSocket coverage boundary, the Claude compatibility semantics, the Layer 3 test that an added strict field breaks, the converted authMode set, and the landing proof for each GitHub merge method. --- .../000_plan.md | 103 +++++++++++++ .../010_phase1.md | 63 ++++++++ .../020_phase2.md | 71 +++++++++ .../030_phase3.md | 141 ++++++++++++++++++ .../040_phase4.md | 121 +++++++++++++++ .../050_phase5.md | 95 ++++++++++++ 6 files changed, 594 insertions(+) create mode 100644 devlog/_plan/260908_a_stack_responses_compat/000_plan.md create mode 100644 devlog/_plan/260908_a_stack_responses_compat/010_phase1.md create mode 100644 devlog/_plan/260908_a_stack_responses_compat/020_phase2.md create mode 100644 devlog/_plan/260908_a_stack_responses_compat/030_phase3.md create mode 100644 devlog/_plan/260908_a_stack_responses_compat/040_phase4.md create mode 100644 devlog/_plan/260908_a_stack_responses_compat/050_phase5.md diff --git a/devlog/_plan/260908_a_stack_responses_compat/000_plan.md b/devlog/_plan/260908_a_stack_responses_compat/000_plan.md new file mode 100644 index 0000000000..3a22b2d98f --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/000_plan.md @@ -0,0 +1,103 @@ +# 000 — a_stack_responses_compat: Plan + +## Objective + +Land four Responses-compatibility changes on `dev` as one manual dependent branch +chain whose tip carries all of them, so a single CI run certifies the whole set. +Three layers carry existing contributor pull requests; one is new work for an +issue that has no pull request. + +| Layer | Source | Author to preserve | Subject | +|---|---|---|---| +| 1 | PR #3906, commit `11c498b6c` | MohamadSabree8 | Muse Spark Contributor Free tiers keep unsupported `web_search` fields | +| 2 | PR #3886, commit `83c1d9b12` | cb8010d6 | Spark streams end `adapter_eof` when the Responses Lite header is present | +| 3 | Issue #3922, new work | — | Claude optional tool parameters become strict on Responses routes | +| 4 | PR #3917, commit `2430724e5` | mashfromband | Routed destinations reject Codex `agent_message` with 422 | + +Evidence base: four read-only `gpt-6-astra` explorer lanes read the current tree at +`2abf071e0` and returned quoted `path:line` anchors, reproduced in each phase doc. + +## Loop-spec + +- Loop archetype: satisfy-spec. Each layer has a stated correct behavior; there is + no metric to optimize. +- Trigger: maintainer request to execute workstream A as a stack. +- Goal: the tip merged into `dev`, children settled with authors preserved, linked + issues closed. +- Non-goals: registry `modelWireDefaults` for the `-free` ids; setting the Lite + header to `"false"` instead of removing it; PR #3838's tool-promotion, + `customToolWireName` export and `statelessResponses` work; any other open PR; + `main`/`preview` promotion. +- Verifier: the single Cross-platform CI run on the tip pull request's head SHA. + It runs the repository's own workflow over the cumulative tree, so it observes + every file changed by all four layers. +- Write scope: `src/adapters/openai-responses.ts`, `src/adapters/opencode-go.ts` -> + `src/adapters/routed-agent-messages.ts`, `src/claude/inbound-content-options.ts`, + their regressions, the two test-layout registries, + `docs-site/src/content/docs/reference/adapters.md`, + `docs-site/src/content/docs/reference/configuration/providers.md`, and this unit. +- Budget: no local suite runs at all (instructed). Wall-clock bound is the CI run + plus merge; a red tip after one bounded repair attempt is BLOCKED, not DONE. +- Stop condition: the stack landed on `dev`, proven for the merge method actually + used (see 050): original-tip ancestry for a merge commit, or landed-commit + ancestry plus per-path content equality for squash and rebase. +- Memory artifact: this unit, plus the goalplan at + `.codexclaw/goalplans/deliver-opencodex-workstream-a-responses-compati/`. +- Escalation: a finding that changes a carried author's intended behavior, or a + provider that rejects an explicit `strict: false`, returns to the maintainer. + +## Constraints (from the requesting maintainer) + +- No local product suite, typecheck, build or install runs in this session. Every + such check is recorded `NOT RUN`. +- Every push uses `--no-verify`. +- CI triggers on the stack tip only. When that one run is green, the tip merges + into `dev`; the remaining pull requests are then settled and the issues closed. +- Carried work keeps its original author through a `Co-authored-by` trailer. + +## Why tip-only CI is achievable + +`.github/workflows/ci.yml` declares `pull_request: {}` with no base filter, and +`push: branches: [main, preview, dev]`. Pushing `codex/a-stack-l1..l3` starts no +workflow: those refs are not integration branches and no pull request points at +them. Opening exactly one pull request, for layer 4 against `dev`, produces +exactly one Cross-platform CI run whose head contains all four layers. + +## Base and chain + +Base: `origin/dev` = `942c028735d39b2ad410b1baa95670984e16576d`. + +``` +codex/a-stack-l4-routed-agentmsg (tip, the only pull request) -> base dev +codex/a-stack-l3-claude-strict +codex/a-stack-l2-spark-lite +codex/a-stack-l1-muse-free +origin/dev 942c02873 +``` + +An ordinary dependent branch chain. GitHub native stacks are not used and were +not requested. + +## Work-phase map (one phase = one full PABCD cycle) + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| wp0 | 000 | This roadmap (docs only) | — | +| wp1 | 010 | Layer 1, carry #3906 | wp0 | +| wp2 | 020 | Layer 2, carry #3886 | wp1 | +| wp3 | 030 | Layer 3, implement #3922 | wp2 | +| wp4 | 040 | Layer 4, carry #3917 | wp3 | +| wp5 | 050 | Publish, one CI run, merge, settle | wp4 | + +Ordering follows textual adjacency in `src/adapters/openai-responses.ts`, which +layers 1, 2 and 4 all touch at distinct hunks (≈2125, ≈2503, ≈2366 and its import). +Layer 3 touches `src/claude/inbound-content-options.ts` only. + +## Terminal outcomes + +- DONE — the stack landed on `dev` with the merge-method-specific proof recorded, + children settled with authors preserved, issues #3885/#3922/#3911 closed. +- BLOCKED — CI red on the tip after a bounded repair attempt, or an unmet merge + requirement. +- NEEDS_HUMAN — an audit finding that would change a carried author's intended + behavior beyond what the issue asks. diff --git a/devlog/_plan/260908_a_stack_responses_compat/010_phase1.md b/devlog/_plan/260908_a_stack_responses_compat/010_phase1.md new file mode 100644 index 0000000000..12d8d9cc2f --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/010_phase1.md @@ -0,0 +1,63 @@ +# 010 — Phase 1: carry PR #3906 (Muse Spark Contributor Free web_search strip) + +Branch `codex/a-stack-l1-muse-free`, based on `origin/dev` `942c02873`. +Carried commit `11c498b6c62ae9f7c5b0d25ca24fc6612f607a5c` by MohamadSabree8. + +## Problem + +`stripMuseSparkUnsupportedWebSearchFields` removes `search_content_types` and +`indexed_web_access` from a Codex `web_search` tool before it reaches the Zen +Responses wire, because that gateway 400s on them. The model guard only lists the +two paid contributor ids, so the two Contributor Free ids ride the same wire and +same gateway contract but keep the rejected fields. + +## MODIFY map + +`src/adapters/openai-responses.ts` — the constant at 2125-2128. + +Before: + +```ts +const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ + "muse-spark-1.3-contributor", + "muse-spark-1.2-contributor", +]); +``` + +After: + +```ts +const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ + "muse-spark-1.3-contributor", + "muse-spark-1.3-contributor-free", + "muse-spark-1.2-contributor", + "muse-spark-1.2-contributor-free", +]); +``` + +Nothing else changes. The consumer at 2148, its model guard at 2155 +(`if (!MUSE_SPARK_WEB_SEARCH_STRICT_MODELS.has(modelId.trim().toLowerCase())) return body;`), +the destination guard at 2159-2164 and the call site at 2451 are untouched. + +## TESTS + +`tests/providers/muse-spark-web-search-compat.test.ts` — add free-tier cases that +mirror the paid-tier assertions already in the file: + +- top-level `tools`: type stays `web_search`, `search_context_size` preserved, + `search_content_types` and `indexed_web_access` absent (mirrors 81-87, 131-137); +- nested `input[].additional_tools.tools`: same removal (mirrors 106-114, 150-158); +- `web_search_preview` untouched for the free ids (mirrors 90-97, 140-147). + +## Known limit (recorded, not fixed here) + +`src/providers/registry.ts:1685-1690` maps only the paid ids in +`modelWireDefaults`, so the `-free` ids do not select the Responses wire +automatically; this fix applies when that wire is chosen explicitly. Changing the +registry is out of scope, matching the carried pull request. + +## Verification (C) + +No local command. The layer is verified by the single tip CI run described in 050. +Local suites: NOT RUN by instruction. + diff --git a/devlog/_plan/260908_a_stack_responses_compat/020_phase2.md b/devlog/_plan/260908_a_stack_responses_compat/020_phase2.md new file mode 100644 index 0000000000..2b518a82a9 --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/020_phase2.md @@ -0,0 +1,71 @@ +# 020 — Phase 2: carry PR #3886 (disable Responses Lite transport for Spark) + +Branch `codex/a-stack-l2-spark-lite`, based on layer 1. +Carried commit `83c1d9b129b80d4f65a797fd61a2026deb8c8123` by cb8010d6. + +## Problem + +Issue #3885: with `x-openai-internal-codex-responses-lite: true`, the canonical +backend opens a `gpt-5.3-codex-spark` SSE response and closes it before a terminal +event, which the adapter reports as `response.incomplete` / `adapter_eof`. The same +request without that header completes. + +## MODIFY map + +`src/adapters/openai-responses.ts` — inside the canonical-forward block at +2503-2513, before the existing routing-hint work. + +After: + +```ts + if (isCanonicalOpenAiForwardProvider(provider)) { + // Spark closes Responses Lite streams before a terminal completion. Select compatibility + // from the final wire model so aliases cannot leave the caller or a static header enabled. + if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; + } + } + const routingHeaders = new Headers(headers); + applyCodexRoutingHint(routingHeaders, finalBody); +``` + +`finalBody` is computed at 2494-2502 and serialized at 2523, so it is the actual +wire model. `parsed.modelId` can differ; the existing test at 187-188 pins that +distinction deliberately. Keying on `finalBody.model` therefore also covers aliases. +The loop removes every case spelling, which matters because static provider headers +merge in at 2315 and 2353 with arbitrary casing. + +## Scope of the fix, and what it does not cover (audit finding 1) + +The independent audit established a real boundary, verified against source: + +- **Covered.** Removing the header fixes the reported defect on the HTTP header + replay path, including the HTTP fallback: `prepareCodexHttpInit()` recomputes + only the routing hint (`src/server/responses/codex-ws-request.ts:46-52`) and + `httpInit` carries the header-deleted request forward (`:68`). +- **Not covered.** On the WebSocket path, `codex-ws-request.ts:30-33` writes + `client_metadata[CODEX_RESPONSES_LITE_METADATA_KEY]` only when the header is + present and reads `"true"`/`"false"`. Deleting the header leaves any + pre-existing `client_metadata` Lite value in the body untouched, and that value + reaches the frame at `:71` and the pool reuse key at + `codex-ws-pool.ts:53-55`. + +Setting the header to `"false"` instead of deleting it would also cover the WS +case, but that expands the carried author's diff beyond issue #3885 and changes +socket reuse identity. This phase carries the author's delete-only form and +records the WS metadata case as unresolved rather than silently expanding scope. +The tip pull request states this limit explicitly so the residual is visible. + +## TESTS + +`tests/codex-integration/codex-metadata-integrity.test.ts`, beside the mixed-case +test at 171: Spark wire model with a caller-provided Lite header, with a +mixed-case static header, and with `parsed.modelId` set to an alias while the +serialized model is Spark; `gpt-5.6-sol` keeps the header. Existing guards at 185, +211, 225 and 267 stay intact. + +## Verification (C) + +No local command. Verified by the single tip CI run in 050. Local suites: NOT RUN. + diff --git a/devlog/_plan/260908_a_stack_responses_compat/030_phase3.md b/devlog/_plan/260908_a_stack_responses_compat/030_phase3.md new file mode 100644 index 0000000000..c0e6a00b3f --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/030_phase3.md @@ -0,0 +1,141 @@ +# 030 — Phase 3: fix issue #3922 (Claude tool strict default on Responses routes) + +Branch `codex/a-stack-l3-claude-strict`, based on layer 2. New work; no existing +pull request. This is the only layer authored here. + +## Problem + +When Claude Code sends a custom tool without an explicit `strict`, the +Messages -> Responses translation emits a function tool that also omits `strict`. +The Responses API treats an omitted `strict` as an attempt to normalize the schema +into strict mode, so parameters that the Anthropic `input_schema` marks optional +are treated as required upstream, and a tool call that omits them fails. Anthropic +requires an explicit `strict: true` to opt in, so the two defaults disagree. + +## MODIFY map + +`src/claude/inbound-content-options.ts` — `toolsToResponses`, the function-tool +branch at 26-33. + +Before: + +```ts + if (typeof raw.name === "string" && raw.name.length > 0 && isRec(raw.input_schema)) { + out.push({ + type: "function", + name: raw.name, + ...(typeof raw.description === "string" ? { description: raw.description } : {}), + parameters: raw.input_schema as Record, + }); + continue; + } +``` + +After: + +```ts + if (typeof raw.name === "string" && raw.name.length > 0 && isRec(raw.input_schema)) { + out.push({ + type: "function", + name: raw.name, + ...(typeof raw.description === "string" ? { description: raw.description } : {}), + parameters: raw.input_schema as Record, + // Anthropic opts into strict tool use explicitly, while Responses normalizes + // an omitted strict into strict mode. Carry the source intent rather than the + // destination default, so an optional input_schema parameter stays optional. + strict: typeof raw.strict === "boolean" ? raw.strict : false, + }); + continue; + } +``` + +The value is derived from the source tool rather than hardcoded, so an explicit +`strict: true` or `strict: false` from the client survives translation, which is +what issue #3922 asks for. + +## How the existing strict-tool admission policy relates (audit finding 2) + +An earlier draft of this document described `src/claude/compatibility.ts:102` +backwards. What that line actually does: + +```ts +if (tool.strict === true) codes.add("strict_tools"); +``` + +`strict_tools` is listed as an incompatible feature at `compatibility.ts:16`, and +`analyzeClaudeCompatibility` (`compatibility.ts:179`) returns `"reject"` for an +incompatible request under enforce mode (decision expression at `:189`), which +`src/server/claude-messages.ts:733-740` applies **before** +translation. So an explicit `strict: true` is already refused in enforce mode and +only reaches translation on the default and shadow paths. + +This phase does not change that policy. Detection reads the **source** Anthropic +tool before translation, so emitting a default `strict: false` downstream adds no +new rejection; `tests/claude-integration/claude-compatibility.test.ts:73-78` +already covers the explicit-false allow case, and `:24` lists strict tools among +rejected features. + +## Propagation (independently traced twice, no further change needed) + +- `src/claude/inbound-content-options.ts:26-32` builds the tool. +- `src/claude/inbound.ts:350-351` assigns it to `body.tools`. +- `src/server/claude-messages.ts:875,897` serializes that body into the internal + Responses request; `src/server/responses/core.ts:3160` parses it. +- `src/responses/schema.ts:114` accepts `strict`; `parser-tools.ts:63` preserves an + explicit value including `false`. +- `src/responses/parser.ts:570` keeps `_rawBody`, and + `src/adapters/openai-responses.ts:2362-2364` starts from it. Canonical-field + stripping only removes `external_web_access`/`defer_loading` (`:189-200`) and + schema normalization spreads the tool (`{ ...tool, parameters }`, `:648-658`), + so `strict` reaches `JSON.stringify(finalBody)` at `:2523`. + +## Boundaries + +- Hosted `web_search` leaves the function at 22-24, before this branch, so it gains + no `strict` field. +- Native Anthropic passthrough never reaches translation: + `src/server/claude-messages.ts:721-722` returns from `anthropicNativePassthrough` + before the translation call at 757. +- Other Anthropic server tools still drop at 35. + +## Schema promise, stated precisely (audit finding 4) + +At translation, `parameters` is the caller's `input_schema` reference +(`inbound-content-options.ts:31`), so `properties`, `required` and nested schemas +are unchanged there. That is not a promise of a byte-identical schema on every +outbound route: `openai-responses.ts:651` runs `normalizeXaiToolParameters`, `:657` +supplies a root `type: "object"` when absent, `responses-code-mode.ts:23-27` can +rewrite an `exec` parameter description, and Azure Chat sanitizes at +`openai-chat.ts:1359-1361`. The regression asserts an unchanged schema through the +ordinary OpenAI Responses route. + +Adding the field also shifts fallback cache-cohort hashes, because translated tool +definitions participate in the hash at `src/claude/inbound.ts:386-392`. That is a +cohort change, not a correctness change. + +## Known risk + +The same translated tools feed translated Chat Completions routes, where +`openai-chat.ts:1343` forwards an explicit `strict`; Azure deletes it at `:1364`, +and `tests/providers/azure-model-router-tool-schema.test.ts:42` already pins that +absence. No repository-declared rejection of an explicit `strict: false` was found, +but universal upstream acceptance is not proven. A provider rejecting it is an +escalation. + +## TESTS + +`tests/claude-integration/claude-inbound.test.ts`: + +- **Update the existing assertion at 80-83.** It is an exact `toEqual` on the + translated `Read` tool and will fail once `strict` is present; the expected + object gains `strict: false`. +- Keep `expect(tools[1]).toEqual({ type: "web_search" })` at 84 unchanged. +- New cases: omitted `strict` -> `false`; explicit `false` -> preserved; explicit + `true` -> preserved; an `input_schema` with one required and one optional + property keeps its `required` array through `parseRequest`. +- Assert the three values on the **serialized adapter output**, not only the + translator return, so the wire body is what is pinned. + +## Verification (C) + +No local command. Verified by the single tip CI run in 050. Local suites: NOT RUN. diff --git a/devlog/_plan/260908_a_stack_responses_compat/040_phase4.md b/devlog/_plan/260908_a_stack_responses_compat/040_phase4.md new file mode 100644 index 0000000000..b5a0f94be3 --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/040_phase4.md @@ -0,0 +1,121 @@ +# 040 — Phase 4: carry PR #3917 (routed agent_message conversion), stack tip + +Branch `codex/a-stack-l4-routed-agentmsg`, based on layer 3. This branch is the +stack tip and the only one with a pull request. +Carried commit `2430724e57e0950bde4b006c0175a2d5c70a0baf` by mashfromband. + +## Problem + +Codex writes every sub-agent reply into the rollout as an `agent_message` input +item, which is private to the ChatGPT Codex schema, so it is replayed in the input +of every later turn of that thread. The routed Responses destinations reported in +#3911 and #3907 reject the whole body with +`422 unknown item type "agent_message"`. 422 is a client error, so nothing fails +over and the thread stays broken. The plaintext conversion already existed but was +scoped to the OpenCode Go destination, and nothing in those reports is specific to +that destination. + +## MODIFY / RENAME map + +1. `src/adapters/opencode-go.ts` -> `src/adapters/routed-agent-messages.ts`. + `isOpenCodeGo` is deleted; its only production consumer is the call site below. + `normalizeOpenCodeGoAgentMessages` becomes `normalizeRoutedAgentMessages` with + the algorithm unchanged, including the fail-closed check that every content part + is `input_text`/`input_image`/`input_file`. + +2. `src/adapters/openai-responses.ts:1` and `:2366`. + + Before: + + ```ts + import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go"; + ... + if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody); + ``` + + After: + + ```ts + import { normalizeRoutedAgentMessages } from "./routed-agent-messages"; + ... + if (!forward) outBody = normalizeRoutedAgentMessages(outBody); + ``` + + `forward` is `provider.authMode === "forward"` (2356). All forward destinations + retain the existing behavior and keep the item unchanged. + +3. `tests/providers/opencode-go-agent-messages.test.ts` -> + `tests/adapters/routed-agent-messages.test.ts`, with the two Go-specific + expectations (54, 120-122) changed from `agent_message` to the converted + `message`/`user` shape for arbitrary routed URLs. + +4. `tests/responses/responses-opaque-blob-recovery.test.ts` — the four assertions + at 553, 583, 611 and 754. That fixture is `authMode: "key"` (163-164), so its + retried item is now converted. Opaque-blob recovery repairs an undecryptable part + into `[encrypted content omitted]`, which leaves the item fully plaintext; on a + routed retry it is converted too, which is what lets the retry be accepted. + Expected object becomes: + + ```ts + { + type: "message", + role: "user", + content: [ + { type: "input_text", text: 'Agent message {"author":"/root/child_task","recipient":"/root"}' }, + { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, + { type: "input_text", text: "[encrypted content omitted]" }, + ], + } + ``` + + The `authMode: "forward"` case in the same file is untouched. + +5. Both test-layout registries, because the test basename and directory change: + `scripts/test-layout/layout.json:917` and + `tests/fixtures/test-layout-expected.json:752` drop + `"opencode-go-agent-messages.test.ts": "providers"` and gain + `"routed-agent-messages.test.ts": "adapters"`. + `tests/test-layout-tooling.test.ts:250` compares the two tables exactly, so + missing either one fails. + +6. `docs-site/src/content/docs/reference/adapters.md` and + `docs-site/src/content/docs/reference/configuration/providers.md`, whose wording + describes the conversion as Go-specific, as in the carried pull request. The + carried text's universal "any routed destination" phrasing is narrowed to the + observed non-forward destinations rather than copied unchanged. + +## Preserved behavior + +Forward destinations, ciphertext and unknown part types (the `every` guard), +replay immutability and no-op reference identity, the identity prefix text, and +the session-header assertion at 55 of the moved test. + +## PR #3838 boundary + +#3838 stays open and independent. Its `normalizeOpenCodeGoAdditionalTools` +promotion, `customToolWireName` export and `statelessResponses` registry flag are +unrelated to this conversion. Its mixed-content policy drops ciphertext and unknown +parts whenever plaintext survives, which contradicts the fail-closed retention kept +here; it is not carried. + +## Verification (C) + +No local command. Verified by the single tip CI run in 050. Local suites: NOT RUN. + + +## Which destinations actually change (audit finding 5) + +`src/types/provider.ts:449` declares `authMode?: "key" | "forward" | "oauth" | "local"`. +Because the new gate is `!forward` and `forward` is `provider.authMode === "forward"` +(`openai-responses.ts:2356`), the conversion now applies to **key, oauth, local and +undefined** whenever this adapter is selected. Every forward destination is +unchanged, including noncanonical forward gateways; the built-in ChatGPT +destination is forward (`src/providers/registry.ts:1200-1204`), so its native items +stay intact. + +No repository-declared non-forward destination requires plaintext `agent_message` +preservation. Authentication mode alone cannot prove what an arbitrary custom +upstream accepts, so the carried claim that *every* routed destination rejects the +item is stated here as the observed pattern rather than a proven universal. +Regression coverage adds a non-forward mode beyond the carried key/forward +fixtures. diff --git a/devlog/_plan/260908_a_stack_responses_compat/050_phase5.md b/devlog/_plan/260908_a_stack_responses_compat/050_phase5.md new file mode 100644 index 0000000000..97b928d92c --- /dev/null +++ b/devlog/_plan/260908_a_stack_responses_compat/050_phase5.md @@ -0,0 +1,95 @@ +# 050 — Phase 5: publish, single CI run, merge, settle + +## Publication + +Push all four branches with `--no-verify`, in chain order: + +``` +git push --no-verify origin codex/a-stack-l1-muse-free +git push --no-verify origin codex/a-stack-l2-spark-lite +git push --no-verify origin codex/a-stack-l3-claude-strict +git push --no-verify origin codex/a-stack-l4-routed-agentmsg +``` + +Pushing l1-l3 starts no workflow: `.github/workflows/ci.yml` limits its `push` +trigger to `[main, preview, dev]`, and no pull request points at those refs. + +## The single pull request + +Open exactly one pull request: `codex/a-stack-l4-routed-agentmsg` -> `dev`. Its +head contains all four layers, so the one Cross-platform CI run it starts is +cumulative evidence for the whole stack. The description follows +`.github/PULL_REQUEST_TEMPLATE.md` (Summary, Verification, Checklist), states that +local suites were NOT RUN by maintainer instruction with CI as the verification +gate, and names every carried pull request and issue. + +Author preservation: each carried commit keeps its original author through a +`Co-authored-by` trailer that survives a squash, satisfying +`missing_coauthor_credit` in `.github/scripts/pr-carry-attribution.cjs`: + +- `Co-authored-by: MohamadSabree8 ` +- `Co-authored-by: R <53855466+cb8010d6@users.noreply.github.com>` +- `Co-authored-by: mashfromband ` + +## Merge gate + +Merge only when the tip's Cross-platform CI is green on the exact head SHA of the +pull request. Skipped, cancelled or queued jobs are not passing evidence, and a +green run on an earlier head does not certify a newer one. After merging, fetch +`origin/dev` and prove landing in the way the chosen merge method allows. + +The method decides the proof, and only one of the three preserves the tip SHA: + +- **Create a merge commit.** The tip SHA itself becomes reachable from `dev`, so + `git merge-base --is-ancestor origin/dev` exits 0 and is sufficient. +- **Squash and merge.** GitHub writes one new commit, so the tip SHA never becomes + an ancestor and that check would fail on a successful merge. +- **Rebase and merge.** GitHub replays the commits onto `dev` with new SHAs, so the + original tip SHA is likewise not an ancestor. + +For the two rewriting methods, identify the landed commit or commits on `dev` +first, then prove landing by content and attribution rather than by the original +SHA: + +- `git diff origin/dev -- ` is empty; +- `git merge-base --is-ancestor origin/dev` exits 0 for the landed + commit; +- the landed record carries all three `Co-authored-by` trailers. + +Record the method used together with its matching proof, rather than asserting +ancestry of the original tip generically. + +## Settlement + +Once the change is confirmed on `dev`: + +- PRs #3906, #3886 and #3917 — comment that the work landed on `dev` through the + stack tip, name the merge commit, and close them. Their authors are already + preserved in the trailers. +- Issues #3885 (Spark `adapter_eof`), #3922 (Claude tool strict) and #3911 + (routed `agent_message` 422) — close, since PRs target `dev` and GitHub only + auto-closes on merges into the default branch. +- PR #3838 stays open; its residual work is unrelated to this stack. + +## Verification (C) + +The tip CI run identified by its run id and head SHA, with every required job +reporting success, plus the merge-method-specific landing proof above against a +freshly fetched `origin/dev`. Local suites: NOT RUN by maintainer instruction. + + +## Merge readiness is broader than one workflow (audit finding 7) + +The single tip CI run is the verification evidence this session produces, but it is +not by itself the whole merge gate. `MAINTAINERS.md:57-69` also requires the +applicable required checks, resolution of outstanding maintainer objections, and +applicable security review. The `dev`-only maintainer-integration path still +records the decision and the exact-head evidence. Anything in that set that this +session cannot satisfy is reported rather than assumed. + +## Attribution is added, not inherited + +The three carried commits do **not** already contain `Co-authored-by` trailers in +their original messages. The trailers listed above are added when the commits are +carried onto the stack, and their presence is verified on the final squash-surviving +record before the children are closed. From 468b51ea872674e939eded69f666b8c48791df77 Mon Sep 17 00:00:00 2001 From: MohamadSabree8 Date: Mon, 7 Sep 2026 15:23:59 +0300 Subject: [PATCH 2/9] fix(responses): strip web_search fields for Muse Spark Contributor Free tiers The -free tiers ride the same Zen Responses wire with the same gateway contract, so a Codex web_search carrying search_content_types / indexed_web_access 400s for them exactly like the paid tiers. (cherry picked from commit 11c498b6c62ae9f7c5b0d25ca24fc6612f607a5c) Co-authored-by: MohamadSabree8 --- src/adapters/openai-responses.ts | 2 ++ .../muse-spark-web-search-compat.test.ts | 16 ++++++++++++++++ 2 files changed, 18 insertions(+) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 1b8c1b076e..67df8d9946 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2124,7 +2124,9 @@ export function stripOpenAiOnlyWebSearchFields(body: unknown): unknown { */ const MUSE_SPARK_WEB_SEARCH_STRICT_MODELS = new Set([ "muse-spark-1.3-contributor", + "muse-spark-1.3-contributor-free", "muse-spark-1.2-contributor", + "muse-spark-1.2-contributor-free", ]); const MUSE_SPARK_WEB_SEARCH_STRICT_RESPONSE_URLS = new Set([ diff --git a/tests/providers/muse-spark-web-search-compat.test.ts b/tests/providers/muse-spark-web-search-compat.test.ts index ed22b4e036..81e810872a 100644 --- a/tests/providers/muse-spark-web-search-compat.test.ts +++ b/tests/providers/muse-spark-web-search-compat.test.ts @@ -158,6 +158,22 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { expect(Object.hasOwn(nested, "indexed_web_access")).toBe(false); }); + /** + * The Contributor Free tiers ride the same Zen Responses wire with the same + * gateway contract, so a Codex `web_search` + refused-field body 400s for them + * exactly like the paid tiers. + */ + test("Contributor Free tiers get the same web_search sanitization", () => { + for (const modelId of ["muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free"]) { + const body = build(modelId, { tools: [webSearchTool()] }); + const tool = toolsOf(body)[0]!; + expect(tool.type).toBe("web_search"); + expect(tool.search_context_size).toBe("medium"); + expect(Object.hasOwn(tool, "search_content_types")).toBe(false); + expect(Object.hasOwn(tool, "indexed_web_access")).toBe(false); + } + }); + test("OpenCode Go applies the same Muse compatibility guard", () => { const body = buildForProvider(ZEN_GO_PROVIDER, "muse-spark-1.3-contributor", { tools: [webSearchTool()], From a82bfe152ba3f4165027b1e65b2933661fabcf88 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 8 Sep 2026 02:59:07 +0900 Subject: [PATCH 3/9] test(responses): cover nested and preview cases for Muse Spark Free tiers The carried fix covered a top-level web_search tool for the two Contributor Free ids. The sanitizer also walks input[].additional_tools.tools, and it must leave web_search_preview alone, so pin both for the free ids the way the paid ids are already pinned. Co-authored-by: MohamadSabree8 --- .../muse-spark-web-search-compat.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/tests/providers/muse-spark-web-search-compat.test.ts b/tests/providers/muse-spark-web-search-compat.test.ts index 81e810872a..062a42ebcd 100644 --- a/tests/providers/muse-spark-web-search-compat.test.ts +++ b/tests/providers/muse-spark-web-search-compat.test.ts @@ -174,6 +174,31 @@ describe("#2617/#3378 Muse Spark web_search compatibility", () => { } }); + test("a nested additional_tools declaration is sanitized for the Free tiers too", () => { + for (const modelId of ["muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free"]) { + const body = build(modelId, { + input: [{ type: "additional_tools", tools: [webSearchTool()] }], + }); + const item = (body.input as Array>)[0]!; + const nested = (item.tools as Array>)[0]!; + expect(nested.type).toBe("web_search"); + expect(Object.hasOwn(nested, "search_content_types")).toBe(false); + expect(Object.hasOwn(nested, "indexed_web_access")).toBe(false); + } + }); + + test("the Free tiers keep the field on web_search_preview, where the gateway accepts it", () => { + for (const modelId of ["muse-spark-1.2-contributor-free", "muse-spark-1.3-contributor-free"]) { + const body = build(modelId, { + tools: [{ ...webSearchTool(), type: "web_search_preview" }], + }); + const tool = toolsOf(body)[0]!; + expect(tool.type).toBe("web_search_preview"); + expect(tool.search_content_types).toEqual(["text", "image"]); + expect(tool.indexed_web_access).toBe(true); + } + }); + test("OpenCode Go applies the same Muse compatibility guard", () => { const body = buildForProvider(ZEN_GO_PROVIDER, "muse-spark-1.3-contributor", { tools: [webSearchTool()], From 2b5bd3882e3e8b6d538a260e03bf79aadaf37f71 Mon Sep 17 00:00:00 2001 From: R <53855466+cb8010d6@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:36:16 +0800 Subject: [PATCH 4/9] fix(responses): disable Lite transport for Spark The canonical backend starts a Spark SSE response with the Responses Lite header but closes it before a terminal event, which the adapter correctly surfaces as adapter_eof. The identical request completes without that header. Select the compatibility exception from the final wire model and remove both caller-provided and statically configured Lite headers only for gpt-5.3-codex-spark. Other canonical models retain the existing metadata path. Regression: cover Spark suppression and unaffected Sol forwarding at the adapter boundary. (cherry picked from commit 83c1d9b129b80d4f65a797fd61a2026deb8c8123) Co-authored-by: R <53855466+cb8010d6@users.noreply.github.com> --- src/adapters/openai-responses.ts | 7 +++++ .../codex-metadata-integrity.test.ts | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 67df8d9946..0e0cf1cd83 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -2503,6 +2503,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed.modelId, ); if (isCanonicalOpenAiForwardProvider(provider)) { + // Spark closes Responses Lite streams before a terminal completion. Select compatibility + // from the final wire model so aliases cannot leave the caller or a static header enabled. + if (isPlainObject(finalBody) && finalBody.model === "gpt-5.3-codex-spark") { + for (const name of Object.keys(headers)) { + if (name.toLowerCase() === CODEX_RESPONSES_LITE_HEADER) delete headers[name]; + } + } const routingHeaders = new Headers(headers); applyCodexRoutingHint(routingHeaders, finalBody); // Static headers may use mixed casing. Remove every stale spelling diff --git a/tests/codex-integration/codex-metadata-integrity.test.ts b/tests/codex-integration/codex-metadata-integrity.test.ts index 72fbcad6e9..c03d03eb8c 100644 --- a/tests/codex-integration/codex-metadata-integrity.test.ts +++ b/tests/codex-integration/codex-metadata-integrity.test.ts @@ -208,6 +208,35 @@ describe("Codex request transport metadata", () => { expect(new Headers(dropped.headers).get(hintHeader)).toBe("model=gpt-5.6-sol"); }); + test("canonical adapter drops Lite only for the Spark wire model", async () => { + const adapter = createResponsesPassthroughAdapter({ + adapter: "openai-responses", authMode: "forward", baseUrl: "https://chatgpt.com/backend-api/codex", + headers: { "X-OpenAI-Internal-Codex-Responses-Lite": "true" }, + }); + + for (const [model, incomingLite, expectedLite] of [ + ["gpt-5.3-codex-spark", "true", null], + ["gpt-5.3-codex-spark", undefined, null], + ["gpt-5.6-sol", "true", "true"], + ] as const) { + const parsed = minimalParsed(); + parsed.modelId = model; + parsed._rawBody = { model, input: [], stream: true }; + const incoming = new Headers(); + if (incomingLite !== undefined) incoming.set(liteHeader, incomingLite); + const request = await adapter.buildRequest(parsed, { + headers: incoming, + }); + expect(new Headers(request.headers).get(liteHeader)).toBe(expectedLite); + } + + const routed = minimalParsed(); + routed.modelId = "spark-alias"; + routed._rawBody = { model: "gpt-5.3-codex-spark", input: [], stream: true }; + const request = await adapter.buildRequest(routed, { headers: new Headers({ [liteHeader]: "true" }) }); + expect(new Headers(request.headers).get(liteHeader)).toBeNull(); + }); + test("noncanonical adapters neither forward caller Lite nor synthesize a routing hint", async () => { for (const authMode of ["forward", "key"] as const) { const adapter = createResponsesPassthroughAdapter({ From 4f3f067e5a0221dd6c47438e6013bf07ab24c48c Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 8 Sep 2026 03:01:56 +0900 Subject: [PATCH 5/9] docs(devlog): note the layer-1 line drift in the layer-2 anchors Layer 1 inserts two lines above the canonical-forward block, so the phase doc now states both the pinned-base line numbers and where the same code sits on this branch. --- .../020_phase2.md | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/devlog/_plan/260908_a_stack_responses_compat/020_phase2.md b/devlog/_plan/260908_a_stack_responses_compat/020_phase2.md index 2b518a82a9..13b4cc949c 100644 --- a/devlog/_plan/260908_a_stack_responses_compat/020_phase2.md +++ b/devlog/_plan/260908_a_stack_responses_compat/020_phase2.md @@ -12,8 +12,10 @@ request without that header completes. ## MODIFY map -`src/adapters/openai-responses.ts` — inside the canonical-forward block at -2503-2513, before the existing routing-hint work. +`src/adapters/openai-responses.ts` — inside the canonical-forward block, before +the existing routing-hint work. Line numbers here are against the pinned base +`942c02873` (block at 2503-2513); layer 1 adds two lines above it, so on this +branch the block sits at 2505-2515. After: @@ -30,11 +32,12 @@ After: applyCodexRoutingHint(routingHeaders, finalBody); ``` -`finalBody` is computed at 2494-2502 and serialized at 2523, so it is the actual -wire model. `parsed.modelId` can differ; the existing test at 187-188 pins that -distinction deliberately. Keying on `finalBody.model` therefore also covers aliases. -The loop removes every case spelling, which matters because static provider headers -merge in at 2315 and 2353 with arbitrary casing. +`finalBody` is computed at 2494-2502 and serialized at 2523 on the pinned base +(2496-2504 and 2525 on this branch), so it is the actual wire model. +`parsed.modelId` can differ; the existing test at 187-188 pins that distinction +deliberately. Keying on `finalBody.model` therefore also covers aliases. The loop +removes every case spelling, which matters because static provider headers merge +in at 2315 and 2353 on the pinned base (2317 and 2355 here) with arbitrary casing. ## Scope of the fix, and what it does not cover (audit finding 1) @@ -68,4 +71,3 @@ serialized model is Spark; `gpt-5.6-sol` keeps the header. Existing guards at 18 ## Verification (C) No local command. Verified by the single tip CI run in 050. Local suites: NOT RUN. - From b9418c9cef2639484643b71010896acbd5627908 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 8 Sep 2026 03:10:34 +0900 Subject: [PATCH 6/9] fix(claude): carry the source strict intent into translated Responses tools Anthropic enables strict tool use by setting strict: true, while the Responses API reads an omitted strict as permission to normalize the schema into strict mode. Translating a Claude Code tool without the field therefore made every optional input_schema parameter behave as required upstream, so a tool call that omitted one failed even though the client never asked for strict mode. Emit the field from the source tool: an explicit true or false is preserved, an omitted one becomes an explicit false, and a non-boolean value cannot opt the tool into strict mode. The input_schema is forwarded unchanged, hosted web_search leaves the translator before this branch, and native Anthropic passthrough never reaches it. The existing exact expectation on the translated Read tool gains the field. The new regression asserts the three cases on the serialized outbound body built by a real Responses adapter, because parsed._rawBody is the translator's own object and reading it back would prove nothing about the wire. Closes #3922. --- src/claude/inbound-content-options.ts | 6 ++ .../claude-integration/claude-inbound.test.ts | 98 +++++++++++++++++++ 2 files changed, 104 insertions(+) diff --git a/src/claude/inbound-content-options.ts b/src/claude/inbound-content-options.ts index f6b762aa83..0b59a93073 100644 --- a/src/claude/inbound-content-options.ts +++ b/src/claude/inbound-content-options.ts @@ -29,6 +29,12 @@ export function toolsToResponses(tools: unknown): Rec[] | undefined { name: raw.name, ...(typeof raw.description === "string" ? { description: raw.description } : {}), parameters: raw.input_schema as Record, + // Anthropic opts into strict tool use explicitly, while Responses reads an + // omitted strict as permission to normalize the schema into strict mode. That + // turns an optional input_schema parameter into a required one and breaks the + // call, so carry the source intent instead of the destination default. A + // non-boolean value is not a valid Anthropic opt-in and must not become one. + strict: typeof raw.strict === "boolean" ? raw.strict : false, }); continue; } diff --git a/tests/claude-integration/claude-inbound.test.ts b/tests/claude-integration/claude-inbound.test.ts index 7227bbf2f1..19c99db593 100644 --- a/tests/claude-integration/claude-inbound.test.ts +++ b/tests/claude-integration/claude-inbound.test.ts @@ -5,6 +5,9 @@ import { repoPath } from "../helpers/repo-root"; import { AnthropicRequestError, anthropicToResponsesBody, anthropicToResponsesTranslation, effortForThinkingBudget, extractOcxEffortDirective, resolveInboundModel } from "../../src/claude/inbound"; import { parseRequest } from "../../src/responses/parser"; import { responsesRequestSchema } from "../../src/responses/schema"; +import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; +import { withTestTranslatorBudget } from "../helpers/translator-budget"; +import type { OcxProviderConfig } from "../../src/types"; // Full Claude Code-shaped request: system array, tool cycle, image, thinking, options. function claudeCodeRequest(): Record { @@ -80,6 +83,7 @@ describe("claude inbound translation", () => { expect(tools[0]).toEqual({ type: "function", name: "Read", description: "Read a file", parameters: { type: "object", properties: { file_path: { type: "string" } }, required: ["file_path"] }, + strict: false, }); expect(tools[1]).toEqual({ type: "web_search" }); @@ -644,3 +648,97 @@ test("inbound leaves preserve the tool_choice error identity and avoid facade ba .not.toMatch(/from\s+["']\.\/inbound["']/); } }); + + +/** + * #3922: Anthropic enables strict tool use by setting strict: true, while Responses + * reads an omitted strict as permission to normalize the schema into strict mode. + * Translating without the field therefore made every optional input_schema parameter + * behave as required upstream, so a call that omitted one failed. The translated tool + * now carries the source intent, and the value has to survive to the serialized wire + * body rather than only to the translator's return. + */ +describe("#3922 translated tools carry the source strict intent", () => { + const schema = { + type: "object", + properties: { + prompt: { type: "string" }, + isolation: { type: "string", enum: ["worktree", "remote"] }, + options: { type: "object", properties: { enabled: { type: "boolean" } } }, + }, + required: ["prompt"], + additionalProperties: false, + }; + const request = (tool: Record) => ({ + model: "openai/gpt-5.4", + max_tokens: 32, + messages: [{ role: "user", content: "Run a local agent." }], + tools: [tool], + }); + const agent = (extra: Record = {}) => ({ + name: "Agent", description: "Run an agent", input_schema: schema, ...extra, + }); + const translatedTool = (tool: Record) => + (anthropicToResponsesBody(request(tool)).tools as Record[])[0]!; + + test("an omitted strict becomes an explicit false instead of an implicit strict request", () => { + expect(translatedTool(agent()).strict).toBe(false); + }); + + test("an explicit strict survives in both directions", () => { + expect(translatedTool(agent({ strict: true })).strict).toBe(true); + expect(translatedTool(agent({ strict: false })).strict).toBe(false); + }); + + test("a non-boolean strict cannot opt the tool into strict mode", () => { + expect(translatedTool(agent({ strict: "true" })).strict).toBe(false); + }); + + test("the source input_schema is forwarded unchanged", () => { + for (const extra of [{}, { strict: true }, { strict: false }]) { + const tool = agent(extra); + // Compare against a detached copy: the expected value must not be the very + // object under test, or an in-place mutation would move both sides together. + const expectedSchema = structuredClone(tool.input_schema); + expect(translatedTool(tool).parameters).toEqual(expectedSchema); + expect(tool.input_schema).toEqual(expectedSchema); + } + }); + + test("hosted web_search gains no strict field", () => { + const body = anthropicToResponsesBody(request({ type: "web_search_20250305", name: "web_search" })); + expect((body.tools as Record[])[0]).toEqual({ type: "web_search" }); + }); + + test("strict intent and schema survive into the serialized Responses body", async () => { + // parsed._rawBody is the translator's own object, so reading it back proves + // nothing about the wire. Build the actual outbound request instead. + const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter({ + adapter: "openai-responses", + authMode: "key", + baseUrl: "https://api.openai.com/v1", + apiKey: "test-key", + } as OcxProviderConfig)); + + for (const [tool, expected] of [ + [agent(), false], + [agent({ strict: true }), true], + [agent({ strict: false }), false], + ] as const) { + const expectedSchema = structuredClone(tool.input_schema); + const parsed = parseRequest({ ...anthropicToResponsesBody(request(tool)), model: "gpt-5.4" }); + expect(parsed.context.tools?.[0]?.strict).toBe(expected); + + const outbound = await adapter.buildRequest(parsed); + try { + const wire = JSON.parse(String(outbound.body)) as { tools: { strict?: boolean; parameters?: unknown }[] }; + expect(wire.tools).toHaveLength(1); + expect(wire.tools[0]?.strict).toBe(expected); + expect(wire.tools[0]?.parameters).toEqual(expectedSchema); + expect(tool.input_schema).toEqual(expectedSchema); + } finally { + outbound.releaseBodyObservation?.(); + } + } + }); +}); From 8ec0884dfe84c159fdec75bf6fdea6ca1a9c44aa Mon Sep 17 00:00:00 2001 From: mashfromband Date: Mon, 7 Sep 2026 23:24:48 +0900 Subject: [PATCH 7/9] fix(adapters): convert Codex agent_message for every routed Responses destination `agent_message` is Codex's private multi-agent input item and exists only in the ChatGPT Codex backend's schema. Codex writes every sub-agent reply into the rollout, so it is replayed in the `input` of every later turn of that thread. A routed Responses destination answers the whole body with `422 unknown item type "agent_message"`, and 422 is a client error nothing fails over, so the thread stays broken until the history is dropped. The plaintext conversion already existed but was scoped to the OpenCode Go destination. Nothing about the rejection is destination-specific, so the conversion now applies to every destination with `authMode` other than "forward". Forward destinations keep the item unchanged, and genuine ciphertext and unknown part types keep their existing fail-closed path; the encrypted v2 task surface still owns those through `unreadable_encrypted_agent_task` and the opt-in recovery route. `isOpenCodeGo` existed only to scope this call and is removed with it. The helper and its tests move to destination-neutral names. Opaque-blob recovery repairs an undecryptable part into an omission marker, which leaves the item entirely plaintext; on a routed retry it is now converted too, which is what lets that retry be accepted at all. Fixes #3911 (cherry picked from commit 2430724e57e0950bde4b006c0175a2d5c70a0baf) Co-authored-by: mashfromband --- .../src/content/docs/reference/adapters.md | 14 +++-- .../docs/reference/configuration/providers.md | 18 +++--- scripts/test-layout/layout.json | 2 +- src/adapters/openai-responses.ts | 4 +- ...pencode-go.ts => routed-agent-messages.ts} | 22 +++---- .../routed-agent-messages.test.ts} | 53 ++++++++++------- tests/fixtures/test-layout-expected.json | 2 +- .../responses-opaque-blob-recovery.test.ts | 57 +++++++------------ 8 files changed, 90 insertions(+), 82 deletions(-) rename src/adapters/{opencode-go.ts => routed-agent-messages.ts} (61%) rename tests/{providers/opencode-go-agent-messages.test.ts => adapters/routed-agent-messages.test.ts} (76%) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index b1d6029ca9..9c4854a4f4 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -133,11 +133,15 @@ 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. -For OpenCode Go at `https://opencode.ai/zen/go/v1`, requests with `authMode` other -than `"forward"` convert plaintext Codex `agent_message` items into public user messages, preserving content parts and readable author/recipient -metadata. This conversion leaves encrypted or unknown content unchanged and does not apply -to other destinations. Providers using `authMode: "forward"` retain these items unchanged. -See [Go agent messages](/reference/configuration/providers/#opencode-go-session-and-agent-messages) +Requests with `authMode` other than `"forward"` convert plaintext Codex `agent_message` +items into public user messages, preserving content parts and readable author/recipient +metadata. `agent_message` is private to the ChatGPT Codex backend, so a routed destination +that receives one rejects 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. +See [agent messages](/reference/configuration/providers/#routed-agent-messages) for the separate opt-in encrypted-task recovery behavior. The canonical ChatGPT Codex forward destination also normalizes two public Responses shapes that diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index a6ecac02ae..c5ff621180 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -936,13 +936,15 @@ their previous behavior. See the [ordering migration note](/guides/model-ordering/#migration-note-native-ids-in-existing-orders). `modelDisplayNames` on a provider controls readable labels without changing wire ids. -## OpenCode Go session and agent messages - -With the [`openai-responses` adapter](/reference/adapters/#openai-responses) and -base URL `https://opencode.ai/zen/go/v1`, plaintext Codex `agent_message` items -become user messages when `authMode` is not `"forward"` (for example, `"key"`). -Providers using `authMode: "forward"` retain these items unchanged. This conversion is scoped to that destination, including -renamed provider entries; other Responses destinations keep their input unchanged. +## 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"` +(for example, `"key"`). Providers using `authMode: "forward"` retain these items unchanged. +`agent_message` is private to the ChatGPT Codex backend, so any routed destination that +receives one answers 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. 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). @@ -961,6 +963,6 @@ current tail message (ignoring trailing `compaction_trigger` or `additional_tool It does not batch-recover unseen historical messages; those remain unchanged. A cache miss or expiry does not extend the history-recovery contract. -Sender and recipient on Go Responses are context for the receiving model, not a new +Sender and recipient on routed Responses are context for the receiving model, not a new machine-readable routing protocol. Tool routing continues to use the existing collaboration contracts. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index e1b29d490b..444963711c 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -914,7 +914,6 @@ "openai-responses-passthrough.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", - "opencode-go-agent-messages.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", "opencode-go-luna-wire.test.ts": "providers", @@ -1073,6 +1072,7 @@ "retry-after-429.test.ts": "server", "route-decision-trace.test.ts": "server", "route-explainability.test.ts": "cli", + "routed-agent-messages.test.ts": "adapters", "router-combo-failover-classification.test.ts": "routing", "router-discarded-baseurl-warning.test.ts": "routing", "router-template-baseurl.test.ts": "routing", diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 0e0cf1cd83..60c679489d 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -1,4 +1,4 @@ -import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "./opencode-go"; +import { normalizeRoutedAgentMessages } from "./routed-agent-messages"; 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"; @@ -2365,7 +2365,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): parsed._rawBody, forward || parsed._previousResponseInputExpanded === true, ); - if (!forward && isOpenCodeGo(provider.baseUrl)) outBody = normalizeOpenCodeGoAgentMessages(outBody); + if (!forward) outBody = normalizeRoutedAgentMessages(outBody); 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/opencode-go.ts b/src/adapters/routed-agent-messages.ts similarity index 61% rename from src/adapters/opencode-go.ts rename to src/adapters/routed-agent-messages.ts index 94055a292a..7e4b8ecf7b 100644 --- a/src/adapters/opencode-go.ts +++ b/src/adapters/routed-agent-messages.ts @@ -1,13 +1,15 @@ -/** Match the Go destination, including user-renamed provider entries. */ -export function isOpenCodeGo(baseUrl: string): boolean { - try { - const url = new URL(baseUrl); - return url.origin === "https://opencode.ai" && url.pathname.replace(/\/+$/, "") === "/zen/go/v1"; - } catch { return false; } -} - -/** Public Responses rejects Codex's private agent_message variant, even with plaintext content. */ -export function normalizeOpenCodeGoAgentMessages(body: unknown): unknown { +/** + * `agent_message` is Codex's private multi-agent input item: it exists only in the ChatGPT + * Codex backend's schema. Codex replays every sub-agent reply in the history it sends, so + * once a thread has used sub-agents, a routed Responses destination answers the whole body + * with `422 unknown item type "agent_message"` and every later turn of that thread fails the + * same way. Rewrite the item as the public user message it already is. + * + * Genuine ciphertext and unknown part types keep their existing fail-closed path: the + * 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 { if (!body || typeof body !== "object" || Array.isArray(body)) return body; const record = body as Record; if (!Array.isArray(record.input)) return body; diff --git a/tests/providers/opencode-go-agent-messages.test.ts b/tests/adapters/routed-agent-messages.test.ts similarity index 76% rename from tests/providers/opencode-go-agent-messages.test.ts rename to tests/adapters/routed-agent-messages.test.ts index f79f529a5e..5e94b04940 100644 --- a/tests/providers/opencode-go-agent-messages.test.ts +++ b/tests/adapters/routed-agent-messages.test.ts @@ -1,6 +1,6 @@ import { expect, test } from "bun:test"; import { createResponsesPassthroughAdapter } from "../../src/adapters/openai-responses"; -import { isOpenCodeGo, normalizeOpenCodeGoAgentMessages } from "../../src/adapters/opencode-go"; +import { normalizeRoutedAgentMessages } from "../../src/adapters/routed-agent-messages"; import { parseRequest } from "../../src/responses/parser"; import { routeModel } from "../../src/router"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; @@ -26,17 +26,25 @@ test("Responses converts plaintext task and peer messages without mutating repla test("ciphertext and unknown content are never reclassified as plaintext", () => { for (const part of [{ type: "encrypted_content", encrypted_content: "opaque" }, { type: "future_type", text: "opaque" }]) { const raw = { input: [{ type: "agent_message", content: [part] }] }; - expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); + expect(normalizeRoutedAgentMessages(raw)).toBe(raw); } }); 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] }] }; - const result = normalizeOpenCodeGoAgentMessages(raw) as typeof raw; + const result = normalizeRoutedAgentMessages(raw) as typeof raw; expect(result.input[0]!.content[1]).toBe(image); }); +test("a body with no agent messages keeps its exact reference", () => { + const raw = { input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hi" }] }] }; + expect(normalizeRoutedAgentMessages(raw)).toBe(raw); + for (const shape of [null, "a string", [1, 2], { input: "not an array" }]) { + expect(normalizeRoutedAgentMessages(shape)).toBe(shape); + } +}); + test("native forward keeps agent_message and auth/session headers unchanged", async () => { const budget = createTranslatorBudget(); const provider = { ...base, baseUrl: "https://chatgpt.com/backend-api/codex", authMode: "forward" as const }; @@ -48,11 +56,20 @@ test("native forward keeps agent_message and auth/session headers unchanged", as budget.dispose(); }); -test("other destinations do not get Go normalization or session identity", async () => { +test("an arbitrary routed destination converts too, and gains no session identity", async () => { + // The 422 this guards against is not destination-specific: `agent_message` is private to + // the ChatGPT Codex backend, so any routed Responses target rejects the whole body. const budget = createTranslatorBudget(); - const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://example.test/v1" }).buildRequest(parseRequest(body()), { headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget }); - expect(JSON.parse(request.body as string).input[0].type).toBe("agent_message"); + const raw = body(); + const original = structuredClone(raw); + const parsed = parseRequest(raw); + const request = await createResponsesPassthroughAdapter({ ...base, baseUrl: "https://example.test/v1" }).buildRequest(parsed, { headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content); expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); + expect(parsed._rawBody).toBe(raw); + expect(raw).toEqual(original); budget.dispose(); }); @@ -108,18 +125,21 @@ test.each([ "http://opencode.ai/zen/go/v1", "https://opencode.ai/zen/v1", "https://opencode.ai/zen/go/v10", -])("Go-like destination %s preserves private agent messages", async baseUrl => { +])("Go-like destination %s converts without inheriting any Go-specific handling", async baseUrl => { + // A spoofed or malformed Go URL is just another routed destination now. It gets the same + // conversion every routed target gets, and still no Go session identity. const raw = body(); const original = structuredClone(raw); const parsed = parseRequest(raw); const budget = createTranslatorBudget(); try { const request = await createResponsesPassthroughAdapter({ ...base, baseUrl }).buildRequest(parsed, { - headers: new Headers(), translatorBudget: budget, - }); - expect(JSON.parse(request.body as string).input[0]).toMatchObject({ - type: "agent_message", content: original.input[0]!.content, + headers: new Headers({ "session-id": "child-id" }), translatorBudget: budget, }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content); + expect(new Headers(request.headers).get("x-opencode-session")).toBeNull(); expect(parsed._rawBody).toBe(raw); expect(raw).toEqual(original); } finally { @@ -127,12 +147,7 @@ test.each([ } }); -test.each(["not a URL", "https://", "/zen/go/v1"])( - "malformed destination %s is not classified as Go", - baseUrl => expect(isOpenCodeGo(baseUrl)).toBe(false), -); - -test("Go conversion preserves file payloads beside text without mutating raw replay", async () => { +test("conversion preserves file payloads beside text without mutating raw replay", async () => { const file = { type: "input_file", filename: "assignment.txt", file_data: "data:text/plain;base64,SGVsbG8=" }; const message = body().input[0]!; const raw = { ...body(), input: [{ ...message, content: [...message.content, file] }] }; @@ -161,10 +176,10 @@ for (const { name, content } of [ { name: "text mixed with ciphertext", content: [ { type: "input_text", text: "Routing header" }, { type: "encrypted_content", encrypted_content: "opaque" }, ] }, -]) test(`Go preserves ${name} without partially converting it`, async () => { +]) test(`routed destinations preserve ${name} without partially converting it`, async () => { const raw = { ...body(), input: [{ ...body().input[0]!, content }] }; const original = structuredClone(raw); - expect(normalizeOpenCodeGoAgentMessages(raw)).toBe(raw); + expect(normalizeRoutedAgentMessages(raw)).toBe(raw); const parsed = parseRequest(raw); const budget = createTranslatorBudget(); try { diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 02c2062062..2aafc9c654 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -749,7 +749,6 @@ "openai-responses-passthrough.test.ts": "responses", "opencode-cli.test.ts": "providers", "opencode-free-provider.test.ts": "providers", - "opencode-go-agent-messages.test.ts": "providers", "opencode-go-deepseek.test.ts": "providers", "opencode-go-grok46-responses.test.ts": "providers", "opencode-go-luna-wire.test.ts": "providers", @@ -907,6 +906,7 @@ "retry-after-429.test.ts": "server", "route-decision-trace.test.ts": "server", "route-explainability.test.ts": "cli", + "routed-agent-messages.test.ts": "adapters", "router-combo-failover-classification.test.ts": "routing", "router-discarded-baseurl-warning.test.ts": "routing", "router-template-baseurl.test.ts": "routing", diff --git a/tests/responses/responses-opaque-blob-recovery.test.ts b/tests/responses/responses-opaque-blob-recovery.test.ts index cf26431381..87eb74f923 100644 --- a/tests/responses/responses-opaque-blob-recovery.test.ts +++ b/tests/responses/responses-opaque-blob-recovery.test.ts @@ -154,6 +154,23 @@ function serializedOutboundWithEncryptedAgentMessage(): string { return JSON.stringify({ model: "model-a", input: agentMessageReplayInput() }); } +/** + * What a routed destination receives on the retry: recovery has replaced the undecryptable + * part with an omission marker, which leaves the item entirely plaintext, so the adapter + * converts it into the public user message a routed Responses schema can accept. + */ +function recoveredAgentMessage(): Record { + return { + type: "message", + role: "user", + content: [ + { type: "input_text", text: 'Agent message {"author":"/root/child_task","recipient":"/root"}' }, + { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, + { type: "input_text", text: "[encrypted content omitted]" }, + ], + }; +} + function config(): OcxConfig { return { defaultProvider: "first", @@ -550,15 +567,7 @@ describe("opaque blob recovery through /v1/responses", () => { expect(outbound).toHaveLength(4); const retriedInput = outbound.at(3)?.input as Array> | undefined; - expect(retriedInput?.at(0)).toEqual({ - type: "agent_message", - author: "/root/child_task", - recipient: "/root", - content: [ - { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, - { type: "input_text", text: "[encrypted content omitted]" }, - ], - }); + expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); expect(retriedInput?.at(1)).toEqual(agentMessageReplayInput().at(1)); expect(logCtx.activeAttempt?.recoveryKinds).toEqual(["transient-5xx", "opaque-blob-rejection"]); }); @@ -580,15 +589,7 @@ describe("opaque blob recovery through /v1/responses", () => { expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); expect(outbound).toHaveLength(2); const retriedInput = outbound.at(1)?.input as Array> | undefined; - expect(retriedInput?.at(0)).toEqual({ - type: "agent_message", - author: "/root/child_task", - recipient: "/root", - content: [ - { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, - { type: "input_text", text: "[encrypted content omitted]" }, - ], - }); + expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); }); test("recovers a zero-output error-event decrypt failure before client relay", async () => { @@ -608,15 +609,7 @@ describe("opaque blob recovery through /v1/responses", () => { expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); expect(outbound).toHaveLength(2); const retriedInput = outbound.at(1)?.input as Array> | undefined; - expect(retriedInput?.at(0)).toEqual({ - type: "agent_message", - author: "/root/child_task", - recipient: "/root", - content: [ - { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, - { type: "input_text", text: "[encrypted content omitted]" }, - ], - }); + expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); }); for (const streamMode of ["legacy-tee", "eager-relay"] as const) { @@ -751,15 +744,7 @@ describe("opaque blob recovery through /v1/responses", () => { expect(body).not.toContain(FUNCTION_OUTPUT_DECRYPT_MESSAGE); expect(outbound).toHaveLength(2); const retriedInput = outbound.at(1)?.input as Array> | undefined; - expect(retriedInput?.at(0)).toEqual({ - type: "agent_message", - author: "/root/child_task", - recipient: "/root", - content: [ - { type: "input_text", text: "Message Type: MESSAGE\nTask name: /root\nSender: /root/child_task\nPayload:" }, - { type: "input_text", text: "[encrypted content omitted]" }, - ], - }); + expect(retriedInput?.at(0)).toEqual(recoveredAgentMessage()); }); test("absent Content-Type decrypt stream does not recover a non-stream request", async () => { From 173562f0044a231e34e135110b31308fca703c2d Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 8 Sep 2026 03:14:21 +0900 Subject: [PATCH 8/9] test(adapters): pin an OAuth destination and narrow the routed-422 wording The carried conversion is gated on authMode rather than on the destination URL, but its regression only exercised key and forward. The reported xAI/Grok failure is an OAuth pool destination, so pin one: a future narrowing of the gate back toward key-only would otherwise pass unnoticed. Also narrow the two reference pages. The conversion is justified by the destinations that actually reported the 422; authMode is an authentication setting, so it cannot establish what every custom upstream accepts. Co-authored-by: mashfromband --- .../src/content/docs/reference/adapters.md | 4 ++-- .../docs/reference/configuration/providers.md | 4 ++-- tests/adapters/routed-agent-messages.test.ts | 20 +++++++++++++++++++ 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/docs-site/src/content/docs/reference/adapters.md b/docs-site/src/content/docs/reference/adapters.md index 9c4854a4f4..bc3bab2c82 100644 --- a/docs-site/src/content/docs/reference/adapters.md +++ b/docs-site/src/content/docs/reference/adapters.md @@ -135,8 +135,8 @@ 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 -metadata. `agent_message` is private to the ChatGPT Codex backend, so a routed destination -that receives one rejects the entire body with +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 diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index c5ff621180..5514ad7694 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -941,8 +941,8 @@ their previous behavior. See the With the [`openai-responses` adapter](/reference/adapters/#openai-responses), plaintext Codex `agent_message` items 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, so any routed destination that -receives one answers the whole request with +`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. diff --git a/tests/adapters/routed-agent-messages.test.ts b/tests/adapters/routed-agent-messages.test.ts index 5e94b04940..cbbb7151d2 100644 --- a/tests/adapters/routed-agent-messages.test.ts +++ b/tests/adapters/routed-agent-messages.test.ts @@ -73,6 +73,26 @@ test("an arbitrary routed destination converts too, and gains no session identit budget.dispose(); }); +test("an OAuth destination converts as well, since the gate reads authMode rather than the URL", async () => { + // The reported xAI/Grok failure (#3907) is an OAuth pool destination, not a key-auth + // one, and the gate is `authMode !== "forward"`. Pin a mode other than key/forward so + // a future narrowing of the gate back toward key-only cannot pass unnoticed. + const budget = createTranslatorBudget(); + const raw = body(); + const original = structuredClone(raw); + try { + const request = await createResponsesPassthroughAdapter({ + ...base, baseUrl: "https://api.x.ai/v1", authMode: "oauth" as const, + }).buildRequest(parseRequest(raw), { headers: new Headers(), translatorBudget: budget }); + const sent = JSON.parse(request.body as string); + expect(sent.input[0]).toMatchObject({ type: "message", role: "user" }); + expect(sent.input[0].content.slice(1)).toEqual(original.input[0]!.content); + expect(raw).toEqual(original); + } finally { + budget.dispose(); + } +}); + test("canonical Go forward auth preserves private agent messages and the raw replay body", async () => { const raw = body(); const original = structuredClone(raw); From 5f19e158926a20f29fcdc1d65e8fa5942aef2e18 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 8 Sep 2026 03:16:34 +0900 Subject: [PATCH 9/9] docs(devlog): drop a trailing blank line in the layer-1 phase doc --- devlog/_plan/260908_a_stack_responses_compat/010_phase1.md | 1 - 1 file changed, 1 deletion(-) diff --git a/devlog/_plan/260908_a_stack_responses_compat/010_phase1.md b/devlog/_plan/260908_a_stack_responses_compat/010_phase1.md index 12d8d9cc2f..85f9e11d3d 100644 --- a/devlog/_plan/260908_a_stack_responses_compat/010_phase1.md +++ b/devlog/_plan/260908_a_stack_responses_compat/010_phase1.md @@ -60,4 +60,3 @@ registry is out of scope, matching the carried pull request. No local command. The layer is verified by the single tip CI run described in 050. Local suites: NOT RUN by instruction. -