Ctrl K
ComunidadGuías…

evidence/FixV08V10V12V14.md

Rama master · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# FixV08V10V12V14 — backpressure, structured output, ordered Responses items, Cascade controls

Lane: one worktree, branch `fix/v08-v10-v12-v14` from master `b3877065`.
Files owned by this lane: `src/handlers/chat.js`, `src/handlers/responses.js`, `src/windsurf.js`.
Declared cross-lane touches (both necessary for the row to work at all, both minimal and isolated):

| File | Why | Shape of change |
|---|---|---|
| `src/client.js` | V-14: `cascadeChat` is the only path from the handler to the wire builder | destructure `completion`, forward `maxOutputTokens` to the one `buildSendCascadeMessageRequest` call |
| `src/devin-connect-openai.js` | V-08: the Connect adapter called `send(...)` without awaiting, so no writer could ever hold its producer | `await` prefixes only; `prime`/`sendContent`/`emitToolCalls` became `async` (Main approved in IRC: V-15 already merged, no live owner) |

New modules: `src/stream-writer.js` (the one bounded writer), `src/json-schema.js` (bounded JSON Schema validator).

---

## V-08 — one abort-aware bounded writer for every SSE exit

**Defect (KH-001).** `captureRes.write()` returned a constant `true`, so chat's bounded drain never armed behind a translator; the Connect `send`, its heartbeat and its final `[DONE]` discarded `res.write()`'s verdict; the Responses translator discarded it too. A slow reader bought unbounded upstream production.

**Change**
- `src/stream-writer.js` — `createBoundedWriter({res, signal, enabled, maxWaitMs, onTimeout})`. Contract: one outstanding drain wait (no payload queue), the false-returning frame is never resent, the wait is raced against `drain`/`close`/`error`/abort and settles exactly once, gone is re-checked before every write and after every wait, half-open clients are bounded by `WINDSURFAPI_MAX_DRAIN_MS` (default 120 s). `write` returns the real verdict; `writeFrame` awaits the drain first; `awaitDrain` throws `CLIENT_ABORTED`; `settle` never throws.
- `src/handlers/chat.js` — the inline Cascade C2 block is now a thin wrapper over that module (`streamResponse` keeps `enabled: useCascade`; the legacy gRPC channel stays synchronous and unarmed, unchanged). The **Connect** handler gets its own writer armed unconditionally: `send` returns the emission promise, the heartbeat is skipped while pressured, the shutdown notice stays deliberately synchronous, and the final `[DONE]` waits out the drain.
- `src/handlers/responses.js` — the translator writes through the same module, `_write`/`send`/`feed` return the verdict, `createCaptureRes.write()` returns `translator.egressOk` and forwards the real socket's `drain`/`error` to the producer, the commit gate also refuses a `clientGone` (drain-timeout) turn, and the `[DONE]` sentinel waits for the drain.
- `src/devin-connect-openai.js` — every `send(...)` is awaited.

**Red → green.** `test/connect-stream-backpressure-drain.test.js`
- Baseline: 4 of 5 defect tests fail (`write()=false … pauses upstream production`, `terminal frame and [DONE] wait`, `drain timeout`, `captureRes reports the real verdict`); the fast-reader control passes.
- Fixed: 6/6 pass, including "no frame may be produced while the socket has not drained" and "the false frame is never resent".

**Known boundary.** `src/handlers/messages.js` has its own `createCaptureRes` copy (lane V-02). It still returns `true`, so the Messages route does not propagate pressure into the Cascade producer. Out of this lane's file scope — flagged, not silently claimed.

---

## V-10 — structured output stops inventing values and checks the real schema

**Defect (KH-013).** `valueFromToolFacts` returned literal `true` for `ok`, lifted `version`/`name` out of unrelated tool results by key name, and missing keys became `null`. A `response_format: {type:'json_schema'}` request was never validated: the proxy answered 200 for a body the caller's own validator rejects.

**Change**
- `src/json-schema.js` — `validateJsonSchema(value, schema)`: type/enum/const, object `properties`/`required`/`additionalProperties`, array `items`/`minItems`/`maxItems`/`uniqueItems`, string `minLength`/`maxLength`/`pattern`, numeric bounds/`multipleOf`, `allOf`/`anyOf`/`oneOf`/`not`, local `$ref` with a depth guard, OpenAPI `nullable`. Unknown keywords are ignored (annotation semantics), never a failure.
- `src/handlers/chat.js` — `valueFromToolFacts` and `collectToolFacts` are deleted; `stabilizeJsonPayload` projects only values present in the model's own payload and keeps `versionsMatch` only as a derivation over two emitted operands; `structuredOutputViolation()` + `structuredOutputError()` added; the caller's schema is threaded into both Cascade entry points and the Connect non-stream arm.
- Failure shape: `502 { error: { type: 'server_error', code: 'STRUCTURED_OUTPUT_NOT_CONFORMING', message: '… (path: reason) …' } }`. Non-stream: returned before cache/session/affinity side effects. Cascade stream: JSON content is buffered on that path, so nothing invalid has been delivered — the turn closes with the typed error frame and no `finish_reason:'stop'`/`[DONE]`, and the upstream spend is still billed.

**Red → green.** `test/structured-output-schema.test.js` (10/10 green) plus the replaced pin in `test/messages.test.js` (`does not invent values when the model drifts off the requested key set`). On the baseline the new file fails at import (the API did not exist) and the old pin asserted the invented `versionsMatch:true`.

**Known boundary.** The Connect **stream** arm passes the model's text through verbatim (no post-processing, no buffer), so a schema is not checked there. Fixing that requires buffering inside the adapter — a different change, not claimed.

---

## V-12 — Responses commits exact ordered items

**Defect (KH-009/KH-020/KH-045).** The commit kept one flat assistant message (all text concatenated, calls last), `committedToolCalls` kept only `function_call` (custom calls lost), stored the CLIENT name instead of the wire name, and GET regenerated items from that flat message with fresh ids.

**Change** (`src/handlers/responses.js`)
- `historyFromResponseItems(items, toolNameMap)` rebuilds the internal Chat history in output order: text runs keep their position, consecutive calls collapse into one assistant turn with several `tool_calls` (parallel-call shape, and it keeps the same-source run short), each call keeps its `call_id` and its **wire** name (`responseItemToolName`).
- The commit persists that ordered history plus a `__responses_items` sidecar on the last assistant message — the exact client-facing items (ids, types, `call_id`, `name`/`namespace`, `arguments`/`input`, status).
- The sidecar is stripped from every dispatch (`dispatchMessages`), so it never reaches the upstream body; the store keeps the annotated copy.
- `storedResponseBody` replays the persisted items when present (identical ids/types/names) and keeps the old reconstruction for entries written before this change.

**Red → green.**
- `test/responses-tool-history-persistence.test.js` (3/3 green): ordered GET replay, wire names + order + `call_id`s in the chained history, no sidecar leak, legacy flat entry still reconstructs. Baseline: the two defect tests fail, the legacy control passes.
- `test/responses-post-tool-text-item.test.js`: the pinned lossy test was replaced by `round-trips the ordered items: text, call, text — not one merged string` (8/8 green).

---

## V-14 — Cascade completion controls

**Defect (KH-005).** `temperature`/`top_p`/`top_k`/`max_tokens` never reached Cascade; the wire hardcoded `max_output_tokens = 32768`; a tiny requested cap still produced a long answer with `finish_reason:'stop'`.

**Change**
- `src/windsurf.js` — `normalizeCascadeMaxOutputTokens()` + `CASCADE_MAX_OUTPUT_TOKENS_CEILING`; `buildCascadeConfig` writes the caller's cap into `CascadePlannerConfig` field 6 (clamped 1…32768, unset keeps 32768 byte-identically); `buildSendCascadeMessageRequest` passes it through.
- `src/client.js` — `cascadeChat` forwards `completion.maxTokens` to the wire builder.
- `src/handlers/chat.js` — both Cascade entry points receive `completion`; the cap is enforced locally in the repo's own chars/4 units (non-stream truncates the returned content, stream cuts the content channel, JSON is capped before shaping) and the terminal becomes `finish_reason:'length'`; a capped turn caches what was delivered. `temperature`/`top_p`/`top_k` are **refused with 400 `invalid_request_error`** on the Cascade flow, naming the first parameter and pointing at `DEVIN_CONNECT=1`, which does honour them — the same convention the T1 gate already uses for seed/penalties/logit_bias.

**Red → green.** `test/cascade-completion-controls.test.js` (9/9 green): wire field 6 = 64, clamp behaviour, 400 for temperature/top_p/top_k on Cascade, Connect control still receives `{temperature, topP, topK}`, non-stream and stream cap → `length`, both no-cap controls unchanged. Baseline: the file fails at import (the API did not exist).

**Behaviour change to note for release.** A Cascade deployment (`DEVIN_CONNECT=0`) that sends `temperature`, `top_p` or `top_k` now gets a 400 instead of a silent drop. `test/openai-sampling-params.test.js`'s "allows a request with only temperature/top_p" and `test/openai-logprobs.test.js`'s sibling pin were written for the silent-drop behaviour and are **not** updated in this lane; they will fail until the maintainer accepts the contract change. This is a deliberate, audit-mandated trade (a success that ignores the request is the finding).

---

## Verification

```
# targeted (all green in this worktree)
node --import ./test/setup-env.mjs --test test/connect-stream-backpressure-drain.test.js      # 6/6
node --import ./test/setup-env.mjs --test test/structured-output-schema.test.js              # 10/10
node --import ./test/setup-env.mjs --test test/responses-tool-history-persistence.test.js    # 3/3
node --import ./test/setup-env.mjs --test test/responses-post-tool-text-item.test.js         # 8/8
node --import ./test/setup-env.mjs --test test/cascade-completion-controls.test.js           # 9/9
node --import ./test/setup-env.mjs --test test/messages.test.js                              # 84/84
node --import ./test/setup-env.mjs --test test/stream-backpressure-drain.test.js             # 10/10 (C2 refactor kept green)
node --import ./test/setup-env.mjs --test test/devin-connect-openai.test.js                  # 78/78 (adapter await refactor)
```

Red proof: the four new test files were copied into a clean worktree at master `b3877065` and run there — 8 defect tests fail, 3 controls pass, 2 files fail at import (APIs absent).

Full suite (`node scripts/run-tests.mjs`): **7048 tests, 6951 pass, 0 fail, 97 skipped, exit 0**.

One first-run failure was mine to fix: `test/mutations/responses-stream-done-sentinel.json`
anchored on the old one-line `[DONE]` write in `src/handlers/responses.js`, so both of its
mutants reported `hits=0`. The anchors now point at the sequential-emission block
(`await translator.settleEgress(); translator.writeRaw('data: [DONE]\n\n')`), and the two
mutants still describe the same defects (no sentinel at all / guard dropped but write kept).
`test/docs-consistency-guard.test.js` 47/47 and `test/smoke-matrix.test.js` 137/137 green after
the re-anchor.

Also updated for the V-14 contract change: `test/openai-sampling-params.test.js` — the
"temperature/top_p are allowed" pin now asserts the split (Cascade 400, Connect 200).