From 6db04ea74bef1d649a47ac4b1b89aca20f8da2ce Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 00:22:01 +0900 Subject: [PATCH 1/2] docs(devlog): seed the L1 dispatch packet --- .../260911_l1_responses_core/000_packet.md | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) create mode 100644 devlog/_plan/260911_l1_responses_core/000_packet.md diff --git a/devlog/_plan/260911_l1_responses_core/000_packet.md b/devlog/_plan/260911_l1_responses_core/000_packet.md new file mode 100644 index 0000000000..fec95cdedb --- /dev/null +++ b/devlog/_plan/260911_l1_responses_core/000_packet.md @@ -0,0 +1,92 @@ +# Dispatch packet — L1 (revision 5) + +Round unit: `devlog/_plan/260911_lane_dispatch_round` on `dev`. Base freeze: `origin/dev` `6d3ad12e3` (2.51.0). +Five audit rounds shaped this packet. The last one was a seven-lane feasibility check that asked whether each stack is implementable inside its owned paths; three lanes came back with gaps, and the fixes are folded here. `010_lane_partition.md` is the authoritative ownership list; `130_wp4_feasibility.md` records why each path was granted. + + +## Shared frame + +**Repository.** Your worktree is named in your packet, already checked out on your lane branch, cut +from `origin/dev` `6d3ad12e3` (2.51.0). Work only there. Do not add, move, or remove a worktree. + +**Loop.** Run `$codexclaw:cxc-loop` as HOTL for your lane: one work-phase per issue, in order. Your +goal ends when your last PR is green and reported, not when the code looks right. + +**Subagents.** Unlimited `xai/grok-4.6` subagents, read-only, spawned with `spawn_agent` +(`model: "xai/grok-4.6"`). Use them to reproduce, to read the call sites you are about to change, to +find a second caller of a helper you are touching, and to review your staged diff adversarially +before you push. A finding enters your work only with an exact `path:line` anchor. Subagents never +write, commit, push, or call a mutating `gh`. Treat a `fail` verdict the way this round did: fold it +in and re-audit. This packet is at revision 3 because two audit rounds rejected revisions 1 and 2. + +**MUST NOT.** + +- No local product suite: no `bun test`, no `bun run test`, no `bun run test:changed`, no + `bun run typecheck`, no `bun run build:gui`, no `bun install`. Report them as `NOT RUN`. +- No merge, no release, no force-push to a shared branch, no direct push to `dev`. +- No path outside your owned list, including paths a carried PR happens to touch. Dropping a hunk + from a carried PR is expected; report what you dropped. +- No locale key in `gui/src/i18n/*`. If you need one, stop and report. +- No security write-up in `devlog/`; scratch space only, per `AGENTS.md`. + +**MUST.** + +- Prefix every mutating git command with `git -c core.hooksPath=/dev/null`. This repository's hooks + can start a GUI install, typecheck, and build, which the no-local-suite rule forbids. +- Push with `--no-verify`. +- Write the focused regression test `AGENTS.md` requires for a behaviour change, in the domain + directory beside the existing tests for that subsystem, and register it in both + `scripts/test-layout/layout.json` `explicit` and `tests/fixtures/test-layout-expected.json`. You + will not run it; hosted CI will. Those two maps are append-only and other lanes are adding to them + too; the orchestrator resolves the conflicts at merge, so do not skip the entry. +- Fill every section of `.github/PULL_REQUEST_TEMPLATE.md` and put `Closes #` in the body. In + **Verification**, state that the local suite, typecheck, and build were `NOT RUN` by operator + instruction and that hosted CI on the exact pushed head is the proof. +- When you carry another author's PR, add a `Co-authored-by` trailer in a branch commit. Resolve the + address with `gh api users/ --jq '.id'` and use `+@users.noreply.github.com`. +- Keep a devlog unit under `devlog/_plan/260911_l_/`. + +**Stacking.** First PR targets `dev`; the second targets the first PR's head branch, the third the +second. Retarget a child to `dev` after its parent lands. No native GitHub stacks. + +**Decisions already made for you.** Both audit rounds found items where the issue left a real choice +open. Those calls are recorded in your packet in bold. Implement the recorded decision; if you think +it is wrong, report the reason and stop. + +**Stop conditions.** Stop and report when the fix needs a path you do not own, when it needs a policy +no issue has fixed, when a locale key is unavoidable, or when hosted CI fails for a reason outside +your diff. + +**Report format.** Per PR: number, exact head SHA, CI run id and conclusion, the issue it closes, the +co-authors credited, the hunks you dropped from a carried PR, and any decision you made. Say +`NOT RUN` for local checks. + +**Decision boundary.** You do not merge, do not close another author's PR, and do not rank your lane +against another. When your last PR is green, report and stop. + +## L1 — Responses pipeline and tool contract + +Worktree `~/.codex/worktrees/260911-l1/opencodex`, branch `codex/260911-l1-responses-core`. + +Owned: `src/server/responses/core.ts`, `src/server/responses/compact.ts`, +`src/server/responses/policy-fallback.ts`, `src/server/chat-completions.ts`, +`src/server/claude-messages.ts`, `src/server/request-log-conversation.ts`, +`src/server/responses-undeclared-tool-guard.ts`, `src/providers/opencode-go-transport.ts`, +`src/types/tools.ts`, and `docs-site/src/content/docs/reference/configuration/providers.md` (the page +#4184 already edits). You do not own `codex-ws-exchange.ts` or `codex-ws-wire.ts` (L6) or +`codex-auth-error.ts` (L3). + +1. **#4172 — OpenCode Go sessionless requests omit `x-opencode-session`.** Expected behaviour is + fixed by the issue: every request to the canonical Go destination carries the header; identity + keeps its stable per-conversation value; no identity gets an isolated per-request value rather + than none and rather than one shared global id; an explicit header still wins. Carry PR #4184 by + `chilung-cgu` (open, not a draft, `CHANGES_REQUESTED`); read the review first. Most urgent item in + the round: upstream ended the grace period on 09/06 and now errors without the header. +2. **#4176 — a routed provider prefixes a bare Codex tool with `default.`.** **Decision: normalize + the invented prefix back at the undeclared-tool guard** — the #4181 shape, which is what the issue + states. #4181 by `chilung-cgu` is open, not a draft, `CHANGES_REQUESTED`; #4171 by `rrmlima` is an + open **draft** at `CHANGES_REQUESTED` and its unified-exec rewrite is out of round scope. Credit + `rrmlima` only if you reuse code from #4171. + +`core.ts` is contended by four open PRs. Keep the diff minimal; do not reformat around it. + From 9e75542ffd7052324bf6d296958e5ff50c1705ac Mon Sep 17 00:00:00 2001 From: JUN Date: Fri, 11 Sep 2026 06:39:23 +0900 Subject: [PATCH 2/2] fix(opencode-go): give sessionless requests an isolated session lane OpenCode Go began rejecting requests without x-opencode-session on 2026-09-06. Requests carrying conversation identity already got a stable lane from #3945; requests with none left the transport unheaderised and hit 400. The lane is now allocated once per admitted Request and carried across every boundary that rebuilds one, including the policy fallback retry, so a hop to the next candidate does not split one turn into two Go conversations. Co-authored-by: chilung-cgu <215337896+chilung-cgu@users.noreply.github.com> --- .../docs/reference/configuration/providers.md | 21 ++++ src/providers/opencode-go-transport.ts | 10 +- src/server/chat-completions.ts | 9 +- src/server/claude-messages.ts | 8 +- src/server/request-log-conversation.ts | 42 ++++++- src/server/responses/compact.ts | 3 +- src/server/responses/core.ts | 6 +- src/server/responses/policy-fallback.ts | 8 +- .../opencode-go-session-header.test.ts | 106 +++++++++++++++++- 9 files changed, 199 insertions(+), 14 deletions(-) diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 5f3fc99649..d40de409d2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -949,6 +949,27 @@ receive the default only when the setting is absent; custom renamed entries keep value and do not acquire this default by destination matching. Chat model routes keep their existing protocol. The stateless flag does not force Responses streaming into JSON. +## OpenCode Go session affinity + +Every request opencodex routes to an OpenCode Go destination carries an `x-opencode-session` header. +The upstream began rejecting requests without it on 2026-09-06, so the header is not an optimization. + +The value depends on what the request already knows about itself: + +- An operator-configured `x-opencode-session` on the provider is preserved exactly as written. +- A request that carries conversation identity — Codex thread headers, a Claude `metadata.user_id`, + a `session_id`, or an inbound `x-opencode-session` — is hashed into a stable per-conversation value, + so every turn of one conversation reaches Go under the same session. +- A request with no identity at all, such as a model-availability probe or a first request before any + conversation metadata exists, receives a value allocated once for that request. It is isolated from + other requests rather than shared, and it survives the places opencodex rebuilds the request: the + translation to the internal Responses shape, compaction, combo children, and the policy-fallback + retry that hands the turn to the next candidate. + +Non-Go destinations are unaffected: opencodex never derives or adds the session header for them. A +header an operator configured on such a provider is still sent, because opencodex leaves that +configuration alone. + ## OpenCode Go reasoning efforts Go catalog rows preserve their configured reasoning efforts exactly, including during diff --git a/src/providers/opencode-go-transport.ts b/src/providers/opencode-go-transport.ts index a863d24f37..e24d1d9cbd 100644 --- a/src/providers/opencode-go-transport.ts +++ b/src/providers/opencode-go-transport.ts @@ -22,7 +22,15 @@ export function deriveOpenCodeGoSessionId(sessionLane: string): string { return `ocx_${digest}`; } -/** Add per-conversation Go affinity only to the canonical fixed-key destination. */ +/** + * Add Go affinity only to the canonical fixed-key destination. + * + * Callers on the request path resolve the lane with `getOrAllocateRequestSessionLane`, which returns + * real conversation identity when the client supplied it and a per-request value otherwise, so a + * request reaching this helper from the proxy always carries a lane. The `!sessionLane` guard stays + * for direct callers that have no request context; it is not a per-request identity of its own, and + * minting one here would hand each retry a different value. + */ export function resolveOpenCodeGoTransport( provider: T, sessionLane: string | undefined, diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 20c29856ec..44409a660c 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -26,7 +26,10 @@ import { NoEligiblePolicyCandidateError, UnknownRoutingPolicyError, routeModel } import { evidenceFromBody } from "../routing/request-evidence"; import { resolveWireProtocolOverride } from "./adapter-resolve"; import { resolveOpenCodeGoTransport } from "../providers/opencode-go-transport"; -import { normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation"; +import { + getOrAllocateRequestSessionLane, + linkRequestSessionLane, +} from "./request-log-conversation"; import type { OcxConfig } from "../types"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { @@ -142,8 +145,7 @@ async function handleChatCompletionsWithBudget( let chatNativeRoute: ReturnType | null = null; try { const route = routeModel(config, chatBody.model as string, evidenceFromBody(chatBody)); - route.provider = resolveOpenCodeGoTransport(route.provider, - sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session"))); + route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req)); // Settle the wire once so every branch below reads the adapter this model will // actually use, not the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat"); @@ -305,6 +307,7 @@ async function handleChatCompletionsWithBudget( headers, body: internalBodyJson, }); + linkRequestSessionLane(req, internalReq); let nativeLogged = false; const finalizeNativeLog = (status: number, meta: { terminalStatus?: RequestLogEntry["terminalStatus"]; closeReason: "terminal" | "client_cancel" | "non_stream" }) => { diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index a16466459b..ba4d4999d3 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -36,7 +36,12 @@ import { resolveWireProtocolOverride } from "./adapter-resolve"; import type { OcxConfig } from "../types"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "./request-decompress"; import { addFinalRequestLog, httpStatusForRequestLogTerminal, recordFirstOutput, type RequestLogContext, type RequestLogEntry } from "./request-log"; -import { conversationIdFromClaudeMetadata, normalizeLogConversationId, sessionLaneIdFromRequest } from "./request-log-conversation"; +import { + conversationIdFromClaudeMetadata, + linkRequestSessionLane, + normalizeLogConversationId, + sessionLaneIdFromRequest, +} from "./request-log-conversation"; import { responseWithDeferredRequestLog } from "./relay"; import { handleResponses } from "./responses"; import { @@ -897,6 +902,7 @@ async function handleClaudeMessagesWithBudget( headers, body: JSON.stringify(internalBody), }); + linkRequestSessionLane(req, internalReq); } finally { reservation.release(); } diff --git a/src/server/request-log-conversation.ts b/src/server/request-log-conversation.ts index ed1e2f33d4..fd77f05798 100644 --- a/src/server/request-log-conversation.ts +++ b/src/server/request-log-conversation.ts @@ -2,7 +2,7 @@ * Best-effort chat/session correlation for Logs / usage.jsonl (#330). * Opaque ids only — never persist raw emails or Claude Desktop system-hash fallbacks. */ -import { createHash } from "node:crypto"; +import { createHash, randomUUID } from "node:crypto"; /** Reject absurdly long client strings before hashing (DoS / JSONL bloat). */ export const LOG_CONVERSATION_ID_INPUT_MAX = 4096; @@ -217,3 +217,43 @@ export function summarizeConversationLogs(entries: readonly TotalsSource[]): Con unmeteredRequests, }; } + +/** + * Request-scoped Go affinity for requests that carry no conversation identity. + * + * A sessionless request must still reach OpenCode Go with `x-opencode-session`, because the upstream + * began rejecting requests without it on 2026-09-06. It must not reuse one global value either, which + * would smear unrelated probes into a single conversation. So the lane is allocated once per admitted + * `Request` object and retained for that object's lifetime. + * + * The identity has to survive every place the proxy rebuilds a `Request`: translation to the internal + * Responses shape, compaction, and — the boundary that matters most — the policy fallback retry, where + * a second candidate would otherwise be handed a freshly minted lane after a retryable failure. + * `linkRequestSessionLane` carries the allocation across those boundaries. + */ +const requestAllocatedSessionLanes = new WeakMap(); + +/** + * Resolve the session lane for a request: real conversation identity when the client supplied it, + * otherwise a per-request value allocated once and reused for retries on the same object. + */ +export function getOrAllocateRequestSessionLane(req: Request): string { + const explicit = sessionLaneIdFromRequest(req.headers) + ?? normalizeLogConversationId(req.headers.get("x-opencode-session")); + if (explicit) return explicit; + + const existing = requestAllocatedSessionLanes.get(req); + if (existing) return existing; + const allocated = randomUUID(); + requestAllocatedSessionLanes.set(req, allocated); + return allocated; +} + +/** + * Carry a source request's lane onto a request the proxy built from it, so a rebuilt request keeps + * the conversation it belongs to instead of looking sessionless again. + */ +export function linkRequestSessionLane(sourceReq: Request, targetReq: Request): void { + requestAllocatedSessionLanes.set(targetReq, getOrAllocateRequestSessionLane(sourceReq)); +} + diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 3c345a9f1d..8add02babf 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -157,7 +157,7 @@ import { } from "./core"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel, safeOriginLabel } from "./fetch-helpers"; import { mapCodexAuthContextErrorToResponse, nativeMainRefreshFailureResponse } from "./codex-auth-error"; -import { sessionLaneIdFromRequest } from "../request-log-conversation"; +import { linkRequestSessionLane, sessionLaneIdFromRequest } from "../request-log-conversation"; import { recallComboForLane } from "./combo-session-recall"; export const COMPACT_RESPONSE_MAX_BYTES = 32 * 1024 * 1024; @@ -1149,6 +1149,7 @@ export async function handleResponsesCompact( headers: internalHeaders, body: JSON.stringify(internalBody), }); + linkRequestSessionLane(req, internalReq); const response = await handleResponses(internalReq, config, logCtx, { abortSignal: req.signal, turnAdmissionLease, ...(admission ? { admission } : {}) }); if (!response.ok) return response; let json: { output?: unknown[]; status?: unknown; error?: unknown }; diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c1ce136ca4..425621814c 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -320,6 +320,8 @@ import { } from "../request-log"; import { conversationIdFromResponsesRequest, + getOrAllocateRequestSessionLane, + linkRequestSessionLane, normalizeLogConversationId, reasoningReplayConversationIdFromResponsesRequest, sessionLaneIdFromRequest, @@ -2451,8 +2453,7 @@ async function applyFinalRouteRequestNormalization(args: { // Settle the wire once so logging, fast-mode, auth, and sidecars read the adapter // this request will actually use (#404). - route.provider = resolveOpenCodeGoTransport(route.provider, - sessionLaneIdFromRequest(req.headers) ?? normalizeLogConversationId(req.headers.get("x-opencode-session"))); + route.provider = resolveOpenCodeGoTransport(route.provider, getOrAllocateRequestSessionLane(req)); route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, inboundWire); if (preserveAnthropicResponseModel) parsed._responseModelId = responseModelId; logCtx.model = route.modelId; @@ -2816,6 +2817,7 @@ export async function handleComboResponses( headers: childHeaders, body: JSON.stringify(childBody), }); + linkRequestSessionLane(req, childRequest); let resolvedAuth: CodexAuthContext | undefined; let terminalRecorder: ((status: ResponsesTerminalStatus, httpStatusOverride?: number) => void) | undefined; const started = Date.now(); diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index 13c90bdcee..e3b061ed28 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -2,6 +2,7 @@ import { comboFailureDecision } from "../../combos/failover"; import { readBoundedResponseBody } from "../../lib/bounded-body"; import { readJsonRequestBody, resolveInboundBodyLimitBytes } from "../request-decompress"; import { finishRequestAttempt, type RequestLogContext } from "../request-log"; +import { linkRequestSessionLane } from "../request-log-conversation"; import type { OcxConfig } from "../../types"; import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace"; import { handleResponses as handleResponsesCore } from "./core"; @@ -56,12 +57,17 @@ function requestWithCandidate( headers.delete("content-encoding"); headers.delete("content-length"); headers.set("content-type", "application/json"); - return new Request(req.url, { + const retryRequest = new Request(req.url, { method: req.method, headers, body: JSON.stringify({ ...rawBody, model: `${candidate.provider}/${candidate.model}` }), signal: req.signal, }); + // A sessionless request keeps the lane it was already allocated. Without this the second + // candidate reaches OpenCode Go under a different x-opencode-session than the first attempt, + // which is the same conversation split the header exists to prevent. + linkRequestSessionLane(req, retryRequest); + return retryRequest; } function errorCodeFromText(text: string): string | undefined { diff --git a/tests/providers/opencode-go-session-header.test.ts b/tests/providers/opencode-go-session-header.test.ts index c176dc703e..9b326e294c 100644 --- a/tests/providers/opencode-go-session-header.test.ts +++ b/tests/providers/opencode-go-session-header.test.ts @@ -3,6 +3,8 @@ import { providerConfigSeed } from "../../src/providers/derive"; import { resolveOpenCodeGoTransport } from "../../src/providers/opencode-go-transport"; import { getProviderRegistryEntry } from "../../src/providers/registry"; import { handleResponses } from "../../src/server/responses/core"; +import { handleResponsesWithPolicyFallback, rankPolicyFallbackCandidates } from "../../src/server/responses/policy-fallback"; +import { getOrAllocateRequestSessionLane } from "../../src/server/request-log-conversation"; import { handleChatCompletions } from "../../src/server/chat-completions"; import { handleClaudeMessages } from "../../src/server/claude-messages"; import type { OcxConfig, OcxProviderConfig } from "../../src/types"; @@ -133,12 +135,17 @@ describe("OpenCode Go session affinity (#3344)", () => { expect(first.headers.get(SESSION_HEADER)).not.toContain("conversation-a"); }); - test("Claude recognizes renamed canonical Go destinations and omits shared system affinity", async () => { + test("Claude recognizes renamed canonical Go destinations and isolates a request with no identity", async () => { const input = { claude: true, model: CHAT_MODEL, providerName: "renamed-go" }; const metadata = await captureRequest({ ...input, metadataUserId: "user_test_account__session_conversation-a" }); const desktop = await captureRequest(input); + const secondDesktop = await captureRequest(input); expect(metadata.headers.get(SESSION_HEADER)).toBe("ocx_a89540229ef781fd5f7adf92a711b436"); - expect(desktop.headers.has(SESSION_HEADER)).toBe(false); + // A shared system prompt is not identity, so this request has none. It still has to carry the + // header — Go rejects requests without one — but under a lane of its own rather than a shared value. + expect(desktop.headers.get(SESSION_HEADER)).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(desktop.headers.get(SESSION_HEADER)).not.toBe(metadata.headers.get(SESSION_HEADER)); + expect(secondDesktop.headers.get(SESSION_HEADER)).not.toBe(desktop.headers.get(SESSION_HEADER)); }); test("Claude explicit Go header precedes metadata and matches native Chat affinity", async () => { @@ -185,11 +192,17 @@ describe("OpenCode Go session affinity (#3344)", () => { } }); - test(`Claude ${model} omits Go affinity without usable metadata identity`, async () => { + test(`Claude ${model} isolates each request whose metadata identity is unusable`, async () => { + const seen = new Set(); for (const metadataUserId of [undefined, "", " \t\n ", "invalid\u0000identity", "x".repeat(4097)]) { const captured = await captureRequest({ claude: true, model, metadataUserId }); expect(captured.url).toBe(url); - expect(captured.headers.has(SESSION_HEADER)).toBe(false); + // Unusable identity is not the same as no header: the request still reaches Go, and it does + // so under a lane nobody else shares. + const lane = captured.headers.get(SESSION_HEADER); + expect(lane).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(seen.has(lane!)).toBe(false); + seen.add(lane!); expect(captured.headers.has("session_id")).toBe(false); } }); @@ -367,3 +380,88 @@ describe("OpenCode Go session affinity (#3344)", () => { expect(captured.headers.has(SESSION_HEADER)).toBe(false); }); }); + +describe("OpenCode Go affinity across the policy fallback retry (#4172)", () => { + const policyTrace = { + version: 1, + decisionId: "decision-policy-go", + createdAt: Date.now(), + requestedModel: "policy/go", + routeKind: "policy", + profile: { id: "profile-go", revision: "rev-1" }, + requirements: [], + candidates: [ + { provider: "opencode-go", model: MUSE_MODEL, eligible: true, exclusions: [], score: { total: 2 } }, + { provider: "opencode-go-2", model: MUSE_MODEL, eligible: true, exclusions: [], score: { total: 1 } }, + ], + selected: { candidateIndex: 0, provider: "opencode-go", model: MUSE_MODEL, reason: "policy-test" }, + } as unknown as Parameters[0]; + + function sessionlessRequest(): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "policy/go", input: "ping", stream: false }), + }); + } + + function laneHeaderFor(req: Request): string | undefined { + return resolveOpenCodeGoTransport(opencodeGo(), getOrAllocateRequestSessionLane(req)) + .headers?.[SESSION_HEADER]; + } + + async function runPolicyFallback(req: Request): Promise { + const seen: Request[] = []; + let attempts = 0; + const runCore = (async (coreReq: Request, _config: unknown, logCtx: { routeDecision?: unknown }) => { + seen.push(coreReq); + logCtx.routeDecision = policyTrace; + attempts += 1; + if (attempts === 1) { + return new Response(JSON.stringify({ error: { message: "upstream temporarily unavailable" } }), { + status: 503, + headers: { "content-type": "application/json" }, + }); + } + return Response.json({ id: "resp_policy_go", object: "response", status: "completed", output: [] }); + }) as unknown as NonNullable[4]>["runCore"]; + + const config = { providers: { "opencode-go": opencodeGo() } } as unknown as OcxConfig; + const response = await handleResponsesWithPolicyFallback( + req, config, { model: "", provider: "" } as never, {}, { runCore }, + ); + expect(response.status).toBe(200); + return seen; + } + + test("a sessionless request keeps one lane when the policy hops to the next candidate", async () => { + const seen = await runPolicyFallback(sessionlessRequest()); + // The retry is a different Request object built by requestWithCandidate. Without the link it + // would look sessionless again and be handed a second lane, splitting one turn across two Go + // conversations — which is exactly what the header exists to prevent. + expect(seen).toHaveLength(2); + expect(seen[1]).not.toBe(seen[0]); + const first = laneHeaderFor(seen[0]!); + expect(first).toMatch(/^ocx_[0-9a-f]{32}$/); + expect(laneHeaderFor(seen[1]!)).toBe(first); + }); + + test("two independent sessionless requests do not share a lane through the same fallback", async () => { + const firstTurn = await runPolicyFallback(sessionlessRequest()); + const secondTurn = await runPolicyFallback(sessionlessRequest()); + expect(laneHeaderFor(secondTurn[0]!)).not.toBe(laneHeaderFor(firstTurn[0]!)); + expect(laneHeaderFor(secondTurn[1]!)).toBe(laneHeaderFor(secondTurn[0]!)); + }); + + test("real conversation identity still wins over the per-request allocation", async () => { + const req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: codexHeaders("child-thread-policy"), + body: JSON.stringify({ model: "policy/go", input: "ping", stream: false }), + }); + const seen = await runPolicyFallback(req); + const expected = laneHeaderFor(req); + expect(laneHeaderFor(seen[0]!)).toBe(expected); + expect(laneHeaderFor(seen[1]!)).toBe(expected); + }); +});