From 8de421f45631927d16571afdfb482f824037d597 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 23:41:20 +0900 Subject: [PATCH 1/3] fix(claude): preserve terminal native context errors in Messages --- .../261003_release_lane_a/001_evidence.md | 10 ++ .../020_messages_context.md | 4 +- .../src/content/docs/guides/claude-code.md | 9 ++ src/claude/outbound.ts | 2 + src/protocols/encoders/messages.ts | 7 +- src/server/claude-messages.ts | 17 ++- structure/data-planes/inbound-compat.md | 9 ++ .../claude-outbound.test.ts | 139 +++++++++++++++++- .../protocol-direct-encoders-messages.test.ts | 20 ++- 9 files changed, 203 insertions(+), 14 deletions(-) diff --git a/devlog/_plan/261003_release_lane_a/001_evidence.md b/devlog/_plan/261003_release_lane_a/001_evidence.md index 61cd6689f07..377cc58d622 100644 --- a/devlog/_plan/261003_release_lane_a/001_evidence.md +++ b/devlog/_plan/261003_release_lane_a/001_evidence.md @@ -24,3 +24,13 @@ - Transport B: cherry-picked6508 with -x (0076dc5b0b), original author retained; added UTF-8 exact threshold, destination-away and AbortError executor tests. Focused initial green exit0. Removing byte conversion made the boundary tests fail (transport-mutation-red.log); source restored before broader affected transport checks. No retries or credential policy altered. - Transport C/D: 199 passed,0 failed,1 older-runtime skip; mutation6fail. Typecheck/privacy/structure exit0; docs561 pages/77932links pass. Pinned Bun postinstall was completed after ignore-scripts prevented bun-run commands; no lockfile change. Independent code/security reviewer01a10221-1acd-7f90-92c9-ae96f95229f1 returned PASS, no blockers, four extra synthetic boundary probes passed. Next: publish transport and continue Messages. + +- Messages P revalidation: transport D concluded reviewed upload bytes are ready; continue020 per roadmap. All three Messages runtime files remain identical to original architect/reflection/audit source. Same D1-D3/D5 decisions apply with accepted reachability clarifications; no credentials/compaction-policy edits. Transport published as PR6513, and Messages is a separate child branch pending parent integration. + +- Messages B: new real HTTP Messages regressions first failed8 cases (502 collection/JSON, missing stream code, retry hint leak), then passed. Restoring all three pre-fix source files with current regressions caused13 failures; restored fixes afterward. A canonical native final-send fixture also asserts actual Messages SSE/JSON and exactly1 upstream send. Explicit Direct stored-main variant encountered the separately owned auth fence (requires caller bearer); used the existing canonical-forward fixture pattern without changing credential owners. +- Messages affected command from020 passed211 tests/0 failures across5 files. Defensive upstream JSON remained JSON into the ingress: stream true also returns HTTP400 as asserted. Existing endpoint negatives preserve unrelated failures. +- Composed real-client probe: isolated Claude Code2.1.288 -> proxy /v1/messages -> canonical native Responses executor redirected to synthetic upstream response.failed/context_length_exceeded. Exited1 in0.765s,3 bounded upstream requests, displays API Error400 and synthetic input-limit message, no timeout. Client uses known Claude model name mapped in isolated config to native GPT destination. It proves composed surfacing/termination, not zero retries, real account behavior or automatic compaction. First unrecognized fixture alias was rejected by client before upstream (0hits) and is not counted as passing evidence. Scratch script/log retained; no private conversation or external provider used. + +- Test placement amendment: registering a new Messages test hit NEW_OVERSIZED at2000lines in test-layout-expected.json. Move the new cases into the existing claude-outbound.test.ts under a nested fixture scope (remains below2000), remove only our new registry row/file; preserve ratchet, no cap increase or formatting workaround. Runtime/interface/flow decisions unchanged. + +- Messages C/D: after relocating fixtures,238 tests passed/0fail across7files including layout/ratchet; typecheck/privacy/structure exit0,docs561pages/77932links pass. Independent implementation/security reviewer01a10230-ce6c-75c3-b677-9edafdd2b12a PASS, blockers0; additional partial-output/cancellation/exact-code-negative probes passed. Next publish Messages child and complete exact-head hosted evidence; no claims of native account/automatic-compaction success. diff --git a/devlog/_plan/261003_release_lane_a/020_messages_context.md b/devlog/_plan/261003_release_lane_a/020_messages_context.md index 613eb6463bc..2c14cad76e4 100644 --- a/devlog/_plan/261003_release_lane_a/020_messages_context.md +++ b/devlog/_plan/261003_release_lane_a/020_messages_context.md @@ -47,13 +47,13 @@ No change to core-auth.ts/core-options.ts/core-combo.ts or Responses retry/compa ## Test delta and activation -NEW tests/claude-integration/claude-messages-context-errors.test.ts, registered in both scripts/test-layout/layout.json explicit and tests/fixtures/test-layout-expected.json. Reuse isolatedCodexHome and per-test OPENCODEX_HOME helpers from adjacent endpoint tests; fixtures contain synthetic input and credentials only. Public handleClaudeMessages with configured openai-responses local upstream activates the actual error translation. Include Retry-After in the non-2xx context fixture to prove suppression; for failed JSON assert upstream content type and record whether earlier normalization consumes it. Use HTTP400 for the single-send non-2xx fixture; refusal/maintenance precedence stays covered separately by existing endpoint tests. Failed JSON must use a route/config that does not reframe it as SSE; if no public route reaches it, report the coverage limit instead of claiming activation. Parameterize stream true/false and terminal SSE/provider 413/non-2xx context error/failed JSON where constructible. Assert exact invalid_request_error + context_length_exceeded, HTTP400 for nonstream/JSON, no retry header, one upstream send, one terminal stream error and no message_stop. 413 fixture embeds a private-marker string in upstream body; final error must contain only proxy-safe copy. Add unknown and transient negative cases preserving existing classification. Cancel the response consumer and assert upstream cleanup/no extra dispatch where the harness can observe it. +MODIFY tests/claude-integration/claude-outbound.test.ts with a nested Messages-ingress describe and locally scoped home/server fixtures. Reuse this existing registered file: a new roster row would push test-layout-expected.json to the prohibited 2000-line threshold. No ratchet increase or compressed manifest rows. Reuse isolatedCodexHome and per-test OPENCODEX_HOME helpers from adjacent endpoint tests; fixtures contain synthetic input and credentials only. Public handleClaudeMessages with configured openai-responses local upstream activates the actual error translation. Include Retry-After in the non-2xx context fixture to prove suppression; for failed JSON assert upstream content type and record whether earlier normalization consumes it. Use HTTP400 for the single-send non-2xx fixture; refusal/maintenance precedence stays covered separately by existing endpoint tests. Failed JSON must use a route/config that does not reframe it as SSE; if no public route reaches it, report the coverage limit instead of claiming activation. Parameterize stream true/false and terminal SSE/provider 413/non-2xx context error/failed JSON where constructible. Assert exact invalid_request_error + context_length_exceeded, HTTP400 for nonstream/JSON, no retry header, one upstream send, one terminal stream error and no message_stop. 413 fixture embeds a private-marker string in upstream body; final error must contain only proxy-safe copy. Add unknown and transient negative cases preserving existing classification. Cancel the response consumer and assert upstream cleanup/no extra dispatch where the harness can observe it. MODIFY tests/claude-integration/claude-outbound.test.ts: update the existing classified invalid_request_error exact assertion to include context_length_exceeded; synthetic response.failed with status413 + context code and status-absent context code both produce invalid_request_error/context_length_exceeded without message_start or successful terminal. Explicit expectations, not parity. MODIFY tests/responses/protocol-direct-encoders-messages.test.ts: direct AdapterEvent error with code context_length_exceeded reaches streaming frame and folded HTTP400; keep explicit oracle assertions. Update the test's legacyFold fixture narrow status rule to mirror the new ingress, without deriving expectations from production helpers. Keep unknown failures and translator limits in existing cases. -Run explicit test files plus tests/responses/responses-context-overflow.test.ts and existing claude-messages-endpoint.test.ts. Direct Arguments observe each target; baseline three-file command passed143 tests (001); new-file and endpoint coverage will run after implementation. Exact implementation command: `bun test tests/claude-integration/claude-messages-context-errors.test.ts tests/claude-integration/claude-outbound.test.ts tests/claude-integration/claude-messages-endpoint.test.ts tests/responses/protocol-direct-encoders-messages.test.ts tests/responses/responses-context-overflow.test.ts`. Add source-oracle test layout/file-size checks if a new test file is added. No source cap is to be increased. +Run explicit test files plus tests/responses/responses-context-overflow.test.ts and existing claude-messages-endpoint.test.ts. Direct Arguments observe each target; baseline three-file command passed143 tests (001); new-file and endpoint coverage will run after implementation. Exact implementation command: `bun test tests/claude-integration/claude-outbound.test.ts tests/claude-integration/claude-messages-endpoint.test.ts tests/responses/protocol-direct-encoders-messages.test.ts tests/responses/responses-context-overflow.test.ts`. Add source-oracle test layout/file-size checks if a new test file is added. No source cap is to be increased. ## Exact documentation additions diff --git a/docs-site/src/content/docs/guides/claude-code.md b/docs-site/src/content/docs/guides/claude-code.md index 9b8c640de14..376bc472166 100644 --- a/docs-site/src/content/docs/guides/claude-code.md +++ b/docs-site/src/content/docs/guides/claude-code.md @@ -601,6 +601,15 @@ Three config states: The compaction value is adjustable on the Claude page. **Warning:** raising it past a model's real window breaks that model — the chat errors out before the summary can fire. +A model's advertised context window does not guarantee that a tool-heavy request fits the +upstream input limit. A classified input-limit rejection reaches Claude Code as +`invalid_request_error` with `context_length_exceeded`; a non-streaming response uses HTTP 400 +instead of a retryable 502. Reduce the current input or compact earlier. If `/compact` also +exceeds the limit, preserve the original history and try compacting a fork with fewer enabled +tool or MCP schemas, if your client supports that workflow. Recovery still depends on the +reduced request fitting the upstream limit. A `[1m]` marker or larger client accounting setting +does not raise that limit, and OpenCodex does not silently remove history or tools to make it fit. + Sub-1M native Anthropic models are never auto-marked. Values you export yourself always win (the proxy uses YOUR value to decide which models are safe to mark). Invalid hand-edited config values fall back to 829,800. diff --git a/src/claude/outbound.ts b/src/claude/outbound.ts index 17ddcedaf51..b4259b93e4a 100644 --- a/src/claude/outbound.ts +++ b/src/claude/outbound.ts @@ -248,6 +248,7 @@ export function anthropicIncompleteOutcome( */ export function anthropicFailedStatus(error: Rec, message: string): number { const code = typeof error.code === "string" ? error.code : undefined; + if (code === "context_length_exceeded") return 400; return code === "translation_buffer_limit" ? 413 : typeof error.status === "number" @@ -726,6 +727,7 @@ export function responsesSseToAnthropicSse( status, message, true, + code === "context_length_exceeded" ? code : undefined, ); break; } diff --git a/src/protocols/encoders/messages.ts b/src/protocols/encoders/messages.ts index c4167aba3cc..973a93fc1b5 100644 --- a/src/protocols/encoders/messages.ts +++ b/src/protocols/encoders/messages.ts @@ -386,7 +386,8 @@ function createAnthropicMessageWriter( return; } const message = typeof error.message === "string" ? error.message : "upstream request failed"; - fail(anthropicFailedStatus(error, message), message, true); + fail(anthropicFailedStatus(error, message), message, true, + error.code === "context_length_exceeded" ? error.code : undefined); }, overflow() { fail(413, "upstream translation buffer exceeded the safe limit", false, "translation_buffer_limit"); @@ -413,7 +414,7 @@ export function encodeAnthropicMessageSse( /** * Fold an encoded Messages stream into the non-streaming client response, with the status * mapping the Messages ingress applied to its collected message: a translator overflow is 413, - * a stream that ended in an error frame is 502, anything else unexpected a 502 api_error. + * a classified context rejection is 400; other error frames/unexpected failures stay 502. */ export async function collectAnthropicMessageResponse( stream: ReadableStream, @@ -444,7 +445,7 @@ export async function collectAnthropicMessageResponse( ); } return new Response(JSON.stringify(message), { - status: isError ? 502 : 200, + status: isError ? (translatedError?.code === "context_length_exceeded" ? 400 : 502) : 200, headers: { "Content-Type": "application/json" }, }); } diff --git a/src/server/claude-messages.ts b/src/server/claude-messages.ts index 030581fa6f4..625a5250e96 100644 --- a/src/server/claude-messages.ts +++ b/src/server/claude-messages.ts @@ -1315,10 +1315,12 @@ async function handleClaudeMessagesWithBudget( const replayRefusal = isReplayRefusalResponse(response); // Re-shape the OpenAI-style error envelope into the Anthropic one, preserving status. let message = `upstream error (${response.status})`; + let contextError = false; try { const text = await response.text(); try { - const parsed = JSON.parse(text) as { error?: { message?: string; type?: string } | string; message?: string }; + const parsed = JSON.parse(text) as { error?: { message?: string; type?: string; code?: unknown } | string; message?: string }; + contextError = typeof parsed?.error === "object" && parsed.error?.code === "context_length_exceeded"; const nested = typeof parsed?.error === "object" && parsed.error ? parsed.error.message : undefined; const flat = typeof parsed?.error === "string" ? parsed.error : parsed?.message; message = nested || flat || (text ? `upstream error (${response.status}): ${text.slice(0, 400)}` : message); @@ -1327,7 +1329,7 @@ async function handleClaudeMessagesWithBudget( } } catch { /* keep fallback message */ } const upstreamRetryAfter = response.headers.get("retry-after"); - const retryAfter = replayRefusal + const retryAfter = replayRefusal || contextError ? undefined : resolveClientRetryAfter({ status: response.status, @@ -1347,10 +1349,10 @@ async function handleClaudeMessagesWithBudget( const nativeMainFence = response.status === 503 && upstreamRetryAfter?.trim() === "1" && message === CODEX_MAIN_PROFILE_MAINTENANCE_MESSAGE; - const transient = !replayRefusal && !nativeMainFence && isTransientUpstreamStatus(response.status); + const transient = !replayRefusal && !nativeMainFence && !contextError && isTransientUpstreamStatus(response.status); const outStatus = replayRefusal ? REPLAY_REFUSED_STATUS - : nativeMainFence ? 503 : transient ? 529 : response.status; + : nativeMainFence ? 503 : contextError ? 400 : transient ? 529 : response.status; const outHeaders = new Headers({ "Content-Type": "application/json" }); if (retryAfter) outHeaders.set("Retry-After", retryAfter); else if (transient) outHeaders.set("Retry-After", "2"); @@ -1359,7 +1361,7 @@ async function handleClaudeMessagesWithBudget( outStatus, message, undefined, - replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : undefined, + replayRefusal ? UPSTREAM_RESET_REPLAY_REFUSED_CODE : contextError ? "context_length_exceeded" : undefined, )), { status: outStatus, headers: outHeaders, @@ -1410,7 +1412,7 @@ async function handleClaudeMessagesWithBudget( ); } return new Response(JSON.stringify(message), { - status: isError ? 502 : 200, + status: isError ? (translatedError?.code === "context_length_exceeded" ? 400 : 502) : 200, headers: { "Content-Type": "application/json" }, }); } @@ -1433,6 +1435,9 @@ async function handleClaudeMessagesWithBudget( "translation_buffer_limit", ); } + if (error?.code === "context_length_exceeded") { + return anthropicErrorResponse(400, error.message ?? "upstream context limit exceeded", "invalid_request_error", error.code); + } return anthropicErrorResponse(502, error?.message ?? "upstream request failed", "api_error"); } let message: Rec; diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 470e1a317e4..765ef62bd07 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -281,6 +281,15 @@ its defaults and exclusions are owned by [Responses transport](../transports/res The provider summary default applies at Responses ingress; native Chat and Anthropic inbound preferences keep their existing handling. Raw content is never renamed to a summary. See [bridge contract](../providers/chat-compat.md). +## Claude context rejection + +Claude Messages preserves the classified `context_length_exceeded` error through +`src/claude/outbound.ts`, `src/protocols/encoders/messages.ts`, and +`src/server/claude-messages.ts`. Streaming output carries one `invalid_request_error` +terminal with that code; collected and failed-JSON responses return HTTP 400 without +a retry hint. This mapping adds no recovery send or context pruning. Unknown upstream +failures, replay refusal, and local translation-buffer limits retain their distinct handling. + ## Claude affinity at final Go dispatch `src/server/claude-messages.ts` carries validated conversation affinity privately through diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index b07a7b9cf0d..6c87113c111 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -1,4 +1,12 @@ -import { describe, expect, test } from "bun:test"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { saveConfig } from "../../src/config"; +import { startServer } from "../../src/server"; +import type { OcxConfig } from "../../src/types"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; import { anthropicErrorBody, anthropicErrorType, @@ -928,7 +936,7 @@ describe("claude outbound SSE", () => { const events = await collectEvents(responsesSseToAnthropicSse(streamFrom(upstream), "m")); expect(events.at(-1)!.data).toEqual({ type: "error", - error: { type: "invalid_request_error", message: "Cursor context limit exceeded" }, + error: { type: "invalid_request_error", message: "Cursor context limit exceeded", code: "context_length_exceeded" }, }); }); @@ -1795,3 +1803,130 @@ describe("deferred Claude thinking order", () => { } }); }); + +describe("Messages ingress context errors", () => { + let home: string; + let previousHome: string | undefined; + let isolated: IsolatedCodexHome; + const originalFetch = globalThis.fetch; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-messages-context-")); + process.env.OPENCODEX_HOME = home; + isolated = installIsolatedCodexHome("ocx-messages-context-codex-"); + }); + afterEach(() => { + globalThis.fetch = originalFetch; + isolated.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + }); + + const contextError = { + type: "invalid_request_error", code: "context_length_exceeded", message: "Synthetic input context limit exceeded", + }; + function failedSse(error: Record): Response { + const frame = { type: "response.failed", response: { status: "failed", error } }; + return new Response(`event: response.failed\ndata: ${JSON.stringify(frame)}\n\n`, { + headers: { "Content-Type": "text/event-stream" }, + }); + } + + async function withMessages(upstreamResponse: () => Response, stream: boolean, check: (response: Response, hits: () => number) => Promise, canonical = false) { + let hits = 0; + const upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { hits++; return upstreamResponse(); } }); + let canonicalSends = 0; + const config = { port: 0, hostname: "127.0.0.1", defaultProvider: "native", providers: { + native: { adapter: "openai-responses", baseUrl: `${upstream.url.origin}/v1`, authMode: "forward", allowPrivateNetwork: true }, + } } as OcxConfig; + if (canonical) { + config.providers.native!.baseUrl = "https://chatgpt.com/backend-api/codex"; + globalThis.fetch = ((input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://chatgpt.com/backend-api/codex/responses") { + canonicalSends++; + return originalFetch(new URL("/v1/responses", upstream.url), init); + } + // A fixture must never make an unmocked external call. + if (!url.startsWith("http://127.0.0.1:") && !url.startsWith("http://localhost:")) { + throw new Error("unexpected external request in context fixture"); + } + return originalFetch(input, init); + }) as typeof fetch; + } + saveConfig(config); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/messages", server.url), { + method: "POST", headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ model: "native/gpt-test", stream, max_tokens: 32, messages: [{ role: "user", content: "synthetic input" }] }), + }); + await check(response, () => hits); + if (canonical) expect(canonicalSends).toBe(1); + } finally { await server.stop(true); await upstream.stop(true); } + } + + async function expectContext(response: Response, stream: boolean) { + expect(response.headers.has("retry-after")).toBe(false); + const text = await response.text(); + expect(text).not.toContain("private-413-marker"); + if (stream && response.headers.get("content-type")?.includes("text/event-stream")) { + expect(response.status).toBe(200); + const frames = text.split("\n\n").filter(frame => frame.includes("event: error")); + expect(frames).toHaveLength(1); + expect(text).not.toContain("event: message_start"); + expect(text).not.toContain("event: message_stop"); + const line = frames[0]!.split("\n").find(line => line.startsWith("data:"))!; + expect(JSON.parse(line.slice(5))).toMatchObject({ type: "error", error: { type: "invalid_request_error", code: "context_length_exceeded" } }); + } else { + expect(response.status, text).toBe(400); + expect(JSON.parse(text)).toMatchObject({ type: "error", error: { type: "invalid_request_error", code: "context_length_exceeded" } }); + } + } + + describe("Messages context failures preserve terminal semantics", () => { + for (const stream of [false, true]) { + test(`canonical native terminal reaches the Messages wire (stream=${stream})`, async () => { + await withMessages(() => failedSse(contextError), stream, async (response, hits) => { + await expectContext(response, stream); + expect(hits()).toBe(1); + }, true); + }); + test(`classified terminal SSE stays terminal (stream=${stream})`, async () => { + await withMessages(() => failedSse(contextError), stream, async (response, hits) => { + await expectContext(response, stream); + expect(hits()).toBe(1); + }); + }); + test(`provider 413 becomes a safe context error (stream=${stream})`, async () => { + await withMessages(() => Response.json({ message: "private-413-marker" }, { status: 413 }), stream, async (response, hits) => { + await expectContext(response, stream); + expect(hits()).toBe(1); + }); + }); + test(`non-2xx context error preserves code and suppresses retry hint (stream=${stream})`, async () => { + await withMessages(() => Response.json({ error: contextError }, { status: 400, headers: { "Retry-After": "7" } }), stream, async (response, hits) => { + await expectContext(response, stream); + expect(hits()).toBe(1); + }); + }); + test(`failed Responses JSON retains context identity (stream=${stream})`, async () => { + await withMessages(() => Response.json({ id: "resp_context", object: "response", status: "failed", error: contextError, output: [] }), stream, async (response, hits) => { + await expectContext(response, stream); + expect(hits()).toBe(1); + }); + }); + } + test("unknown collected failure remains 502 and does not gain context identity", async () => { + await withMessages(() => failedSse({ type: "server_error", code: "upstream_server_error", message: "synthetic upstream failure" }), false, async (response, hits) => { + expect(response.status).toBe(502); + const body = await response.json(); + expect(body.error.type).toBe("overloaded_error"); + expect(body.error.code).toBeUndefined(); + expect(hits()).toBe(1); + }); + }); + }); +}); diff --git a/tests/responses/protocol-direct-encoders-messages.test.ts b/tests/responses/protocol-direct-encoders-messages.test.ts index 237a5db24b2..5c721afa1c2 100644 --- a/tests/responses/protocol-direct-encoders-messages.test.ts +++ b/tests/responses/protocol-direct-encoders-messages.test.ts @@ -117,7 +117,10 @@ async function legacyFold(events: AdapterEvent[], options: ToolOptions = {}): Pr "translation_buffer_limit", ); } - return new Response(JSON.stringify(message), { status: isError ? 502 : 200, headers: { "Content-Type": "application/json" } }); + return new Response(JSON.stringify(message), { + status: isError ? (translatedError?.code === "context_length_exceeded" ? 400 : 502) : 200, + headers: { "Content-Type": "application/json" }, + }); } async function expectFoldParity(events: AdapterEvent[], options: ToolOptions = {}) { @@ -320,6 +323,21 @@ describe("direct Messages encoder matches bridge + converter (stream)", () => { }); describe("direct Messages fold matches bridge + collector (non-stream)", () => { + test.each([400, 413, undefined])("context rejection remains terminal with upstream status %s", async status => { + const events: AdapterEvent[] = [{ type: "error", status, errorType: "invalid_request_error", + code: "context_length_exceeded", message: "Synthetic input limit", retryable: false }]; + const frames = await expectStreamParity(events); + expect(frames).toEqual([{ event: "error", data: { type: "error", error: { + type: "invalid_request_error", code: "context_length_exceeded", message: "Synthetic input limit", + } } }]); + const response = await foldAnthropicMessage(replay(events), directOptions()); + expect(response.status).toBe(400); + expect(response.headers.has("retry-after")).toBe(false); + expect(await response.json()).toEqual({ type: "error", error: { + type: "invalid_request_error", code: "context_length_exceeded", message: "Synthetic input limit", + } }); + }); + for (const [name, scenario] of Object.entries(SCENARIOS)) { test(name, async () => { await expectFoldParity(scenario.events, scenario.options); From 8322239bd7caf6c03ad047076f971a57fb9674e1 Mon Sep 17 00:00:00 2001 From: JUN Date: Sat, 3 Oct 2026 23:42:40 +0900 Subject: [PATCH 2/3] docs: record reviewed Lane A publication handoff --- devlog/_plan/261003_release_lane_a/030_review_publication.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/devlog/_plan/261003_release_lane_a/030_review_publication.md b/devlog/_plan/261003_release_lane_a/030_review_publication.md index 0b6112daffe..c86eb3205c9 100644 --- a/devlog/_plan/261003_release_lane_a/030_review_publication.md +++ b/devlog/_plan/261003_release_lane_a/030_review_publication.md @@ -10,3 +10,7 @@ Depends on verified transport and Messages changes. No runtime edits are pre-pla 6. NEW/UPDATE .tmp/release-stabilization/report.md with exact local/PR head, URL, source heads/credit/dispositions, test outputs, review evidence and residual native limitations. Do not expose private data. DONE only after all required applicable exact-head checks actually succeed and blocking reviews resolve. Publication changes only Git history and PR metadata. Checklist claims are evidence-based; security review is technical, not a substitute for any maintainer decision. + +## Prepared handoff + +Transport PR6513 has independent code/security PASS and all applicable local checks. Its CodeRabbit threshold wording finding is fixed atdb094c49c7 and the thread resolved; corrected-head hosted CI remains the integration gate. Messages has independent code/security PASS and238 local tests plus typecheck/privacy/structure/docs success. Its child branch includes the parent threshold wording fix without runtime drift. Report and live exact-head CI/review snapshots are retained in ignored .tmp/release-stabilization; no merge or release is performed by this lane. This plan stays open for coordinator integration, rather than claiming the changes shipped. From 3aeacabe816a23ce995da6e3077c15b6ef60c121 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 00:10:38 +0900 Subject: [PATCH 3/3] test(claude): clean up context fixture on setup failures --- .../020_messages_context.md | 10 ++-- .../claude-outbound.test.ts | 48 ++++++++++--------- 2 files changed, 31 insertions(+), 27 deletions(-) diff --git a/devlog/_plan/261003_release_lane_a/020_messages_context.md b/devlog/_plan/261003_release_lane_a/020_messages_context.md index 2c14cad76e4..112244b819b 100644 --- a/devlog/_plan/261003_release_lane_a/020_messages_context.md +++ b/devlog/_plan/261003_release_lane_a/020_messages_context.md @@ -47,17 +47,17 @@ No change to core-auth.ts/core-options.ts/core-combo.ts or Responses retry/compa ## Test delta and activation -MODIFY tests/claude-integration/claude-outbound.test.ts with a nested Messages-ingress describe and locally scoped home/server fixtures. Reuse this existing registered file: a new roster row would push test-layout-expected.json to the prohibited 2000-line threshold. No ratchet increase or compressed manifest rows. Reuse isolatedCodexHome and per-test OPENCODEX_HOME helpers from adjacent endpoint tests; fixtures contain synthetic input and credentials only. Public handleClaudeMessages with configured openai-responses local upstream activates the actual error translation. Include Retry-After in the non-2xx context fixture to prove suppression; for failed JSON assert upstream content type and record whether earlier normalization consumes it. Use HTTP400 for the single-send non-2xx fixture; refusal/maintenance precedence stays covered separately by existing endpoint tests. Failed JSON must use a route/config that does not reframe it as SSE; if no public route reaches it, report the coverage limit instead of claiming activation. Parameterize stream true/false and terminal SSE/provider 413/non-2xx context error/failed JSON where constructible. Assert exact invalid_request_error + context_length_exceeded, HTTP400 for nonstream/JSON, no retry header, one upstream send, one terminal stream error and no message_stop. 413 fixture embeds a private-marker string in upstream body; final error must contain only proxy-safe copy. Add unknown and transient negative cases preserving existing classification. Cancel the response consumer and assert upstream cleanup/no extra dispatch where the harness can observe it. +MODIFY tests/claude-integration/claude-outbound.test.ts with a nested Messages-ingress describe and locally scoped home/server fixtures. Reuse this existing registered file: a new roster row would push test-layout-expected.json to the prohibited 2,000-line threshold. No ratchet increase or compressed manifest rows. Reuse isolatedCodexHome and per-test OPENCODEX_HOME helpers from adjacent endpoint tests; fixtures contain synthetic input and credentials only. Public handleClaudeMessages with configured openai-responses local upstream activates the actual error translation. Include Retry-After in the non-2xx context fixture to prove suppression; for failed JSON assert upstream content type and record whether earlier normalization consumes it. Use HTTP 400 for the single-send non-2xx fixture; refusal/maintenance precedence stays covered separately by existing endpoint tests. Failed JSON must use a route/config that does not reframe it as SSE; if no public route reaches it, report the coverage limit instead of claiming activation. Parameterize stream true/false and terminal SSE/provider 413/non-2xx context error/failed JSON where constructible. Assert exact invalid_request_error + context_length_exceeded, HTTP 400 for nonstream/JSON, no retry header, one upstream send, one terminal stream error and no message_stop. 413 fixture embeds a private-marker string in upstream body; final error must contain only proxy-safe copy. Add unknown and transient negative cases preserving existing classification. Cancel the response consumer and assert upstream cleanup/no extra dispatch where the harness can observe it. -MODIFY tests/claude-integration/claude-outbound.test.ts: update the existing classified invalid_request_error exact assertion to include context_length_exceeded; synthetic response.failed with status413 + context code and status-absent context code both produce invalid_request_error/context_length_exceeded without message_start or successful terminal. Explicit expectations, not parity. +MODIFY tests/claude-integration/claude-outbound.test.ts: update the existing classified invalid_request_error exact assertion to include context_length_exceeded; synthetic response.failed with status 413 + context code and status-absent context code both produce invalid_request_error/context_length_exceeded without message_start or successful terminal. Explicit expectations, not parity. -MODIFY tests/responses/protocol-direct-encoders-messages.test.ts: direct AdapterEvent error with code context_length_exceeded reaches streaming frame and folded HTTP400; keep explicit oracle assertions. Update the test's legacyFold fixture narrow status rule to mirror the new ingress, without deriving expectations from production helpers. Keep unknown failures and translator limits in existing cases. +MODIFY tests/responses/protocol-direct-encoders-messages.test.ts: direct AdapterEvent error with code context_length_exceeded reaches streaming frame and folded HTTP 400; keep explicit oracle assertions. Update the test's legacyFold fixture narrow status rule to mirror the new ingress, without deriving expectations from production helpers. Keep unknown failures and translator limits in existing cases. -Run explicit test files plus tests/responses/responses-context-overflow.test.ts and existing claude-messages-endpoint.test.ts. Direct Arguments observe each target; baseline three-file command passed143 tests (001); new-file and endpoint coverage will run after implementation. Exact implementation command: `bun test tests/claude-integration/claude-outbound.test.ts tests/claude-integration/claude-messages-endpoint.test.ts tests/responses/protocol-direct-encoders-messages.test.ts tests/responses/responses-context-overflow.test.ts`. Add source-oracle test layout/file-size checks if a new test file is added. No source cap is to be increased. +Run explicit test files plus tests/responses/responses-context-overflow.test.ts and existing claude-messages-endpoint.test.ts. Direct Arguments observe each target; baseline three-file command passed 143 tests (001); new-file and endpoint coverage will run after implementation. Exact implementation command: `bun test tests/claude-integration/claude-outbound.test.ts tests/claude-integration/claude-messages-endpoint.test.ts tests/responses/protocol-direct-encoders-messages.test.ts tests/responses/responses-context-overflow.test.ts`. Add source-oracle test layout/file-size checks if a new test file is added. No source cap is to be increased. ## Exact documentation additions -MODIFY structure/data-planes/inbound-compat.md: add a short Messages error projection paragraph naming src/claude/outbound.ts, src/protocols/encoders/messages.ts and src/server/claude-messages.ts: classified context_length_exceeded remains invalid_request_error through SSE and HTTP400 through collection/failed JSON; unknown transient/transport and local translator limits retain their distinct handling. No implicit recovery send. +MODIFY structure/data-planes/inbound-compat.md: add a short Messages error projection paragraph naming src/claude/outbound.ts, src/protocols/encoders/messages.ts and src/server/claude-messages.ts: classified context_length_exceeded remains invalid_request_error through SSE and HTTP 400 through collection/failed JSON; unknown transient/transport and local translator limits retain their distinct handling. No implicit recovery send. MODIFY docs-site/src/content/docs/guides/claude-code.md after Auto context warning: explain that a model's advertised window is not a guarantee that a tool-heavy serialized request fits. A classified input-limit error is terminal; reduce current input or compact earlier. If manual compact also exceeds input limits, preserve original history and compact a fork with fewer enabled tool/MCP schemas if the client supports it. Do not promise that changing client accounting or a [1m] marker raises upstream limits; recommend inspecting actual model metadata rather than hardcoding reporter measurements as supported thresholds. diff --git a/tests/claude-integration/claude-outbound.test.ts b/tests/claude-integration/claude-outbound.test.ts index 6c87113c111..d2b00461db7 100644 --- a/tests/claude-integration/claude-outbound.test.ts +++ b/tests/claude-integration/claude-outbound.test.ts @@ -1837,35 +1837,39 @@ describe("Messages ingress context errors", () => { async function withMessages(upstreamResponse: () => Response, stream: boolean, check: (response: Response, hits: () => number) => Promise, canonical = false) { let hits = 0; const upstream = Bun.serve({ hostname: "127.0.0.1", port: 0, fetch() { hits++; return upstreamResponse(); } }); - let canonicalSends = 0; - const config = { port: 0, hostname: "127.0.0.1", defaultProvider: "native", providers: { - native: { adapter: "openai-responses", baseUrl: `${upstream.url.origin}/v1`, authMode: "forward", allowPrivateNetwork: true }, - } } as OcxConfig; - if (canonical) { - config.providers.native!.baseUrl = "https://chatgpt.com/backend-api/codex"; - globalThis.fetch = ((input, init) => { - const url = input instanceof Request ? input.url : String(input); - if (url === "https://chatgpt.com/backend-api/codex/responses") { - canonicalSends++; - return originalFetch(new URL("/v1/responses", upstream.url), init); - } - // A fixture must never make an unmocked external call. - if (!url.startsWith("http://127.0.0.1:") && !url.startsWith("http://localhost:")) { - throw new Error("unexpected external request in context fixture"); - } - return originalFetch(input, init); - }) as typeof fetch; - } - saveConfig(config); - const server = startServer(0); + let server: ReturnType | undefined; try { + let canonicalSends = 0; + const config = { port: 0, hostname: "127.0.0.1", defaultProvider: "native", providers: { + native: { adapter: "openai-responses", baseUrl: `${upstream.url.origin}/v1`, authMode: "forward", allowPrivateNetwork: true }, + } } as OcxConfig; + if (canonical) { + config.providers.native!.baseUrl = "https://chatgpt.com/backend-api/codex"; + globalThis.fetch = ((input, init) => { + const url = input instanceof Request ? input.url : String(input); + if (url === "https://chatgpt.com/backend-api/codex/responses") { + canonicalSends++; + return originalFetch(new URL("/v1/responses", upstream.url), init); + } + // A fixture must never make an unmocked external call. + if (!url.startsWith("http://127.0.0.1:") && !url.startsWith("http://localhost:")) { + throw new Error("unexpected external request in context fixture"); + } + return originalFetch(input, init); + }) as typeof fetch; + } + saveConfig(config); + server = startServer(0); const response = await fetch(new URL("/v1/messages", server.url), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ model: "native/gpt-test", stream, max_tokens: 32, messages: [{ role: "user", content: "synthetic input" }] }), }); await check(response, () => hits); if (canonical) expect(canonicalSends).toBe(1); - } finally { await server.stop(true); await upstream.stop(true); } + } finally { + try { await server?.stop(true); } + finally { await upstream.stop(true); } + } } async function expectContext(response: Response, stream: boolean) {