# Request-Tail Contract **Status:** decided policy — the maintainer's call, settled with PR #274. Both local 400s described below stay exactly as they are. This file is the written reasoning so the question is not re-litigated; `test/request-tail-contract.test.js` and `test/mutations/request-tail-contract.json` are the machine-checkable half. **Audience:** whoever is about to "fix" a request-tail rejection, or the per-surface difference between how the surfaces handle one. **Measured:** 2026-10-07 against `master` @ `df9d429` (worktree `.claude/worktrees/tail-contract`). The guard and converter line numbers below are re-derivable with the commands in [§6](#6-where-this-is-pinned); every other line number was hand-checked against the same tree on the same date. **Re-measured:** 2026-10-16 against `master` @ `d3f0490` — the §2 stage table carries those coordinates (the merges in between pushed the chat.js sites by 4–9 lines). The guard block (`:2994-3033`), the delegation lines and the §3 converter chains were **unchanged**, so their 2026-10-07 coordinates still hold. ## 1. The rule A request's **tail** decides whether it is forwarded. Both rejections happen locally, in the shared chat layer, before any backend, account, or cache is touched. | tail shape | what the product does | why (measured, not stylistic) | |---|---|---| | ends on `user` / `tool` / `function` (trailing `system` messages are ignored) | forwarded — the normal shape | the shape the upstream actually answers | | last non-system turn is **`assistant`** — content-independent, including the Anthropic "prefill" half-sentence | `400 invalid_request_error`, `param: "messages"`, *"The conversation must end with a user message or a tool result."* | `8334cea` moved the check into the shared layer and measured that an assistant-terminated conversation is **not supported upstream**: it comes back `UPSTREAM_INTERNAL`, and "two consecutive of those quarantine the account for 120s" (`src/handlers/chat.js:2971-2973`). Serving these shapes is not a missing feature — it is a way to get the caller's account quarantined. | | the **newest user turn anywhere** is empty — trims to zero text bytes (`""`, `[]`, whitespace-only text part) | `400 invalid_request_error`, `param: "messages"`, *"The last user message has empty content."* | `79cd990` (fix #28) measured it with the OpenClaw probe, scenario #14: `{role:"user", content:""}` was "answered against the system prompt as if it were the prompt, producing nonsense"; the fix was this 400, "before any upstream call". | Three properties of the empty-user row — all pinned by tests, because each surprises people: - It fires on the newest user turn **anywhere in the array**, not only on the tail. An agent loop that appends an empty user turn and then a tool result still gets the 400. - "Empty" means **pure-text** empty. `image_url` / file / audio parts count as content (that scope was chosen in `79cd990`); an image-only turn is not rejected by this check. - The assistant-tail check runs **first**: a conversation violating both reports *"must end with a user message…"*. ## 2. Where it is enforced One block: `src/handlers/chat.js:2994-3033` (assistant-tail check `:2994-3009`, empty-user check `:3010-3033`), inside `_handleChatCompletionsInner` (`:2890`). It returns **before**: | stage | first reached at | |---|---| | connect-path stream branch (`if (stream)`) | `chat.js:3663` | | default-path stream branch (`if (stream)`) | `chat.js:4539` | | backend selection (`selectBackend`) | `chat.js:3226` | | account acquisition (`acquireConnectAccount` / `waitForAccount`) | `chat.js:3521` / `:4704` | | response cache (first `cacheGet`) | `chat.js:4587` | Every surface delegates into this function: `/v1/chat/completions` (`src/server.js:610`), `/v1/completions` (`src/handlers/completions.js:97`), `/v1/responses` (`src/handlers/responses.js:1436`), `/v1/messages` (`src/handlers/messages.js:1657`), Gemini (`src/handlers/gemini.js:768`). No surface can bypass the checks, and a rejected request never touches a backend, an account, or the cache. ## 3. The per-surface difference — documented, not endorsed The guard is shared; the input is not. The Anthropic and Gemini converters normalise turns *before* delegating, and a turn that yields **no text / image / tool part** is dropped on the way in: the per-turn push chains in `anthropicToOpenAI` (`src/handlers/messages.js:741-753`) and `geminiToOpenAI` (`src/handlers/gemini.js:235-247`) have no final `else`. The textless variants of **both rejected shapes** are therefore **served** on `/v1/messages` and Gemini, while `/v1/chat/completions` and `/v1/responses` reach the guard and get the 400s — the Responses converter keeps a zero-length content array empty, so predicate B fires there too. (It used to stringify `[]` into the two-character junk string `"[]"`, which slipped past the guard until the 2026-10-07 review; fixed and pinned in `test/request-tail-contract.test.js`.) The boundary is by *yield*, not by emptiness, and it is exact: on the Anthropic surface a `content: ''` string is materialised as an empty-content message and still receives the same 400; on Gemini `parts: [{text: ''}]` does too. Only a turn producing no parts at all is dropped. This difference is **documented, not endorsed**. Each side has its own rationale — the converters are normalisers (a turn with nothing in it carries nothing to forward), the guard is a class rejection at the shared layer — but neither is derived from the other, and a future unification needs its own evidence: - if the converters stop dropping, currently-served shapes start returning 400; - if the shared layer adopts a converter-style drop, the guard loses its class check and the requests it exists to keep away from the upstream reach it again. Either move reds the tests in §6 — make the argument with a measurement, not by editing a pin. ## 4. What is deliberately NOT promised Nothing here constrains the tail of `/v1/completions`. `handleCompletions` (`src/handlers/completions.js:64`) refuses `stream` (`:72`), rejects a blank prompt with its own 400 (`param: "prompt"`, `completions.js:83-88`) and synthesises exactly one non-empty `user` turn from `prompt` (`completions.js:100`). It cannot produce an assistant tail or an empty newest user turn, so neither rule above applies. Do not extend this contract there by analogy; that route has nothing to decide. ## 5. How to change it The current rule is a **measurement, not a preference**. What would have to exist first: 1. **An upstream that can serve the shape without quarantining the account, measured.** For the assistant tail that means the `8334cea` class of probe (live: it returned `UPSTREAM_INTERNAL` and account penalties, not an answer). For the empty user turn, evidence that the model answers sensibly instead of "against the system prompt as if it were the prompt" (the `79cd990` scenario-#14 probe). 2. **The policy call, recorded.** PR #274's consumer evidence is about a client wanting the shape; it is not evidence about the upstream, and the maintainer has ruled the choice is his. Bring the measurement, not the demand. 3. **Update all pins together** — the guard block, `test/request-tail-contract.test.js`, and `test/mutations/request-tail-contract.json` — and say what changed the measurement. A guard whose tests must be edited to keep passing is exactly the failure this contract exists to prevent. ## 6. Where this is pinned | what | where | |---|---| | the pre-existing tail-assistant 400 on the chat surface | `test/responses-chain-scope.test.js:106-137` | | empty-user shapes (including the tool-tail case), the streaming refusals, the predicate order, the Anthropic/Gemini drop difference, the with-text boundary, and the Responses surface's empty-array 400 | `test/request-tail-contract.test.js` | | mutants that must red that file | `test/mutations/request-tail-contract.json` | | the shared layer forwards orphan `tool` / `function` results instead of sweeping them (PR #274 M1: a re-added `repairUnanswerableMessages`-style pre-pass reds it; the PR's own pins call `normalizeMessagesForCascade` directly and cannot see a pre-pass above it) | `test/chat-orphan-guard.test.js` | Provenance, 2026-10-07, `master` @ `df9d429`: - guard and converter line numbers: `grep -n 'ANSWERABLE_ROLES\|trimmedBytes === 0' src/handlers/chat.js` and `grep -n 'textParts.length' src/handlers/messages.js src/handlers/gemini.js` - §2 stage rows (first occurrences): `grep -n 'await acquireConnectAccount(\|selectBackend({ modelInfo })\|await waitForAccountFn(\|const cached = cacheShareable\|if (stream)' src/handlers/chat.js` (2026-10-16 @ `d3f0490` 用同一条命令重导过:3663 / 4539 / 3226 / 3521 / 4704 / 4587; 其余各组坐标在同一天重跑后未变) - surface delegation lines: `grep -n '|| handleChatCompletions' src/handlers/completions.js src/handlers/responses.js src/handlers/messages.js src/handlers/gemini.js` and `grep -n 'handleChatCompletions(body' src/server.js` - §4 completions numbers: `grep -n 'Streaming is not supported\|promptToText(body.prompt)\|messages: \[{ role' src/handlers/completions.js` - behaviour and counts: `node --import ./scripts/mutation-network-deny.mjs --import ./test/setup-env.mjs --test --test-force-exit test/request-tail-contract.test.js` → 17 pass / 0 fail; `node scripts/spec-baseline-check.mjs request-tail-contract.json` → `17 pass + 0 approved skips = 17 [MEASURED_NO_SKIPS]` - commits: `git log --oneline -1 8334cea` and `git log --oneline -1 79cd990`