From 61e2237374cc6e3871960f7e9f07d915245ea7b4 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 10:53:40 +0900 Subject: [PATCH 1/2] docs: plan independent caller and Devin identity replacements --- .../_plan/261004_cache_identity/000_plan.md | 29 +++++++++++++++++++ .../_plan/261004_cache_identity/010_caller.md | 23 +++++++++++++++ .../_plan/261004_cache_identity/020_devin.md | 23 +++++++++++++++ 3 files changed, 75 insertions(+) create mode 100644 devlog/_plan/261004_cache_identity/000_plan.md create mode 100644 devlog/_plan/261004_cache_identity/010_caller.md create mode 100644 devlog/_plan/261004_cache_identity/020_devin.md diff --git a/devlog/_plan/261004_cache_identity/000_plan.md b/devlog/_plan/261004_cache_identity/000_plan.md new file mode 100644 index 00000000000..b0e41f6b7d9 --- /dev/null +++ b/devlog/_plan/261004_cache_identity/000_plan.md @@ -0,0 +1,29 @@ +# Two independent cache-identity fixes + +Public callers can provide a conversation marker without the session header consumed downstream, while Devin currently allocates a trajectory for every turn. This unit prepares separate replacements for #6521 and #6488, preserving their useful behavior and adding deterministic identity/lifetime coverage. Maintainers use this record to review coverage and decide future integration; publication is the terminal outcome, not merge or release. + +## Scope and completion + +Satisfy-spec HOTL, triggered by the next-release cache lane. Class C4 for identity and credential scoping. First lock this docs-only roadmap, then execute `010_caller.md`, then `020_devin.md` serially on independent branches from dev. The serial order avoids shared-checkout branch collisions; neither runtime patch depends on the other. + +Allowed: scoped local commits, pushes, two ordinary dev PRs, focused verification, read-only GitHub evidence and gpt-6.1-sol leaf implementation/review through the available V1 tools. Excluded: merges, release/version edits, installed-runtime changes, live upstream/account writes, source closure, branch deletion, native stacks, unrelated edits, outbound peer messages. Security working notes stay in ignored scratch. No token/cost or wall-clock budget was imposed; use local fixtures and ordinary PR CI only, no paid/live provider probes. + +DONE requires published attached replacement PRs, source coverage and author trailers, passing focused regressions/static gates, independent review and inspected current-head CI. Missing broad/native/live proof keeps a draft with explicit limits. A material scope conflict returns to main for decision; external blockage is reported truthfully under host rules. Evidence lives in ignored lane scratch and goalplan receipts; public records contain only source provenance and outcomes. + +## Source and verification + +Baseline: `0818ea1812a028e1c14cd0b0511b44863407bc52`. +- #6521: `011ab507fcf03ae86933098de1994b25ecfd0495`, mayigululu-hash. +- #6488: `a0b199fc6b96367fb174f6488a7a45eb58eccd54`, Hanqing Zhao (@HQ1995). + +Original live-cache measurements are author evidence in the source PRs, not new measurements or promised savings. Both source diffs were inspected; neither is an inherited stack. Read-only native membership queries returned empty lists. + +Focused baseline command: `bun test tests/providers/devin-prompt-cache.test.ts tests/providers/xai/grok-session-identity.test.ts tests/server/loopback-listener-admission.test.ts`: 59 pass, 0 fail. Dependencies installed with `bun install --frozen-lockfile` (exit 0). Each implementation runs its named behavior tests, typecheck, structure/privacy/layout/ratchet and docs build. Full local and changed suites are impractical during concurrent lanes and release; broader platform coverage belongs to hosted PR checks. CI triggers on every pull request; feature pushes alone do not trigger the main workflow. No manual release workflow is needed. + +## Consultation + +Architect 01a10493-64d4-76c2-b14e-d1307ad82a43 proposed CS-01..04 and DT-01..04. Main accepts caller principal scoping and promotion before admission, while preserving the original request for Bun timeout ownership. Main accepts the bounded provider-local trajectory store and whole-wire field chain. Own thread headers outrank a parent-derived `_clientThreadId`; direct internal callers without a parent may retain that field as fallback. No idle TTL/logout integration is claimed: fixed capacity bounds retention and credential rotation creates a different key. The executable documents carry the decisions; same-architect reflection and independent audit are required before implementation. + +Architect reflection: ALIGNED, with three test refinements accepted: exercise actual combo recall separation; parent-only/direct fallback/session aliases; double release after reacquisition and named retry wire continuity. Baseline typecheck, structure, privacy, layout (18 tests) and ratchet passed. The gate commands are repository scripts/direct test paths and observe the planned target classes. No broad suite was run. + +Roadmap D conclusion: independent reviewer 01a1049a-9a43-7ee1-9e7a-58073e8eb9e7 found no blockers (VERDICT: PASS). Fresh-reader check understood answer, evidence, and next action without revision. The docs-only cycle locks the two independent plans. Next: execute caller, then Devin. No cache savings or runtime improvement has been measured in this cycle. diff --git a/devlog/_plan/261004_cache_identity/010_caller.md b/devlog/_plan/261004_cache_identity/010_caller.md new file mode 100644 index 00000000000..a5928f6f4e7 --- /dev/null +++ b/devlog/_plan/261004_cache_identity/010_caller.md @@ -0,0 +1,23 @@ +# Caller session header replacement + +Depends on roadmap only. Branch `codex/next-release-261004-cache-caller`, independent dev PR. Implements source #6521 with CS-01..04 refinements. Runtime scope is caller helper and serve-options; no global recall redesign. + +## File delta + +NEW `src/server/caller-session-identity.ts`: retain source `callerSessionId(headers)` validation: any explicit session_id/session-id/thread-id presence suppresses promotion, trim x-session-id and accept /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/. `withCallerSessionIdentity(req, admission)` returns the same request on skip, raw validated ID for loopback, and a stable opaque SHA-256 digest of a structured tuple of trusted `contextPrincipalIdOf(admission)` and conversation for authenticated callers. Unknown principal declines promotion. No body read, no persistent map, no raw credential storage. Clone with `new Request(req, {headers})` only when promoting. + +MODIFY `src/server/index/serve-options.ts`: import helper. Responses: after auth/origin checks form `sessionReq = withCallerSessionIdentity(withGrokSessionIdentity(req), admission)`; use sessionReq for runAdmittedHttpTurn and handleResponses. Messages: likewise form `sessionReq = withCallerSessionIdentity(req, admission)` for admission and handleClaudeMessages. Keep original req for timeout lookup, response CORS and cancellation callbacks. Grok and explicit identities win unchanged. Existing guards still run first. + +NEW `tests/server/caller-session-identity.test.ts`: carry all source cases and mocked handleResponses upstream-wire test, adapting signature to explicit loopback admission. Add explicit-empty precedence, exact 128/129 boundary, whitespace normalization, conflicting Grok/caller values, two trusted principals with same conversation, credential rotation and absent principal. Verify body bytes/method/url/abort and no-op object identity. Add behavioral ingress coverage for both Responses and Messages and verify admission receives the same promoted identity; use existing local fixture patterns, no live requests. Public combo recall separation may be demonstrated by resulting session lane keys and existing recall APIs. Source pins remain secondary coverage. + +MODIFY `tests/providers/xai/grok-session-identity.test.ts` and `tests/server/loopback-listener-admission.test.ts`: update changed source-wiring pins without dropping assertions. MODIFY `scripts/test-layout/layout.json` and `tests/fixtures/test-layout-expected.json`: add caller-session-identity to server. MODIFY `structure/runtime.md`, applicable inbound-compat/responses owner docs and `docs-site/src/content/docs/reference/proxy-formats.md`: describe headers, priority, trusted-principal namespace, loopback behavior, no cache-hit promise. Review mapped owner docs; update only affected contracts. + +## Activation and coverage + +Explicit or Grok header plus differing x-session-id -> explicit/Grok wire value. Invalid/absent x-session-id -> original request. Two different admitted principals with equal x-session-id -> distinct promoted lanes. Rotated trusted credential -> new lane. Abort original -> rewritten signal aborts. Responses/Messages fixture -> backend receives promoted header after ingress authorization. All original source behaviors are carried except authenticated raw-ID equality is deliberately replaced by principal-scoped identity, and unsupported cache-savings assertions are removed. + +Field chain: x-session-id creation is external caller; ingress validates and serializes session_id on Request; existing parsers consume it for affinity/logging and passthrough forwards it. No new persisted schema or deserializer. + +Verifier: `bun test tests/server/caller-session-identity.test.ts tests/providers/xai/grok-session-identity.test.ts tests/server/loopback-listener-admission.test.ts` plus typecheck, structure/privacy and both layout guards/file-size ratchet. These direct test paths observe this delta; source-only pins do not prove live behavior. Docs build observes the public page. No full/changed suite per resource contract. Independent security review required. Existing explicit/Grok caller-supplied identities retain existing semantics; this change makes no global authorization-isolation claim. + +Reflection refinement: use real combo recall read/write APIs for equal caller markers under different principals; same principal retains continuity. Test through actual admitted ingress in addition to helper tests. diff --git a/devlog/_plan/261004_cache_identity/020_devin.md b/devlog/_plan/261004_cache_identity/020_devin.md new file mode 100644 index 00000000000..afa7ddd4e67 --- /dev/null +++ b/devlog/_plan/261004_cache_identity/020_devin.md @@ -0,0 +1,23 @@ +# Devin conversation trajectory replacement + +Depends on roadmap; serial execution follows caller publication solely for checkout ownership. Branch `codex/next-release-261004-cache-devin` starts independently at refreshed dev. Implements #6488 with DT-01..04 refinements. + +## File delta + +NEW `src/adapters/devin/trajectory.ts`: small provider-local claim/release API and bounded process-local store. Key = fixed digest of structured tuple [devinCacheIdentity(resolvedApiKey,resolvedHost), conversation], retaining no raw token/conversation. Map holds at most 256 entries {id,active}; insertion order is recency. Existing inactive key becomes active and is refreshed. Matching active key -> unretained fresh request trajectory. For a new key evict oldest inactive entry; if all 256 active, return unretained fresh trajectory. Never evict active entries. Release closure marks only its entry inactive, safely repeatable. Unnamed turns return no override and keep per-request wire UUID fallback. No timers, persistence or new dependency. + +MODIFY `src/adapters/devin.ts`: import claim helper; derive conversation from own thread-id, then session_id/session-id/x-session-affinity, then parsed._clientThreadId only when no parent header identifies it as a shared parent. Preserve source direct internal _clientThreadId behavior without coalescing explicit siblings. Claim inside outer try after credential, tenant host, catalog and cancellation guards; pass trajectoryId into the existing chat request; release in finally after all returns/errors/retries. Claim remains live through reset/signature retry. + +MODIFY `src/adapters/devin/cloud-direct/chat.ts`: add optional trajectoryId to BuildArgs and CloudChatRequest, forward in streamChatEvents to buildGetChatMessageRequest, replace #15.1 randomUUID with `args.trajectoryId ?? crypto.randomUUID()`. Creation: adapter claim; serialization: protobuf #15.1; deserialization: none (outbound-only); consumer: remote Cognition plus local wire-decoding tests. No external support or hit-rate claim beyond original author report. + +MODIFY `tests/providers/devin-prompt-cache.test.ts`: preserve source wire override/default tests and named reuse/unnamed/overlap/HTTP400-release cases. NEW `tests/providers/devin-trajectory.test.ts`: pure store isolation/bounds plus adapter cases as appropriate. Cases: new request-scoped adapters reuse named ID; changed credential/host/conversation separates; own sibling thread IDs outrank shared parent; three overlapping turns distinct; later sequential turn reuses original; post-claim abort and failure release; >256 inactive names evict oldest; 256 active entries never evict, overflow unretained and distinct. Use deterministic barriers with bounded failure deadline and finally teardown. Public claim API is production API, no test-only reset export. + +MODIFY both test layout registries for the new providers test. MODIFY `structure/providers-and-adapters.md` and `docs-site/src/content/docs/reference/adapters.md`: describe credential/host/conversation scope, overlap, bounded process-local lifetime and no guarantee of upstream cache savings. Review other owning structure docs for affected semantics. + +## Verification and source coverage + +Carry all source runtime and regression behavior, with deliberate replacement of raw delimiter key, uncapped live set, and parent-first identity. Preserve optional field fallback and all existing reset/signature handling. Scoped implementation leaf owns runtime/tests/layout; main owns docs and verification. + +Run `bun test tests/providers/devin-*.test.ts tests/adapters/adapter-inner-send-budget-wiring.test.ts`, including new tests and existing originals, then typecheck, structure/privacy/layout/ratchet and docs build. Each conditional path above has a fixture that activates it and checks wire UUIDs or claim IDs. Actual native/live cache-hit measurements remain unrun. Publish draft pending broader hosted/native evidence, inspect exact-head checks and repair in-scope defects. + +Reflection refinements: parent-only request with populated parsed._clientThreadId must remain unnamed; direct fallback without a parent and each alias are exercised. Double release after reacquisition must not deactivate the new claim (release closure has a released flag). Named reset/signature retry wire requests retain the same trajectory within the live claim. From 89ae31b9ed1b74f85ed32135f19cdeac764d6343 Mon Sep 17 00:00:00 2001 From: JUN Date: Sun, 4 Oct 2026 11:04:38 +0900 Subject: [PATCH 2/2] fix(server): promote scoped caller conversation headers Refines #6521 with trusted-principal scoping and admission-time propagation. Co-authored-by: mayigululu-hash --- .../_plan/261004_cache_identity/010_caller.md | 2 + .../content/docs/reference/proxy-formats.md | 18 ++ scripts/test-layout/layout.json | 2 + src/server/caller-session-identity.ts | 29 +++ src/server/index/serve-options.ts | 11 +- structure/data-planes/inbound-compat.md | 4 + structure/runtime.md | 2 +- structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 2 + .../xai/grok-session-identity.test.ts | 3 +- tests/server/caller-session-identity.test.ts | 241 ++++++++++++++++++ tests/server/caller-session-ingress.test.ts | 100 ++++++++ .../loopback-listener-admission.test.ts | 2 +- 13 files changed, 410 insertions(+), 8 deletions(-) create mode 100644 src/server/caller-session-identity.ts create mode 100644 tests/server/caller-session-identity.test.ts create mode 100644 tests/server/caller-session-ingress.test.ts diff --git a/devlog/_plan/261004_cache_identity/010_caller.md b/devlog/_plan/261004_cache_identity/010_caller.md index a5928f6f4e7..645b3f7b323 100644 --- a/devlog/_plan/261004_cache_identity/010_caller.md +++ b/devlog/_plan/261004_cache_identity/010_caller.md @@ -21,3 +21,5 @@ Field chain: x-session-id creation is external caller; ingress validates and ser Verifier: `bun test tests/server/caller-session-identity.test.ts tests/providers/xai/grok-session-identity.test.ts tests/server/loopback-listener-admission.test.ts` plus typecheck, structure/privacy and both layout guards/file-size ratchet. These direct test paths observe this delta; source-only pins do not prove live behavior. Docs build observes the public page. No full/changed suite per resource contract. Independent security review required. Existing explicit/Grok caller-supplied identities retain existing semantics; this change makes no global authorization-isolation claim. Reflection refinement: use real combo recall read/write APIs for equal caller markers under different principals; same principal retains continuity. Test through actual admitted ingress in addition to helper tests. + +Implementation record: helper and both routes implemented; ingress regression moved into registered sibling `tests/server/caller-session-ingress.test.ts` to keep unit and endpoint concerns readable. The accidental compact-route edit found by main was removed before verification. Four behavior files passed 59 tests; layout/tooling/ratchet files passed 27 tests. Typecheck and docs build passed. Structure contracts consolidated into inbound-compat with links from capped runtime/Responses docs; their 600-line budgets are preserved. Publication waits for independent code review and current-head CI inspection. diff --git a/docs-site/src/content/docs/reference/proxy-formats.md b/docs-site/src/content/docs/reference/proxy-formats.md index 818d6a8fa47..67e49b772e4 100644 --- a/docs-site/src/content/docs/reference/proxy-formats.md +++ b/docs-site/src/content/docs/reference/proxy-formats.md @@ -170,6 +170,24 @@ for final text and `session.updated` with `session.status="closed"`. This is the native opencodex data-plane shape. The request body must be a JSON object with a non-empty `model`. `input` may be a string or an array of Responses items. +### Caller conversation header + +For HTTP `POST /v1/responses` and `POST /v1/messages`, a client can send a stable +`x-session-id` for each conversation. When no `session_id`, `session-id`, or `thread-id` +header is present, opencodex promotes the marker to `session_id`. Managed Grok identity +on Responses takes precedence too. Even an empty explicit header suppresses promotion. + +Use 1–128 ASCII letters, digits, `.`, `_`, `:`, or `-`, starting with a letter or digit. +Surrounding whitespace is trimmed; missing or invalid markers are ignored. Loopback +admission preserves the marker. Authenticated admission derives an opaque identifier +from the marker and the admitted credential principal, so different credentials do not +share a newly promoted identifier. Rotation changes this identifier; authenticated +requests without a trusted principal are left unchanged. + +Use distinct markers for distinct conversations. This preserves conversation continuity +for downstream consumers, but does not guarantee an upstream cache hit or measured savings. +Chat Completions, WebSocket frames, compact, and count_tokens do not use this promotion. + ### Accepted request fields | Area | Accepted shape | diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 29885b87eaf..812e21767a3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -9,6 +9,8 @@ "provider-antigravity-family-catalog.test.ts": "providers", "claude-subagent-model-force.test.ts": "claude-integration", "subagent-model-force-api.test.ts": "server", + "caller-session-identity.test.ts": "server", + "caller-session-ingress.test.ts": "server", "agent-subagent-force.test.ts": "cli", "compaction-event-ownership.test.ts": "responses", "zed-retained-budget.test.ts": "providers", diff --git a/src/server/caller-session-identity.ts b/src/server/caller-session-identity.ts new file mode 100644 index 00000000000..338da5973ef --- /dev/null +++ b/src/server/caller-session-identity.ts @@ -0,0 +1,29 @@ +/** Promote third-party conversation markers after ingress authentication and origin checks. */ +import { contextPrincipalIdOf, type DataPlaneAdmission } from "./auth-cors"; + +const SESSION_HEADERS = ["session_id", "session-id", "thread-id"] as const; +const SAFE_CALLER_SESSION_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; + +export function callerSessionId(headers: Headers): string | undefined { + if (SESSION_HEADERS.some(name => headers.has(name))) return undefined; + const value = headers.get("x-session-id")?.trim(); + if (!value || !SAFE_CALLER_SESSION_ID.test(value)) return undefined; + return value; +} + +/** Authenticated callers share continuity only within their trusted credential principal. */ +export function withCallerSessionIdentity(req: Request, admission: DataPlaneAdmission): Request { + const conversation = callerSessionId(req.headers); + if (!conversation) return req; + let sessionId = conversation; + if (admission.kind !== "loopback") { + const principal = contextPrincipalIdOf(admission); + if (!principal) return req; + sessionId = new Bun.CryptoHasher("sha256") + .update(JSON.stringify(["opencodex-caller-session-v1", principal, conversation])).digest("hex"); + } + const headers = new Headers(req.headers); + headers.set("session_id", sessionId); + // Preserve the unread body stream and propagate the original request's abort signal. + return new Request(req, { headers }); +} diff --git a/src/server/index/serve-options.ts b/src/server/index/serve-options.ts index 7ebaed8dd17..e62d352f667 100644 --- a/src/server/index/serve-options.ts +++ b/src/server/index/serve-options.ts @@ -203,6 +203,7 @@ import { readyProtocolMetadata } from "../../remote/protocol"; import { modelCapabilityFields } from "../models-capabilities"; import { createWebsocketHandler } from "./websocket-handler"; import { withGrokSessionIdentity } from "../../grok/session-identity"; +import { withCallerSessionIdentity } from "../caller-session-identity"; export type ServerIngress = "public" | "unauthenticated-loopback" | "hub-management" | "claude-intercept" | "hub-link"; @@ -1474,10 +1475,11 @@ export function createServeOptions(ctx: ServeOptionsContext) { logged = true; addFinalRequestLog(requestId, start, logCtx, status, meta); }; - return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => { + const sessionReq = withCallerSessionIdentity(withGrokSessionIdentity(req), admission); + return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => { let response: Response; try { - response = await handleResponses(withGrokSessionIdentity(req), config, logCtx, { + response = await handleResponses(sessionReq, config, logCtx, { turnAdmissionLease, admission, onRequestBodyRead: () => disableResponsesRequestTimeout(req, requestServer), @@ -1554,8 +1556,9 @@ export function createServeOptions(ctx: ServeOptionsContext) { // Logging is finalized inside handleClaudeMessages (Responses-vocab tap on the // pre-translation stream + native passthrough callbacks) — do not re-wrap the // translated Anthropic stream here. - return runAdmittedHttpTurn(req, policy, async turnAdmissionLease => withCors( - await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy, { claudeIntercept: ingress === "claude-intercept" }), + const sessionReq = withCallerSessionIdentity(req, admission); + return runAdmittedHttpTurn(sessionReq, policy, async turnAdmissionLease => withCors( + await handleClaudeMessages(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy, { claudeIntercept: ingress === "claude-intercept" }), req, policy, ), { requestId, start, logCtx }); diff --git a/structure/data-planes/inbound-compat.md b/structure/data-planes/inbound-compat.md index 765ef62bd07..b0f73ee5c77 100644 --- a/structure/data-planes/inbound-compat.md +++ b/structure/data-planes/inbound-compat.md @@ -190,6 +190,10 @@ block buffer and tracks appended output bytes incrementally. A caller cancellati terminal returns 499 / `client_cancelled`; an already accepted terminal keeps its result. Reader, timer, turn, and translator ownership are released through the existing lifecycle. +## HTTP caller conversation identity + +`src/server/caller-session-identity.ts` promotes validated `x-session-id` on HTTP Responses and Messages before turn admission in `src/server/index/serve-options.ts`. Explicit `session_id`, `session-id`, or `thread-id` presence wins, including empty values; managed Grok promotion runs first on Responses. The trimmed marker must start with an ASCII letter/digit, contain only letters, digits, dots, underscores, colons or hyphens, and stay within 128 characters. Loopback admission keeps it; authenticated admission scopes it with the trusted credential principal into an opaque SHA-256 identifier and skips promotion without that principal. Bodies and abort signals are preserved, and the original Request owns Bun timeout lookup. This provides continuity, not authorization or guaranteed cache hits. Existing explicit/Grok identities, Chat Completions, WebSocket frames, compact and count_tokens retain their behavior. + ## Chat conversation identity forwarding `src/server/chat-completions.ts` preserves caller `prompt_cache_key` on the Chat-to-Responses diff --git a/structure/runtime.md b/structure/runtime.md index 77476c0edc0..eaaef86bb72 100644 --- a/structure/runtime.md +++ b/structure/runtime.md @@ -223,7 +223,7 @@ TTL or lock-file deletion participates in recovery. An explicit Codex integration OFF skips startup cache invalidation before the user-scoped catalog serialization lock is resolved. Explicit `sync` and `sync-cache` retain their catalog-only override. -`startServer` composes up to four sockets in one synchronous startup transaction: the public data listener, optional unauthenticated data-loopback and hub-management listeners, and the optional `hub-link` listener, which opens only for a recorded link and persists its concrete `127.0.0.1:`. Linked-machine data uses the [connection-bound relay contract](remote-link.md#connection-bound-relay-authentication); client-local credentials and routing policy remain unchanged. +`startServer` composes up to four sockets in one synchronous startup transaction: the public data listener, optional unauthenticated data-loopback and hub-management listeners, and the optional `hub-link` listener, which opens only for a recorded link and persists its concrete `127.0.0.1:`. HTTP Responses/Messages apply the [caller header contract](data-planes/inbound-compat.md#http-caller-conversation-identity) before turn admission and dispatch. Linked-machine data uses the [connection-bound relay contract](remote-link.md#connection-bound-relay-authentication); client-local credentials and routing policy remain unchanged. The data-loopback socket serves a fixed data-plane allowlist: Responses and its compact sibling, the native search relay, the standalone Images POSTs, keyed file/stream transcription, `GET /v1/models`, the realtime voice shapes, and the Anthropic and OpenAI chat wires the host's own local clients speak — `POST /v1/messages`, diff --git a/structure/transports/responses.md b/structure/transports/responses.md index 760898a3147..aaa09a62464 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -11,7 +11,7 @@ When a successful streamed native response has a missing or unrecognized non-JSO ## Responses HTTP/SSE -Optional Codex memory selection enters `src/server/responses/request-prepare.ts` for HTTP and WebSocket frames, as specified in [memory phase routing](responses-failover.md#memory-phase-routing); an unset phase keeps its existing route and starts no background work. Responses request preparation stabilizes incoming `` under `skills.catalog_refresh`: `per_session` (default) reuses the first received catalog for a conversation; `per_turn` leaves the supplied catalog unchanged. Other instruction sections and user/tool content remain untouched. Requests without a reliable conversation identity bypass snapshots; shared prompt-cache cohorts are not conversation identities. Only a body with exactly one catalog block across its instructions and developer/system content takes part; two or more pass through unchanged. A known snapshot is substituted before parsing, but a new catalog is stored only when preparation reaches its success return, so a request rejected by parsing or admission pins nothing. Without a named principal, snapshots are shared by conversation id only on a server that requires no data-plane auth. Snapshots are process-local, expire after four idle hours, and use bounded LRU retention; oversized blocks bypass caching. The dashboard's `src/codex/prompt-layers.ts` and `src/codex/prompt-text-probe.ts` continue observing current files for previews and do not own session snapshots. +HTTP ingress applies the [caller header contract](../data-planes/inbound-compat.md#http-caller-conversation-identity) before admission and dispatch. Optional Codex memory selection enters `src/server/responses/request-prepare.ts` for HTTP and WebSocket frames, as specified in [memory phase routing](responses-failover.md#memory-phase-routing); an unset phase keeps its existing route and starts no background work. Responses request preparation stabilizes incoming `` under `skills.catalog_refresh`: `per_session` (default) reuses the first received catalog for a conversation; `per_turn` leaves the supplied catalog unchanged. Other instruction sections and user/tool content remain untouched. Requests without a reliable conversation identity bypass snapshots; shared prompt-cache cohorts are not conversation identities. Only a body with exactly one catalog block across its instructions and developer/system content takes part; two or more pass through unchanged. A known snapshot is substituted before parsing, but a new catalog is stored only when preparation reaches its success return, so a request rejected by parsing or admission pins nothing. Without a named principal, snapshots are shared by conversation id only on a server that requires no data-plane auth. Snapshots are process-local, expire after four idle hours, and use bounded LRU retention; oversized blocks bypass caching. The dashboard's `src/codex/prompt-layers.ts` and `src/codex/prompt-text-probe.ts` continue observing current files for previews and do not own session snapshots. `/v1/responses` is the main Codex-facing endpoint. The server parses Responses input, routes to a provider, lets the selected adapter speak the upstream protocol, then bridges adapter events back to Responses-compatible streaming output. The routed provider name follows every adapter build, including retries and continuations; only `github-copilot` injects a configured `modelContextTiers` value as upstream `contextTier`, while unconfigured passthrough retains the caller field. For an opted-in key-auth provider, a hosted-search continuation stays bound to the API-key selection that served the first leg; the contract is the [hosted-search continuation binding](../providers-and-adapters.md#hosted-search-continuation-binding). diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index 6d7377fa4f3..7e81ea81b7f 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -5,6 +5,8 @@ "provider-antigravity-family-catalog.test.ts": "providers", "claude-subagent-model-force.test.ts": "claude-integration", "subagent-model-force-api.test.ts": "server", + "caller-session-identity.test.ts": "server", + "caller-session-ingress.test.ts": "server", "agent-subagent-force.test.ts": "cli", "compaction-event-ownership.test.ts": "responses", "zed-retained-budget.test.ts": "providers", diff --git a/tests/providers/xai/grok-session-identity.test.ts b/tests/providers/xai/grok-session-identity.test.ts index 2ebf3470e37..d8890a90eca 100644 --- a/tests/providers/xai/grok-session-identity.test.ts +++ b/tests/providers/xai/grok-session-identity.test.ts @@ -129,5 +129,6 @@ describe("Grok turns reach the ChatGPT Codex backend with session_id", () => { test("the /v1/responses route hands handleResponses the Grok-promoted request", () => { // Pins the one-line wiring; the cases above call the helper directly. const source = readFileSync(repoPath("src", "server", "index", "serve-options.ts"), "utf8"); - expect(source).toContain("await handleResponses(withGrokSessionIdentity(req), config, logCtx, {"); + expect(source).toContain("const sessionReq = withCallerSessionIdentity(withGrokSessionIdentity(req), admission);"); + expect(source).toContain("await handleResponses(sessionReq, config, logCtx, {"); }); diff --git a/tests/server/caller-session-identity.test.ts b/tests/server/caller-session-identity.test.ts new file mode 100644 index 00000000000..27086b87a85 --- /dev/null +++ b/tests/server/caller-session-identity.test.ts @@ -0,0 +1,241 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { callerSessionId, withCallerSessionIdentity } from "../../src/server/caller-session-identity"; +import { resolveDataPlaneAdmissionSecret, type DataPlaneAdmission } from "../../src/server/auth-cors"; +import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; +import { captureConfigGeneration } from "../../src/lib/state-store-sweeper"; +import { clearComboRecallForTests, recallComboForLane, rememberComboForLane } from "../../src/server/responses/combo-session-recall"; +import { withGrokSessionIdentity } from "../../src/grok/session-identity"; +import { handleResponses } from "../../src/server/responses/core"; +import { tryAdmitTurn } from "../../src/server/lifecycle"; +import type { OcxConfig } from "../../src/types"; +import { fakeChatGptJwt } from "../helpers/fake-chatgpt-jwt"; +import { installIsolatedCodexHome, type IsolatedCodexHome } from "../helpers/isolated-codex-home"; +import { removeTreeWithRetry } from "../helpers/remove-tree"; +import { repoPath } from "../helpers/repo-root"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +const LOOPBACK = { kind: "loopback", source: "loopback" } as const; +const SESSION = "0f1e2d3c-4b5a-6978-8796-a5b4c3d2e1f0"; + +function callerHeaders(extra: Record = {}): Headers { + return new Headers({ + "content-type": "application/json", + "x-session-id": SESSION, + ...extra, + }); +} + +describe("callerSessionId", () => { + test("promotes the caller's x-session-id when no session header is present", () => { + expect(callerSessionId(callerHeaders())).toBe(SESSION); + expect(callerSessionId(callerHeaders({ "x-session-id": `sess-${SESSION}` }))).toBe(`sess-${SESSION}`); + }); + + test("does nothing when the caller sent no x-session-id", () => { + const headers = callerHeaders(); + headers.delete("x-session-id"); + expect(callerSessionId(headers)).toBeUndefined(); + }); + + for (const explicit of ["session_id", "session-id", "thread-id"]) { + test(`an explicit ${explicit} header wins`, () => { + for (const value of ["caller", ""]) { + const req = new Request("http://localhost/v1/responses", { headers: callerHeaders({ [explicit]: value }) }); + expect(callerSessionId(req.headers)).toBeUndefined(); + expect(withCallerSessionIdentity(req, LOOPBACK)).toBe(req); + expect(req.headers.get(explicit)).toBe(value); + } + }); + } + + test("ignores empty and unsafe session ids", () => { + expect(callerSessionId(callerHeaders({ "x-session-id": "" }))).toBeUndefined(); + expect(callerSessionId(callerHeaders({ "x-session-id": "a b" }))).toBeUndefined(); + expect(callerSessionId(callerHeaders({ "x-session-id": "x".repeat(200) }))).toBeUndefined(); + }); + + test("normalizes whitespace and enforces the exact length and character boundaries", () => { + expect(callerSessionId(callerHeaders({ "x-session-id": ` ${SESSION} ` }))).toBe(SESSION); + expect(callerSessionId(callerHeaders({ "x-session-id": "a".repeat(128) }))).toBe("a".repeat(128)); + for (const value of ["a".repeat(129), ".abc", "_abc", ":abc", "-abc", "a/b", "a,b", "é", " "]) { + const req = new Request("http://localhost/v1/responses", { headers: callerHeaders({ "x-session-id": value }) }); + expect(callerSessionId(req.headers)).toBeUndefined(); + expect(withCallerSessionIdentity(req, LOOPBACK)).toBe(req); + } + expect(callerSessionId(callerHeaders({ "x-session-id": "A0.b_c:d-e" }))).toBe("A0.b_c:d-e"); + }); + + test("the rewritten request keeps its body and abort signal", async () => { + const controller = new AbortController(); + const original = new Request("http://localhost/v1/responses", { + method: "POST", headers: callerHeaders(), body: "{\"a\":1}", signal: controller.signal, + }); + const rewritten = withCallerSessionIdentity(original, LOOPBACK); + expect(rewritten.headers.get("session_id")).toBe(SESSION); + expect(original.headers.has("session_id")).toBe(false); + expect(rewritten.method).toBe(original.method); + expect(rewritten.url).toBe(original.url); + expect(rewritten.bodyUsed).toBe(false); + controller.abort(); + expect(rewritten.signal.aborted).toBe(true); + expect(await rewritten.text()).toBe("{\"a\":1}"); + }); + + test("returns the same request when nothing is promoted", () => { + const original = new Request("http://localhost/v1/responses", { method: "POST", body: "{}" }); + expect(withCallerSessionIdentity(original, LOOPBACK)).toBe(original); + }); + + test("composes after the Grok promotion without double-writing", () => { + const grok = withGrokSessionIdentity(new Request("http://localhost/v1/responses", { + method: "POST", + headers: new Headers({ "x-opencodex-grok": "1", "x-grok-conv-id": SESSION, "x-session-id": "different-caller" }), + body: "{}", + })); + const composed = withCallerSessionIdentity(grok, LOOPBACK); + expect(composed).toBe(grok); + expect(composed.headers.get("session_id")).toBe(SESSION); + expect(composed.headers.get("x-grok-conv-id")).toBe(SESSION); + }); +}); + +describe("Non-Codex callers reach the ChatGPT Codex backend with session_id", () => { + const originalFetch = globalThis.fetch; + let isolated: IsolatedCodexHome; + let home: string; + let previousHome: string | undefined; + let releaseSpendHome: (() => void) | undefined; + let token = ""; + + beforeEach(() => { + previousHome = process.env.OPENCODEX_HOME; + home = mkdtempSync(join(tmpdir(), "ocx-caller-session-")); + process.env.OPENCODEX_HOME = home; + isolated = installIsolatedCodexHome("ocx-caller-session-codex-"); + token = fakeChatGptJwt({ exp: Math.floor(Date.now() / 1000) + 86400, chatgpt_account_id: "fixture-caller-main" }); + writeFileSync(join(isolated.path, "auth.json"), JSON.stringify({ tokens: { access_token: token, account_id: "fixture-caller-main" } })); + releaseSpendHome = acquireOwnedSpendHome(); + }); + afterEach(() => { + releaseSpendHome?.(); + releaseSpendHome = undefined; + globalThis.fetch = originalFetch; + isolated.restore(); + if (previousHome === undefined) delete process.env.OPENCODEX_HOME; + else process.env.OPENCODEX_HOME = previousHome; + removeTreeWithRetry(home); + }); + + test("the upstream request carries the promoted session_id", async () => { + const cfg = { openaiProviderTierVersion: 2, providers: { + openai: { adapter: "openai-responses", authMode: "forward", codexAccountMode: "direct", baseUrl: "https://chatgpt.com/backend-api/codex", models: ["gpt-5.6-luna"] }, + } } as OcxConfig; + const seen: Array<{ url: string; headers: Headers; body: Record }> = []; + globalThis.fetch = Object.assign(async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init); + if (request.url !== "https://chatgpt.com/backend-api/codex/responses") { + throw new Error("Unexpected outbound request in caller-session fixture"); + } + seen.push({ url: request.url, headers: request.headers, body: await request.json() as Record }); + return Response.json({ id: "resp_caller", object: "response", status: "completed", output: [], + usage: { input_tokens: 1, output_tokens: 0, total_tokens: 1 } }); + }, { preconnect() { /* No fixture opens an upstream socket. */ } }); + const lease = tryAdmitTurn(); + expect(lease).not.toBeNull(); + try { + const req = withCallerSessionIdentity(new Request("http://localhost/v1/responses", { + method: "POST", headers: callerHeaders({ authorization: `Bearer ${token}` }), + body: JSON.stringify({ model: "openai/gpt-5.6-luna", stream: false, store: false, input: "ping" }), + }), LOOPBACK); + const logCtx = { model: "", provider: "" } as Parameters[2]; + const response = await handleResponses(req, cfg, logCtx, { turnAdmissionLease: lease!, admission: { kind: "loopback", source: "loopback" }, inboundWire: "responses" }); + const text = await response.text(); + expect(response.status, text).toBe(200); + const wire = seen.at(-1)!; + expect(wire.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(wire.headers.get("session_id")).toBe(SESSION); + expect(logCtx.conversationId).toBeTruthy(); + } finally { lease?.release(); } + }); +}); + +test("the public routes hand their handlers the caller-promoted request", () => { + // Pins the two-line wiring; the cases above call the helper directly. + const source = readFileSync(repoPath("src", "server", "index", "serve-options.ts"), "utf8"); + expect(source).toContain("const sessionReq = withCallerSessionIdentity(withGrokSessionIdentity(req), admission);"); + expect(source).toContain("await handleResponses(sessionReq, config, logCtx, {"); + expect(source).toContain("const sessionReq = withCallerSessionIdentity(req, admission);"); + expect(source).toContain("await handleClaudeMessages(sessionReq, config, logCtx,"); + expect(source.match(/const sessionReq = withCallerSessionIdentity/g)).toHaveLength(2); +}); + +function admitted(key: string, source: "dedicated" | "bearer" = "dedicated"): DataPlaneAdmission { + const result = resolveDataPlaneAdmissionSecret(key, { apiKeys: [{ id: "same-key-id", name: "Caller fixture", createdAt: "2026-01-01T00:00:00.000Z", key }] }, source); + expect(result).not.toBeNull(); + return result!; +} + +function promoted(admission: DataPlaneAdmission, conversation = SESSION): Request { + return withCallerSessionIdentity(new Request("http://localhost/v1/responses", { + headers: callerHeaders({ "x-session-id": conversation }), + }), admission); +} + +describe("trusted caller namespaces", () => { + test("equal caller markers separate credentials and remain stable across credential transports", () => { + const first = promoted(admitted("caller-fixture-key-a")).headers.get("session_id"); + const second = promoted(admitted("caller-fixture-key-b")).headers.get("session_id"); + expect(first).toMatch(/^[a-f0-9]{64}$/); + expect(second).toMatch(/^[a-f0-9]{64}$/); + expect(first).not.toBe(second); + expect(first).not.toBe(SESSION); + expect(promoted(admitted("caller-fixture-key-a", "bearer")).headers.get("session_id")).toBe(first); + expect(promoted(admitted("caller-fixture-key-a"), "another-conversation").headers.get("session_id")).not.toBe(first); + }); + + test("environment credentials rotate into a fresh namespace", () => { + const previous = process.env.OPENCODEX_API_AUTH_TOKEN; + try { + process.env.OPENCODEX_API_AUTH_TOKEN = "caller-environment-fixture-a"; + const first = resolveDataPlaneAdmissionSecret(process.env.OPENCODEX_API_AUTH_TOKEN, {})!; + process.env.OPENCODEX_API_AUTH_TOKEN = "caller-environment-fixture-b"; + const rotated = resolveDataPlaneAdmissionSecret(process.env.OPENCODEX_API_AUTH_TOKEN, {})!; + expect(first.kind).toBe("environment"); + expect(promoted(rotated).headers.get("session_id")).not.toBe(promoted(first).headers.get("session_id")); + } finally { + if (previous === undefined) delete process.env.OPENCODEX_API_AUTH_TOKEN; + else process.env.OPENCODEX_API_AUTH_TOKEN = previous; + } + }); + + test("unknown principals decline promotion despite caller-supplied identity claims", () => { + for (const admission of [{ kind: "configured", keyId: "known-id", source: "dedicated" }, + { kind: "environment", source: "bearer" }] satisfies DataPlaneAdmission[]) { + const request = new Request("http://localhost/v1/responses", { headers: callerHeaders({ + authorization: "Bearer untrusted-fixture", "x-context-principal-id": "spoofed-principal", + }) }); + expect(withCallerSessionIdentity(request, admission)).toBe(request); + } + }); + + test("real combo recall separates equal markers across principals and keeps same-principal continuity", () => { + clearComboRecallForTests(); + const config: OcxConfig = { port: 0, defaultProvider: "fixture", providers: { + fixture: { adapter: "openai-responses", baseUrl: "https://fixture.example.test/v1" }, + }, combos: { alpha: { targets: [{ provider: "fixture", model: "model-a" }] }, + beta: { targets: [{ provider: "fixture", model: "model-b" }] } } }; + const lane = (key: string) => sessionLaneIdFromRequest(promoted(admitted(key)).headers); + try { + rememberComboForLane(lane("recall-fixture-a"), "alpha", { provider: "fixture", model: "model-a" }, "visible-model", captureConfigGeneration()); + expect(recallComboForLane(config, lane("recall-fixture-a"), "visible-model")).toBe("alpha"); + expect(recallComboForLane(config, lane("recall-fixture-b"), "visible-model")).toBeUndefined(); + rememberComboForLane(lane("recall-fixture-b"), "beta", { provider: "fixture", model: "model-b" }, "visible-model", captureConfigGeneration()); + expect(recallComboForLane(config, lane("recall-fixture-a"), "visible-model")).toBe("alpha"); + expect(recallComboForLane(config, lane("recall-fixture-b"), "visible-model")).toBe("beta"); + expect(recallComboForLane(config, lane("recall-fixture-rotated"), "visible-model")).toBeUndefined(); + } finally { clearComboRecallForTests(); } + }); +}); diff --git a/tests/server/caller-session-ingress.test.ts b/tests/server/caller-session-ingress.test.ts new file mode 100644 index 00000000000..8762c058c12 --- /dev/null +++ b/tests/server/caller-session-ingress.test.ts @@ -0,0 +1,100 @@ +import { describe, expect, spyOn, test } from "bun:test"; +import * as callerIdentity from "../../src/server/caller-session-identity"; +import * as lifecycle from "../../src/server/lifecycle"; +import * as bodyAdmission from "../../src/server/inbound-body-admission"; +import * as timeouts from "../../src/server/responses/fetch-helpers"; +import { sessionLaneIdFromRequest } from "../../src/server/request-log-conversation"; +import { ACCESS, PROXY_KEY, reserveIngressFixture } from "../helpers/reserve-ingress-fixture"; + +describe("caller session identity through admitted HTTP ingress", () => { + for (const transport of ["responses", "messages"] as const) { + test(`${transport} uses one promoted request for admission and upstream dispatch`, async () => { + const fixture = await reserveIngressFixture({ configure: config => { + config.apiKeys = [{ id: "second-caller", name: "Second fixture", createdAt: "2026-01-01T00:00:00.000Z", key: "caller-ingress-fixture-second" }]; + } }); + const blockedFetch = globalThis.fetch; + const wires: Headers[] = []; + const lanes: Array = []; + const promoted: Array<{ original: Request; rewritten: Request }> = []; + const timedOut: Request[] = []; + const bodyRequests: Request[] = []; + const realPromotion = callerIdentity.withCallerSessionIdentity; + const promotionSpy = spyOn(callerIdentity, "withCallerSessionIdentity").mockImplementation((req, admission) => { + const rewritten = realPromotion(req, admission); + promoted.push({ original: req, rewritten }); + return rewritten; + }); + const realAdmission = lifecycle.tryAdmitTurn; + const admissionSpy = spyOn(lifecycle, "tryAdmitTurn").mockImplementation(lane => { + lanes.push(lane); + return realAdmission(lane); + }); + const realBodyWork = bodyAdmission.runAdmittedBodyWork; + const bodySpy = spyOn(bodyAdmission, "runAdmittedBodyWork").mockImplementation((req, policy, limit, work, refusal) => { + bodyRequests.push(req); + return realBodyWork(req, policy, limit, work, refusal); + }); + const realTimeout = timeouts.disableResponsesRequestTimeout; + const timeoutSpy = spyOn(timeouts, "disableResponsesRequestTimeout").mockImplementation((req, server) => { + timedOut.push(req); + return realTimeout(req, server); + }); + globalThis.fetch = Object.assign(async (input: RequestInfo | URL, init?: RequestInit) => { + const req = new Request(input, init); + if (req.url === "https://chatgpt.com/backend-api/codex/responses") wires.push(req.headers); + // The existing fixture rejects unknown outbound requests; never fall through to live fetch. + return blockedFetch(req); + }, { preconnect() { /* No fixture opens an upstream socket. */ } }); + try { + const headers = { "x-session-id": "shared-caller", "x-opencodex-api-key": PROXY_KEY, authorization: `Bearer ${ACCESS}` }; + const first = await fixture.request("public", transport, "openai/gpt-5.6-luna", headers); + expect(first.status, first.text).toBe(200); + expect(promoted).toHaveLength(1); + expect(promoted[0]!.rewritten).not.toBe(promoted[0]!.original); + expect(promoted[0]!.rewritten.headers.get("session_id")).toMatch(/^[a-f0-9]{64}$/); + expect(wires).toHaveLength(1); + expect(wires[0]!.get("session_id")).toBe(promoted[0]!.rewritten.headers.get("session_id")); + expect(lanes).toEqual([sessionLaneIdFromRequest(wires[0]!)]); + expect(bodyRequests).toEqual([promoted[0]!.rewritten]); + expect(bodyRequests[0]).toBe(promoted[0]!.rewritten); + expect(timedOut.length).toBeGreaterThan(0); + expect(timedOut.every(req => req === promoted[0]!.original)).toBe(true); + + const second = await fixture.request("public", transport, "openai/gpt-5.6-luna", headers); + expect(second.status, second.text).toBe(200); + expect(wires[1]!.get("session_id")).toBe(wires[0]!.get("session_id")); + expect(lanes[1]).toBe(lanes[0]); + + const local = await fixture.request("local", transport, "openai/gpt-5.6-luna", { "x-session-id": "local-caller", authorization: `Bearer ${ACCESS}` }); + expect(local.status, local.text).toBe(200); + expect(wires[2]!.get("session_id")).toBe("local-caller"); + expect(lanes[2]).toBe(sessionLaneIdFromRequest(wires[2]!)); + + const other = await fixture.request("public", transport, "openai/gpt-5.6-luna", { + ...headers, "x-opencodex-api-key": "caller-ingress-fixture-second", + }); + expect(other.status, other.text).toBe(200); + expect(wires[3]!.get("session_id")).not.toBe(wires[0]!.get("session_id")); + expect(lanes[3]).not.toBe(lanes[0]); + const explicit = await fixture.request("public", transport, "openai/gpt-5.6-luna", { ...headers, session_id: "explicit-winner" }); + expect(explicit.status, explicit.text).toBe(200); + expect(wires[4]!.get("session_id")).toBe("explicit-winner"); + expect(promoted[4]!.rewritten).toBe(promoted[4]!.original); + + const before = { promotions: promoted.length, lanes: lanes.length, wires: wires.length }; + const unauthorized = await fixture.request("public", transport, "openai/gpt-5.6-luna", { "x-session-id": "shared-caller" }); + expect(unauthorized.status).toBe(401); + const hostile = await fixture.request("public", transport, "openai/gpt-5.6-luna", { ...headers, Origin: "https://hostile.example.test" }); + expect(hostile.status).toBe(403); + expect({ promotions: promoted.length, lanes: lanes.length, wires: wires.length }).toEqual(before); + } finally { + timeoutSpy.mockRestore(); + bodySpy.mockRestore(); + admissionSpy.mockRestore(); + promotionSpy.mockRestore(); + globalThis.fetch = blockedFetch; + await fixture.close(); + } + }); + } +}); diff --git a/tests/server/loopback-listener-admission.test.ts b/tests/server/loopback-listener-admission.test.ts index 8e30cdc17d0..056e117b74a 100644 --- a/tests/server/loopback-listener-admission.test.ts +++ b/tests/server/loopback-listener-admission.test.ts @@ -76,7 +76,7 @@ describe("loopback listener policy view", () => { 'await handleClaudeCountTokens(req, config, policy, { claudeIntercept: ingress === "claude-intercept" })', ); expect(source.slice(messagesStart, chatStart)).toContain( - 'await handleClaudeMessages(req, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy, { claudeIntercept: ingress === "claude-intercept" })', + 'await handleClaudeMessages(sessionReq, config, logCtx, { requestId, start, turnAdmissionLease, admission }, policy, { claudeIntercept: ingress === "claude-intercept" })', ); for (const branch of [ source.slice(countTokensStart, messagesStart),