diff --git a/devlog/_plan/260817_cursor_toolcall_decode/000_index.md b/devlog/_plan/260817_cursor_toolcall_decode/000_index.md index 742367cec8..07adae41a2 100644 --- a/devlog/_plan/260817_cursor_toolcall_decode/000_index.md +++ b/devlog/_plan/260817_cursor_toolcall_decode/000_index.md @@ -237,9 +237,9 @@ already-emitted terminal stay graceful. ### What shipped for 020 -Tool-result images reach Cursor as real `McpImageContent`. The final design differs -from the original plan in three ways, each forced by a review that proved the plan -would have broken a working request: +The Cursor ENCODER now emits tool-result images as real `McpImageContent`. The final +design differs from the original plan in three ways, each forced by a review that +proved the plan would have broken a working request: 1. **Bounding is post-serialization, not a byte budget.** A step is one blob shared with the call's arguments, text, and framing, so `toolCallStep` serializes and @@ -263,3 +263,43 @@ measurement cycle in `030` stands, and the phase closes NOOP unless the user supplies a failing case. Note the probe did not isolate the top-level freeform surface — the cursor agent reached `apply_patch` through code mode — so this is "not reproduced", not "proven absent". + +## Final status — all phases + +| Phase | Outcome | Verified | +|-------|---------|----------| +| `010` clean-EOF terminal | **SHIPPED** `54f68daf5` | 7 rounds; lidge 608/0 | +| `020` tool-result images | **SHIPPED** `878b067e8..cc906b0fc` | 5 rounds; byte-equality verified twice; lidge 624/0 | +| `030` xai apply_patch | **NOT REPRODUCED** | live probe: both providers used `apply_patch` | +| `040` server-side cancel | **SHIPPED** `f145fd513..c9681d043` | lidge 630/0 | +| `050` terminal-less turns + #422 | **SHIPPED** `aa800ae65..1651002c5` | 8 rounds; lidge 12800/0 across 830 files | + +Final full suite at `1651002c59`: **12800 pass / 0 fail**, typecheck clean. +Pre-campaign baseline was 12761/0 across 826 files. + +### What the campaign actually found + +The user's report was "Computer Use keeps disconnecting mid tool call". The decode +found three distinct ways a Cursor turn could lose work, and the last one turned +out not to be Cursor-specific at all: + +1. A clean stream EOF dropped an open tool call and reported success (`010`). +2. Every screenshot reached the model as placeholder text, though the wire had + always supported images (`020`). +3. A cancel Cursor sent us was indistinguishable from one we sent, so the turn + vanished entirely (`040`). +4. Underneath all three: the bridge reported a turn with no terminal as + `completed` — and on a compaction turn installed its partial output as + replacement history (`050`). That one affected every provider. + +The xai `apply_patch` symptom did not reproduce when probed live, so no code was +written for it. + +### Remaining follow-ups + +- **Kiro** `completionMode: "disabled"` and **ordinary Google mode** erase + truncation reasons before the bridge can act (`050`). Adapter-side, each its + own unit. +- **User-message images** are still placeholdered although `SelectedImage` + supports blob/inline data (`002`). Separate capability. +- **`030`** stays open as a measurement cycle pending a reproducible failing case. diff --git a/devlog/_plan/260817_cursor_toolcall_decode/020_phase2-toolresult-image-passthrough.md b/devlog/_plan/260817_cursor_toolcall_decode/020_phase2-toolresult-image-passthrough.md index d7c3b55b94..f4a889e77b 100644 --- a/devlog/_plan/260817_cursor_toolcall_decode/020_phase2-toolresult-image-passthrough.md +++ b/devlog/_plan/260817_cursor_toolcall_decode/020_phase2-toolresult-image-passthrough.md @@ -124,3 +124,28 @@ adapter directory) All nine pass, typecheck clean, cursor suite green on `ssh lidge`, pushed. +## POST-SHIP CORRECTION (audit r1, 2026-08-18) + +This phase shipped the ENCODER, and the encoder is correct. It does **not** deliver +the end-to-end capability, and the earlier wording in `000_index.md` overstated it. + +Every Cursor model is listed in `noVisionModels` +(`src/providers/registry.ts:978-982`), so before the adapter ever runs, the vision +sidecar replaces image parts with text descriptions — or strips them fail-closed +when no sidecar plan exists (`src/server/responses/core.ts:2225-2243`). That +preprocessing explicitly covers `toolResult` messages +(`src/vision/index.ts:252-259`, replacement at `:565-581`). + +`tests/cursor-tool-result-image.test.ts` calls `encodeCursorRunRequest` directly +with hand-built `rawMessages`, so it proves encoder support and nothing about the +production path. + +The registry comment claiming "Cursor's wire protocol never forwards image parts" +is now half-stale: still true for USER images (`request-builder.ts:206-214` +flattens them), no longer true for tool results. + +Closing this gap is its own unit, not a wording fix: dropping Cursor from +`noVisionModels` alone would leave user images with neither an image nor a +description. It needs role-aware vision policy plus an end-to-end regression +through the server path. Tracked as follow-up 1 in +`devlog/_plan/260818_cursor_call_integration/000_plan.md`. diff --git a/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md b/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md new file mode 100644 index 0000000000..08397649fe --- /dev/null +++ b/devlog/_plan/260817_cursor_toolcall_decode/050_phase5-nonstreaming-terminal.md @@ -0,0 +1,171 @@ +# 050 — Phase 5: a buffered turn that never terminated is not "completed" + +The last follow-up recorded in `000_index.md`. Deferred from `010` pending +evidence; that evidence now exists and is worse than the note assumed. + +## Measured, not assumed + +`buildResponseJSON` defaults to `"completed"` whenever no error or incomplete +event is present (`bridge.ts:1830-1834`). Probed directly against the current +tree: + +| Adapter events | Result | +|----------------|--------| +| `[text]`, no terminal | `status: "completed"`, no `incomplete_details` | +| `[tool_call_start, tool_call_delta("{\"code\":\"tru")]` | `status: "completed"` with a `function_call` item, `status: "completed"`, `arguments: "{\"code\":\"tru"` | +| same + `tool_call_end` | `status: "failed"`, item `status: "incomplete"` | + +The second row is the defect. A truncated tool call — invalid JSON, never closed — +is handed back as a **successful** turn containing an apparently complete +function call. A caller that trusts `status` will try to execute it. + +The third row is the same bridge, on the same arguments, getting it right. The +rejection logic already exists (`bridge.ts:1070-1080`); it is reached only when +an explicit `tool_call_end` arrives. When the stream simply stops, nothing runs +it. + +## Why this is in scope + +`010` and `040` both closed *adapter-side* routes to this shape: a truncated EOF +and a server cancel now raise typed errors, so those paths no longer reach the +buffered default. This phase closes the default itself, which is what makes the +guarantee hold for any adapter that ends a stream without a terminal — including +future ones nobody has audited. + +## Scope warning + +`src/bridge.ts` is shared by **every** provider. This phase therefore: + +- changes only the no-terminal case, leaving every explicit `done`/`error`/ + `incomplete` path byte-identical; +- runs the **full** suite on `ssh lidge`, not the cursor subset. A baseline full + run at `6d97442839` is captured before the change so any new failure is + attributable. + +## Contract + +| Buffered turn | Status | +|---------------|--------| +| explicit `done` | `completed` — unchanged | +| explicit `error` | `failed` — unchanged | +| `incomplete` / `max_tokens` / `content_filter` | `incomplete` — unchanged | +| no terminal, no open tool call | `incomplete`, `incomplete_details.reason = "adapter_eof"` | +| no terminal, tool call left open | `incomplete`, and the item is **not** `completed` | + +Streaming already reports `adapter_eof` for the fourth row (`bridge.ts:1283`), so +this aligns the buffered path with the streaming one rather than inventing a new +signal. + +## Diff-level plan + +**`src/bridge.ts`** + +- In `buildResponseJSONWithBudget`, track whether any adapter terminal + (`done`/`error`/`incomplete`) was observed. +- When none was, resolve `status` to `"incomplete"` with + `incomplete_details: { reason: "adapter_eof" }`, matching the streaming path's + wording exactly. +- An unclosed tool call must not carry item `status: "completed"`. Reuse the + existing incomplete-item marking rather than adding a second notion of + "unfinished". +- Do not touch `stopReason` handling, usage reporting, or compaction. + +## Tests (`tests/bridge-nonstreaming-terminal.test.ts`) + +1. Text with no terminal -> `incomplete` + `adapter_eof`, not `completed`. Red today. +2. Open tool call with truncated arguments and no `tool_call_end` -> turn is not + `completed` and the item is not `completed`. Red today; this is the executable- + garbage case. +3. Parity: the same events through streaming and buffered agree on terminal + status. This is the assertion that keeps the two paths from drifting again. +4. Explicit `done` -> still `completed` (regression). +5. Explicit `error` -> still `failed`; explicit `incomplete` -> still `incomplete` + with its own reason preserved (regression). + +## Done when + +All five pass, `bun run typecheck` clean, and the **full** suite on `ssh lidge` +matches the pre-change baseline. Tests 1 and 2 demonstrated red beforehand. + + +## Shipped, and what the audit chain changed + +Five review rounds. The plan's core claim survived; nearly every detail did not. + +| Commit | What it closed | +|--------|----------------| +| `aa800ae65` | The default itself: a buffered turn with no adapter terminal is `incomplete`/`adapter_eof`, and an open tool call is no longer emitted as a completed `function_call` with half-written JSON. | +| `44fde398b` | The #422 compaction guard could only see explicit failure events, so a terminal-less turn still installed replacement history. | +| `f73f09c9e` | Streaming emitted the compaction item *before* reading `stopReason`; Google's `parseResponse` dropped `finishReason` entirely. | +| `95f73db17` | `stopReason` is an open-ended string and adapters disagree (`length`, `refusal`); canonical-only matching left the guard bypassable. | +| `71730023a` | **My own regression:** suppressing the item without downgrading the turn produced `completed` with zero compaction items — the shape codex-rs fatals on. Suppression and status now come from one decision. | +| `ea5e61677` | Anthropic `pause_turn` is unfinished by definition; AI SDK `error` is a failure, so Command Code emits a real error terminal instead of a stop reason. | + +The lesson worth keeping: each round fixed the previous round's fix. Round 4 found +that my round-3 change had made things *worse* in one direction — a suppressed +compaction item with a success status is more dangerous than the bug it replaced, +because codex-rs treats zero items as fatal. Widening a guard without widening +what it reports is not a partial fix; it is a new failure. + +## Open follow-ups (adapter-side, deliberately not folded in) + +Both erase truncation metadata **before** the bridge can defend anything, so they +cannot be fixed here: + +- **Kiro** (`kiro.ts:1315`, `:1485`): in `completionMode: "disabled"` — which routed + compaction selects, because it removes tools — the normalized reason is observed + and then the final `done` omits `stopReason`. `MAX_TOKENS` and + `MODEL_CONTEXT_WINDOW_EXCEEDED` both vanish. +- **Google ordinary mode** (`google.ts:779`, `:947`): only `MAX_TOKENS` and five safety + values are forwarded. `MALFORMED_RESPONSE`, `UNEXPECTED_TOOL_CALL`, `IMAGE_SAFETY`, + and `LANGUAGE` become reasonless `done` events. The Vertex/CCA fail-closed guard + covers only part of this. + +Each is its own unit with its own truncation subsystem. Recording them beats +half-fixing them inside a bridge phase. + +## Final state (eight rounds) + +| Commit | Closed | +|--------|--------| +| `ea5e61677` | Anthropic `pause_turn` is unfinished by definition; AI SDK `error` is a failure, so Command Code emits a real error terminal. | +| `6478cbb02` | Removing `error` from the shared table was safe only for Command Code — Anthropic forwards `stop_reason` verbatim, so it needed its own error terminal on the buffered and streaming paths. Both terminals carry usage. | +| `1651002c5` | Anthropic has a **third** terminal path: the EOF branch for providers that close after `message_delta` without `message_stop` bypasses `emitDone` entirely and still reported success. | + +Round 8: **PASS, no findings.** Verified exhaustively that Anthropic's terminal +paths are `message_stop`/`emitDone`, the compatible-provider EOF branch, and +buffered `parseResponse` — and that `anthropicEofTolerance` is not a fourth, +since it runs only when no stop reason was received and delegates back through +`emitDone`. + +Final verification on `ssh lidge` at `1651002c59`: typecheck clean, +**12800 pass / 0 fail** across 830 files. The pre-phase baseline was 12761/0. + +### Two tests that were protecting nothing + +Worth recording, because both looked like coverage: + +- The Anthropic streaming test was titled "carrying usage" and never asserted + usage. Removing usage from the error terminal kept it green. +- The Command Code coverage hand-constructed the downstream error event instead + of driving the parser, so it stayed green while the adapter still emitted a + clean `done`. + +Both now drive the real parsers, and dropping usage from either error terminal +turns the suite red. + +### What the round count actually bought + +Eight rounds, and the finding that justified them arrived at round 7 — after two +rounds had already declared the area closed. Two of the defects were regressions +I introduced while fixing the previous round's finding: + +1. Suppressing the compaction item without downgrading the turn produced + `completed` with zero compaction items, which codex-rs treats as fatal. A + half-widened guard was more dangerous than the bug it replaced. +2. Removing `error` from the shared table fixed Command Code and silently + re-opened Anthropic, which forwards the same string verbatim. + +Both share a shape: a fix that is correct for the case in front of you and wrong +for the one next to it. That is the argument for auditing revisions, not just +first drafts. diff --git a/devlog/_plan/260818_cursor_call_integration/000_plan.md b/devlog/_plan/260818_cursor_call_integration/000_plan.md new file mode 100644 index 0000000000..aaf292b057 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/000_plan.md @@ -0,0 +1,144 @@ +# 000 — Integrate cursor-call onto dev and reach release-ready state + +## Objective + +Land the `cursor-call` tool-call hardening campaign on the current `dev` head, then +carry it to a release-ready state. The campaign shipped against `f64c0639` +(merge-base); `dev` has moved 100+ commits since, and two of the source files we +changed were changed there too — one of them for the SAME defect, in the opposite +shape. + +**Moving-ref discipline (audit `r2` finding 2).** `dev` advances during this work: +it was `87f7f970b` (+104) when the collision sweep ran and `e1bdbc1e5` (+124) a few +minutes later. Every count below is therefore pinned to an immutable SHA, and the +rebase re-reads `origin/dev` immediately before running rather than trusting a +number written here. The COLLISION SET is what matters, and it was re-checked at +the later head: still the same two paths. + +This unit is the integration record, not a re-decode. The decode unit is +`devlog/_plan/260817_cursor_toolcall_decode/`. + +## Audit history + +Round `r1-20260818030046` returned **FAIL** with 6 findings; every one was verified +against the tree and every one was accepted. See `005_audit_r1.md`. The findings +changed the work-phase map (a new WP for the usage regression, gates moved BEFORE +the merge, the stack claim replaced) — they were not absorbed as wording tweaks. + +## Evidence base + +| Fact | Command | +|------|---------| +| merge-base = `f64c06391` (immutable) | `git merge-base origin/dev cursor-call-prerebase-260818` | +| snapshot `cursor-call-prerebase-260818` = `fe2237038`, **31 commits** (immutable) | `git rev-list --count ..cursor-call-prerebase-260818` | +| `cursor-call` is a MOVING ref: 32 commits at `66b9df9ef`, 34 at `be1b881ec`, and it keeps growing as this unit is written | `git rev-list --count ..cursor-call` | +| `origin/dev` is a MOVING ref: +104 at `87f7f970b`, +124 at `e1bdbc1e5` | `git rev-list --count ..origin/dev` | +| snapshot touches **28** paths (18 source/test + 10 devlog) (immutable) | `git diff --name-only cursor-call-prerebase-260818` | +| only 2 of the 28 were touched on dev — re-verified at `e1bdbc1e5` | per-path `git log --oneline ..origin/dev -- ` | + +The earlier draft said "18 files" while listing a devlog wildcard row; `r1` finding 6 +was right. 18 is the source+test count; 28 is the full path count. + +## Collision inventory (all 28 paths) + +18 source/test paths: + +| File | dev commits | Collision | +|------|-------------|-----------| +| `src/adapters/cursor/live-transport.ts` | 3 (`6a64db19d`, `08eb65d1f`, `1824a0148`) | **SEMANTIC** — same defect, opposite shape | +| `src/adapters/google.ts` | 6 (`aca3c0241`, `0be660a2e`, `f6c88febf`, `812255d3a`, `d62cc4029`, `343e5d7a3`) | **TEXTUAL** — identity work; our hunk drifts 939 → 946 | +| `src/adapters/anthropic.ts` | 0 | none | +| `src/adapters/command-code.ts` | 0 | none | +| `src/adapters/cursor/cursor-errors.ts` | 0 | none | +| `src/adapters/cursor/native-exec.ts` | 0 | none | +| `src/adapters/cursor/protobuf-request.ts` | 0 | none | +| `src/adapters/cursor/request-builder.ts` | 0 | none | +| `src/bridge.ts` | 0 | none | +| `src/responses/truncated-stop-reason.ts` | 0 (absent on dev — we add it) | none | +| `tests/anthropic-error-stop-reason.test.ts` | 0 | none | +| `tests/bridge-nonstreaming-terminal.test.ts` | 0 | none | +| `tests/command-code-error-finish.test.ts` | 0 | none | +| `tests/cursor-cancel-provenance.test.ts` | 0 | none | +| `tests/cursor-eof-terminal.test.ts` | 0 | none, but its EXPECTATION changes (see `010`) | +| `tests/cursor-request-builder.test.ts` | 0 | none | +| `tests/cursor-tool-result-image.test.ts` | 0 | none, but its COVERAGE is insufficient (see `005` F1) | +| `tests/google-buffered-stop-reason.test.ts` | 0 | none | + +Plus 10 `devlog/_plan/260817_cursor_toolcall_decode/*` docs, zero dev commits. + +An INDIRECT-breakage sweep, run twice (once by the collision investigator, once +adversarially in `r1`), found no compile break. `AdapterEvent.done.stopReason` +still exists (`src/types.ts:366-387`), the Cursor tool-definition exports we +reference are intact, and the adapter factory signature is compatible. **The real +upstream hazard `r1` found is not a renamed import — it is request PREPROCESSING +(finding F1).** + +## Loop-spec + +- Loop archetype: verifier-defined (typecheck + full suite on lidge decide done). +- Write scope: the 18 source/test paths above, plus this unit. `src/vision/index.ts` + and `src/providers/registry.ts` are **out** of scope: audit `r2` finding 3 is right + that a conditional clause letting WP2b expand into vision policy contradicts the + explicit deferral of F1. WP2b is about EOF usage and authorizes nothing in vision. + No version bump, no npm publish, no `main` promotion. +- Tool/credential scope: local git, `ssh lidge` for verification, `gh`/GitHub app + for PRs and the merge. Push to `origin/cursor-call` is pre-approved + (`--no-verify`); force-push is inherent to the requested rebase and the snapshot + branch is the recovery path. +- Bounds: no stated token budget. Wall-clock dominated by the lidge suite (~8 min). + CI is NOT checked (user waived) — but see `005` F2 for what that waiver can and + cannot license. + +## Work-phase map (one phase = one full PABCD cycle) + +| WP | Doc | Slice | Depends on | +|----|-----|-------|------------| +| wp1-integration-roadmap | this unit + `005` | conflict inventory, audit absorption, roadmap (docs-only) | — | +| wp2-rebase | `010` | rebase with evidence-based conflict resolution | wp1 | +| wp2b-eof-usage | `015` | **NEW (r1 F3):** the surviving EOF error event must carry partial usage | wp2 | +| wp3-remote-verify | `020` | typecheck + full suite + privacy:scan + audit:high on lidge | wp2b | +| wp4-prs | `030` | PR(s) targeting `dev`, topology-honest, template filled | wp3 | +| wp5-merge | `040` | merge onto `dev` + ancestry proof, with the governance position stated | wp4 | +| wp6-release-gates | `050` | gates re-run on merged `dev` + go/no-go note | wp5 | + +`r1` F1 (Cursor tool-result images stripped upstream by the vision sidecar) is +**NOT** folded into this integration. It is a real defect and it makes one +capability claim in the decode unit's 020 overstated, but fixing it means changing +vision preprocessing policy — a different subsystem, a different blast radius, and +a decision about ordinary user images too. It is recorded in `005` and appended as +a follow-up work-phase candidate, and the overstated claim gets corrected in the +decode unit's docs. Landing a rebase does not make it worse. + +## Accept criteria (mirrored into the goalplan) + +- `c1-roadmap-unit` — this unit with research + diff-level decade docs. +- `c2-conflict-inventory` — the 28-path table, produced by the named commands. +- `c3-rebase-clean` — rebase lands, no conflict markers, dev head is an ancestor. +- `c4-resolution-audited` — every resolution passes an adversarial audit round. +- `c5-remote-green` — typecheck + full suite green on lidge at the SHA. +- `c6-prs-open` — PR(s) against `dev` matching the ACTUAL topology, template filled. + (Revised by `r1` F4: "stacked" is no longer required if the history does not + support an honest split.) +- `c7-merged-on-dev` — `git merge-base --is-ancestor` proves it, not an API reply. +- `c8-release-gates` — privacy:scan, audit:high, typecheck, full suite green. +- `c9-go-no-go` — a written note on whether to cut a version. +- `c10-eof-usage` — **NEW:** the EOF truncation error carries partial usage, with a + regression test that fails before the fix. + +## Out of scope (carried follow-ups, NOT this unit) + +1. **Cursor vision preprocessing (`r1` F1)** — all Cursor models are in + `noVisionModels` (`src/providers/registry.ts:978-982`), so + `describeImagesInPlace`/`stripImagesInPlace` replaces tool-result images with + text before the adapter runs (`src/server/responses/core.ts:2225-2243`, + `src/vision/index.ts:252-259,565-581`). The 020 encoder work is correct but + currently unreachable in production. +2. Kiro `completionMode: "disabled"` drops `stopReason` (`kiro.ts:1315`, `:1485`). +3. Google ordinary mode forwards only `MAX_TOKENS` + five safety values; four + other reasons become reasonless `done` (dev `google.ts:786-795`). +4. User-message images still flattened (`request-builder.ts:206-214`). +5. Phase 030 (xai apply_patch) remains NOT REPRODUCED. New information: dev landed + `bc229433a` + `8a4040384`, which stop the code-mode guidance from forbidding a + separately-advertised top-level `apply_patch` — the same affordance surface 030 + suspected, fixed independently on dev. Re-probing needs a user-supplied failing + case. diff --git a/devlog/_plan/260818_cursor_call_integration/005_audit_r1.md b/devlog/_plan/260818_cursor_call_integration/005_audit_r1.md new file mode 100644 index 0000000000..b487eeee49 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/005_audit_r1.md @@ -0,0 +1,135 @@ +# 005 — Audit round r1-20260818030046: FAIL, 6 findings, all accepted + +Reviewer: independent sol/high agent, read-only, told to break the plan before any +rebase step ran. Verdict **FAIL**. Every finding was re-verified against the tree +before absorption; none was waved through and none was argued away. + +## F1 (High) — Cursor tool-result images never reach the encoder in production + +**Verified.** The chain: + +1. `src/providers/registry.ts:978-982` puts **every** Cursor model in + `noVisionModels`, with the comment "Cursor's wire protocol never forwards image + parts (request-builder emits an unsupported-content marker), so the vision + sidecar covers ALL cursor models." +2. `src/server/responses/core.ts:2225-2243` runs the sidecar before the adapter: + `describeImagesInPlace` when a plan exists, else `stripImagesInPlace` fail-closed. +3. `src/vision/index.ts:252-259` — `carriesImages()` explicitly includes + `toolResult`. `:565-581` replaces each image part with a text description. +4. `tests/cursor-tool-result-image.test.ts:52-77` calls `encodeCursorRunRequest` + directly with hand-built `rawMessages`, so it never crosses that preprocessing. + +So the 020 encoder work is correct in itself and **unreachable in production**. The +registry comment it was built against is now half-stale: the request-builder marker +is still true for USER images, but no longer true for tool results. + +**Disposition: accepted, and explicitly NOT fixed in this unit.** The fix is a +capability-policy change spanning `src/providers/registry.ts` and +`src/vision/index.ts`, and the reviewer is right that simply dropping Cursor from +`noVisionModels` is unsafe because user-message images are still flattened at +`src/adapters/cursor/request-builder.ts:206-214` — the model would then get +neither the image nor a description. It needs role-aware preprocessing plus an +end-to-end regression through the server path. + +Two things this unit DOES do about it: record it as follow-up 1 in `000`, and +correct the overstated capability claim so no later doc reads as if the capability +shipped end-to-end. + +## F2 (High) — the merge bypasses a gate `MAINTAINERS.md` requires + +**Verified.** `MAINTAINERS.md:48-49`: "A pull request requires approval from at +least one maintainer and successful required CI checks before merge." +`AGENTS.md:251-253` makes `MAINTAINERS.md` authoritative over `AGENTS.md`. + +The user waived CI checking and granted admin merge. That is the repository owner +exercising owner authority, which is a real thing — but it does not make the merge +*policy-satisfying*, and lidge is Linux-only while CI covers Linux, Windows, and +macOS. Windows-sensitive surfaces are exactly where this repository has been bitten +before. + +**Disposition: accepted as a stated governance exception, not as compliance.** +`040` now records: (a) the user's waiver is the authority for merging without CI; +(b) the merge is therefore an owner-authorized exception; (c) the platform gap is +named — Linux-only evidence; (d) the readiness note in `050` must not claim +"policy-compliant release-ready", only "gates green on Linux, CI waived by owner". +A release-readiness claim that hides this is the failure mode. + +## F3 (Medium) — the surviving EOF shape drops partial usage + +**Verified, and this one needs code.** + +- Thrown path: `attachPartialUsage` (`live-transport.ts:1195-1199`) puts + `partialUsage` on the error, and `src/adapters/cursor.ts:181-192` copies it into + the emitted `error` event. +- Event path: `finalizeTurnEvents` returns + `[{ type: "error", message }]` with **no usage** + (`protobuf-events.ts:1361-1372`), even though `CursorServerMessage`'s error + variant carries `usage?: OcxUsage` (`src/adapters/cursor/types.ts:44-48`) and + `resolvedTurnUsage(state)` is right there at `:1340`, already used by the `done` + branch at `:1376`. + +So choosing dev's shape (correct on its own merits) would silently trade away +usage reporting on truncated turns. That is a real regression, not a style point. + +**Disposition: accepted. New work-phase `wp2b-eof-usage`, doc `015`.** The EOF +error gets `usage: resolvedTurnUsage(state)`, with a regression test that fails +before the change. + +## F4 (Medium) — the three-PR stack cannot be formed at clean boundaries + +**Verified.** `git log --oneline --reverse ..cursor-call` shows ten devlog +commits before the first code commit, then docs interleaved after each +implementation phase (`dfb6fb884`, `6d9744283`, `3f5bf955d`, `f10108315`, +`fe2237038`, `66b9df9ef`). A "PR1 = adapter code only, PR3 = all devlog" split +requires reordering or cherry-picking, which makes it not a stack of this history. + +The reviewer also caught a verification hole: `020` verifies only the final tip, +while `030` planned to reuse that evidence for every layer. `AGENTS.md:178-180` +wants each non-trivial PR verified. + +**Disposition: accepted. `030` is rewritten** to open ONE PR from `cursor-call` +to `dev`, with the reasoning recorded, and criterion `c6` is reworded from +"stacked PRs" to "PR(s) matching the actual topology". The user asked for a stacked +PR; the honest answer is that this history is one linear chain and a fabricated +split would be less reviewable, so the plan says so out loud instead of +manufacturing three PRs whose contents do not match their titles. + +## F5 (Medium) — gates sequenced after the merge, and `audit:high` missing + +**Verified.** `scripts/release.ts:374` runs `bun run audit:high` and `:380` runs +`bun run privacy:scan`; `package.json:52` shows `prepush` runs typecheck, gui +lint, test, and privacy:scan. Deferring privacy:scan until after the merge means a +PR could be merged with it red. + +**Disposition: accepted.** `020` now runs `privacy:scan` and `audit:high` BEFORE +the PR, `030` requires the docs determination before ticking the template box, and +`050` re-runs the gates on merged `dev` as confirmation rather than as first +contact. + +## F6 (Low) — the inventory's counts were wrong + +**Verified.** The snapshot touches 28 paths, not 18; the table collapsed ten devlog +files into one row while the prose said "all 18 files". And `cursor-call` is now 32 +commits (the plan commit itself), so `origin/dev..cursor-call` no longer returns 31. + +**Disposition: accepted.** `000` now states 28 paths (18 source/test + 10 devlog) +and 32 commits, and distinguishes the snapshot ref from the moving branch. + +## What survived the attack + +Recorded because a surviving claim is evidence too: + +- No compile break from dev's changes to `src/types.ts`, `tool-definitions.ts`, + `tool-catalog-nudge.ts`, `parser.ts`, `router.ts`, `core.ts`, `registry.ts`. +- A thrown `CursorStreamTruncatedError` would NOT cause a retry: retry needs no + emitted event, an uncommitted request, and a transient error + (`transport-retry.ts:92-105`), and the request is committed on HTTP/2 connect. + So "the event shape loses a useful retry" is not a reason to keep the throw. +- The literal merged EOF block uses the right variables and preserves dev's guard + ordering; `|| this.emittedTerminal` swallows no dev-covered case. +- `CursorStreamTruncatedError` becomes dead code after the import is dropped, but + compiles. +- The Google patch location is right: `parseResponse` at `:812`, `candidates` in + scope from `:894`, insertion after the truncation guard is safe. +- GUI lint/build N/A for a source-only diff is reasonable. + diff --git a/devlog/_plan/260818_cursor_call_integration/006_audit_r3.md b/devlog/_plan/260818_cursor_call_integration/006_audit_r3.md new file mode 100644 index 0000000000..648c50c6ca --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/006_audit_r3.md @@ -0,0 +1,108 @@ +# 006 — Audit round r3-20260818032759: FAIL, 5 findings, all accepted + +Fresh reviewer (r1/r2's reviewer was retired; a final gate must not reuse a +contaminated one). Verdict **FAIL**: every CODE resolution passed, and the +integration/release WORKFLOW had five defects. + +Round `r2` was aborted as inconclusive — its reviewer produced a NEAR-PASS report +but exited without the CLI recording a verdict, and an unrecorded verdict is not a +verdict (REVIEW-BINDING-01). Its three findings were already absorbed at +`2ea12062d`; `r3` re-verified them independently and they held. + +## What passed (recorded, because a survived claim is evidence) + +- **Rebase executability.** Every identifier in `010`'s literal block exists with + compatible types, and the guard order preserves dev's precedence: + incomplete-frame → zero-frame → existing terminal/expected close → open-tool + truncation → assistant-text synthesis + (`origin/dev:src/adapters/cursor/live-transport.ts:1016-1051`). dev's EOF tests at + `tests/cursor-hardening.test.ts:396-600` stay consistent. +- **WP2b.** Moving `partialUsageFromEventState` down creates no cycle; + `protobuf-events.ts` already has `OcxUsage`, the state fields, and + `resolvedTurnUsage` (`:1340`). Reporting proven token consumption is correct even + though the tool call was never committed. +- **Consumer trace.** `message-mapper.ts:29` forwards error usage; the bridge + reports it only on the failed response and suppresses compaction history + (`bridge.ts:1228-1253`, `:1800-1860`). +- **Collision set.** `fe2237038` is 31 commits / 28 paths (18 source+test, 10 + devlog). Live and local `origin/dev` both `e1bdbc1e5`; still only + `live-transport.ts` and `google.ts` collide. No new collision. +- **Hidden dev behavior.** dev's parser, replay, tool-catalog-nudge, code-mode, and + registry changes contradict none of the campaign's eight test files. +- **Governance wording.** Accurately an owner-authorized exception, not compliance + (`MAINTAINERS.md:48-50`), and the no-Windows-surface claim is true of the 28-path + diff. + +## F1 (High) — the tested `dev` base was not pinned through merge + +`dev` moves, and it moved twice during planning. `020` verified only the rebased +branch SHA, and `040`'s ancestry check runs *after* the merge. So GitHub could +construct a merge result nobody tested and put it on `dev`, with WP6 discovering it +afterwards. + +**Accepted.** `020` now records `VERIFIED_BASE` from +`git ls-remote origin refs/heads/dev` — the LIVE head, following +`scripts/release.ts:327-335`, which uses `ls-remote` precisely because the local +tracking ref goes stale. `040` re-reads it before EVERY merge in the stack and stops +if it moved. + +## F2 (High) — remote verification could destroy unrelated work + +Both remote phases ran `git checkout -f` in the shared `~/Developer/opencodex` +checkout without proving it clean. That silently discards tracked uncommitted work — +in a phase that calls itself "verification only". + +**Accepted.** `020` and `050` now use a dedicated `git worktree add /tmp/ocx-*` +and never touch the shared checkout's HEAD. `git worktree list` on lidge already +shows a dozen `/tmp/ocx-*` verification worktrees, so this is that host's existing +pattern, not a new invention. + +## F3 (Medium) — an honest three-PR stack DOES exist + +`r1` killed a fabricated adapter/bridge/docs split, and `030` over-corrected to one +PR. `r3` showed the campaign's own phase boundaries are already clean commits, with +no reordering needed: + +| PR | Range | Commits | +|----|-------|---------| +| 1 | `..dfb6fb884` | 17 — Cursor EOF + tool-result wire | +| 2 | `dfb6fb884..6d9744283` | 3 — unexpected CANCEL (depends on `emittedTerminal`) | +| 3 | `6d9744283..HEAD` | 15 — bridge/adapter terminals + integration docs | + +Verified: PR1's file set is the Cursor wire files plus decode docs; PR2's is +`cursor-errors.ts`, `live-transport.ts`, its provenance test, and `040_*.md`; PR3's +is the bridge/adapter files. The dependency is real, not decorative — PR2's guard +reads PR1's `emittedTerminal`, and PR3's bridge logic is what makes PR1/PR2's error +events reportable. + +**Accepted, and it restores what the user asked for.** `030` is rewritten as a real +stack; `020` adds a per-layer verification table because `AGENTS.md:178-180` wants +each layer's own evidence, not the tip's borrowed. Criterion `c6` goes back to +requiring a stack. + +The lesson: `r1` was right that THAT split was fake, and `030` drew the wrong +conclusion from it — "no honest split exists" instead of "that split was the wrong +one". Absorbing a finding is not the same as absorbing its narrowest reading. + +## F4 (Medium) — `build:gui` is not N/A for a readiness claim + +`050` skipped it because no `gui/` path changed. But `prepublishOnly` +(`package.json:49`) runs `audit:high`, `typecheck`, and `build:gui` on **every** +publish regardless, and `build:gui` also runs `prepare:package` (`:46-47`). + +**Accepted.** `build:gui` moves into both `020` and `050`. `lint:gui` stays N/A +with evidence. `050` must also state that publication additionally requires a +successful Cross-platform CI run AND a successful Service lifecycle run at the exact +release SHA (`scripts/release.ts:393-401`) — otherwise a "go" reads as if publishing +were one command away. + +## F5 (Medium) — the release baseline was already stale + +`050` said `origin/main = 474584bcd`; it is `0013b2347`. `v2.24.2` the TAG still +points at `474584bcd`, which is a different fact. + +**Accepted.** `050` now requires reading `git ls-remote`, `npm view dist-tags`, and +`gh release list` at write time, and states both the tag target and the `main` tip +rather than conflating them. Same root cause as F1: this plan cannot cache a moving +ref. + diff --git a/devlog/_plan/260818_cursor_call_integration/007_audit_r4.md b/devlog/_plan/260818_cursor_call_integration/007_audit_r4.md new file mode 100644 index 0000000000..65cae5d601 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/007_audit_r4.md @@ -0,0 +1,92 @@ +# 007 — Audit round r4-20260818033946: FAIL, 4 findings, all accepted + +Fourth reviewer, dispatched as an `explorer` role so the `SubagentStop` observer +actually records the verdict — rounds `r2` and `r3` had to be aborted as +inconclusive because their reviewers ran outside that matcher +(`hooks/subagent-stop-observing-review.json` matches `^(explorer)?$`). Their findings +were still absorbed; only the FSM record was missing. + +Verdict **FAIL**: every code judgment held, and four EXECUTION-procedure defects +remained. + +## What passed + +- Both remote phases use dedicated worktrees, no `checkout -f` on the shared lidge + checkout (`020:19`, `050:16`). +- `build:gui` and the publish caveat are in both gate phases (`020:49`, `050:32`). +- Release-state reads are live (`050:49`). +- WP2b's helper move and re-export are technically correct + (`protobuf-events.ts:1340`, `message-mapper.ts:28`). +- **PR1 and PR2 tests do not depend on PR3 code** — the stack is genuinely layered. +- No release-authority gate forgotten: exact-SHA Cross-platform CI and Service + lifecycle are already required before publication (`050:73`). + +## F1 (High) — the base pin was recorded but not USED, and it cannot be one SHA + +Two problems in one finding. + +First, `010` still said `git rebase origin/dev` while `020` recorded a live SHA — so +the pin was decorative. The remote had already moved again to `1645bb924` by the time +`r4` ran (third observed value after `87f7f970b` and `e1bdbc1e5`). + +Second, and worse: `040` required every merge to see live `dev` equal to the original +`VERIFIED_BASE`. That is false by construction the moment PR1 lands — PR1's merge +result IS the new `dev`. The doc defined PR2's updated expectation but never repeated +it for PR3. + +**Accepted.** `010` step 1 now captures `VERIFIED_BASE` with `git ls-remote` and +rebases onto that SHA. `040` replaces the frozen check with an evolving +`EXPECTED_DEV`: verified base before PR1, then each layer's merge result before the +next, re-read live every time, with an explicit stop-and-rebase branch if it differs. + +## F2 (High) — the three-layer topology was not constructible as written + +Measured against the tree: + +- `..dfb6fb884` = 17 commits — correct. +- `dfb6fb884..6d9744283` = 3 — correct. +- `6d9744283..fe2237038` = **11**, `6d9744283..HEAD` = **16** — the doc said + "15 + WP2b". + +Worse than the miscount: `git diff --name-only 6d9744283 cursor-call` shows the top +range also touches `src/adapters/cursor/request-builder.ts`, +`tests/cursor-request-builder.test.ts`, and `tests/cursor-tool-result-image.test.ts` +— all PR1-owned, edited later by `2ea12062d` (the `r2` honesty corrections). And the +rebase REWRITES `dfb6fb884` and `6d9744283`, so the plan named branch points that +will not exist when it runs. WP2b was declared part of PR1 with no instruction for +how it gets there. + +**Accepted, and `030` is rewritten around OWNERSHIP rather than commit ranges.** Each +layer is defined by the subsystem it changes; the doc now carries a five-step +procedure: map the rewritten boundaries by SUBJECT LINE (the rebase preserves order), +move the late PR1-owned edits below the PR1 boundary (fix-forward cherry-pick, or +`rebase -i` split if that is not clean — and record which route was taken), land +WP2b inside PR1 before the branch is cut, create the branches, then re-verify every +layer's file set and refuse to open a mislabeled PR. + +## F3 (Medium) — `f145fd513` hits a second, unplanned conflict + +`010` said every commit after `54f68daf5` applies cleanly. Not true: step 2 removes +`CursorStreamTruncatedError` from the import at `live-transport.ts:51`, and +`f145fd513` edits that exact line to add `CursorUnexpectedCancelError` while still +listing the removed symbol. + +The reviewer also confirmed the good news: the functional context survives. The +`emittedTerminal` write in `push()` (`:541`) and both `classifyTurnFailure` throw +sites (`:642`) still exist after the step-2 resolution, so this is an import line and +nothing more. + +**Accepted.** `010` gains an explicit step 3 with the literal resolved import line +and a warning not to let the conflict marker tempt a wider edit. + +## F4 (Medium) — WP2b's own contract test was gated one layer too high + +`015` re-exports `partialUsageFromEventState` specifically so +`tests/cursor-interaction-query.test.ts` keeps working (five dynamic imports at +`:150`, `:164`, `:172`, `:189`, `:195`), and names that file in its verification. +But `020`'s per-layer table assigned it to PR3 while WP2b lands in PR1 — so PR1 +could have merged with the re-export untested. + +**Accepted.** The test moves to PR1's gate, and the table now names WP2b as part of +PR1 rather than PR3. + diff --git a/devlog/_plan/260818_cursor_call_integration/008_audit_r5.md b/devlog/_plan/260818_cursor_call_integration/008_audit_r5.md new file mode 100644 index 0000000000..1bec34fd26 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/008_audit_r5.md @@ -0,0 +1,85 @@ +# 008 — Audit round r5-20260818035046: FAIL, and the approach changes + +Fifth reviewer, narrow scope: verify `r4`'s four closures and answer whether the +sequence is executable end to end. + +Verdict **FAIL**. Two of four closures held; `r4`-F2's did not, and the reviewer was +right that the sequence could not be carried out as written. + +## Closures that held + +- **r4-F1 (evolving base pin).** `040:26` correctly evolves `EXPECTED_DEV` through + PR1, PR2, and PR3, with a live read and a stop-and-rebase branch before every + merge. +- **r4-F3 (second conflict at the import line).** `010:176`'s resolved import + contains exactly the surviving symbols, and `f145fd513` has no other dependency on + `CursorStreamTruncatedError` — verified by reading the commit's own diff. +- **r4-F4 (WP2b's contract test).** `020:105` has it in PR1, and every other test in + the table imports code its layer owns. + +## F1 (High) — the stack procedure was not executable + +Four concrete defects in `030`'s five steps: + +1. Steps 2-3 placed commits on `cursor-call-wire` **before step 4 created it**, and + step 4 created it at the OLD `PR1_TIP`, which would have excluded exactly those + commits. +2. No restack of PR2/PR3 after PR1 changed. +3. "Let the original commit become a no-op during the retarget" is not a thing a + retarget can do — the commit still exists in the child's history. +4. No handling for two late DOC edits. + +## F2 (High) — the partition still leaked + +`6d9744283..cursor-call` also edits `devlog/_plan/260817_cursor_toolcall_decode/000_index.md` +and `020_phase2-toolresult-image-passthrough.md` (by `be1b881ec`, the F1 capability +correction). Those docs belong to PR1 by the Owns table, yet the procedure only moved +the three `2ea12062d` source/test paths. + +Also: `cursor-interaction-query.test.ts` sat in PR1's Owns set while `015` explicitly +preserves that file unchanged — so an exact file-set match was impossible by +construction. + +## F3 (Medium) — the first literal execution failure + +`010:163` wrote `VERIFIED_BASE=\$(...)` and `git rebase \$VERIFIED_BASE`. Under zsh +the escaped form assigns the command TEXT and passes the literal variable name to +`git rebase`. Fixed to `$(...)` and `"$VERIFIED_BASE"`. + +## The real lesson: stop re-slicing a finished history + +Four rounds attacked the split and each produced a different broken procedure. The +common cause is that all four tried to **re-slice a completed linear history**, which +forces commits to move between layers, and every mechanism for moving them +(cherry-pick, `rebase -i`, retarget) broke a different invariant. + +`030` is now rewritten to build the stack **forward**. Each layer branch is cut from +its base and its files are checked out from `FINAL` (the rebased `cursor-call` tip), +then committed as that layer's contribution. Two consequences: + +- **PR1 ∪ PR2 ∪ PR3 is identical to `cursor-call` by construction**, because every + layer's tree comes from `FINAL`. That is the property the previous procedures kept + failing to guarantee, and `030` step 4 now checks it mechanically + (`git diff EXPECTED_DEV cursor-call --stat` equals the union of the three layer + diffs). +- **The late doc edits stop being a special case.** Step 1 takes the whole + `260817_cursor_toolcall_decode/` directory from `FINAL`, so `be1b881ec`'s edits are + included automatically. `r5` was right about the leak; taking final state rather + than mid-history state is the fix. + +One file needs care under forward construction: `live-transport.ts`. `FINAL`'s +version contains PR2's `classifyTurnFailure`, so PR1 takes that single file from the +rebased wire-work boundary commit instead (its subject is unique in the range — +`git log --format='%s' | sort | uniq -d` returns nothing, confirmed by `r5`), and PR2 +takes it from `FINAL`. Documented in `030` step 1. + +`030` also now states a fallback out loud: if the union check fails for a genuine +interleaving reason, open ONE PR and say why. Iterating on the split a fifth time is +not on the table. + +## Note on the rebuilt layers + +Forward construction creates new commit objects, so the campaign's original messages +are carried over deliberately — the devlog cites them. `cursor-call` remains the +canonical history and the PR bodies say so. + diff --git a/devlog/_plan/260818_cursor_call_integration/009_audit_r6.md b/devlog/_plan/260818_cursor_call_integration/009_audit_r6.md new file mode 100644 index 0000000000..de9719010e --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/009_audit_r6.md @@ -0,0 +1,72 @@ +# 009 — Audit round r6-20260818035826: FAIL, and the category error was mine + +Sixth reviewer, narrow scope: does the forward-construction stack procedure work +mechanically? Verdict **FAIL**, four blockers. + +## What r6 found + +1. **PR3 was not stacked on PR2 (High).** Steps 1-2 created NEW commits from + `EXPECTED_DEV` while step 3 kept the original `cursor-call`. So + `cursor-call-cancel` was not an ancestor of `cursor-call`, and a PR against it + would compare from the merge base and show the entire original series. A retarget + cannot repair ancestry. +2. **PR1 copied PR2/PR3 content (High).** Taking `cursor-errors.ts` from `FINAL` + hands PR1 the `CursorUnexpectedCancelError` that is supposed to be PR2's, making + PR2's own checkout a no-op. Taking the whole decode directory hands PR1 the + `040` and `050` phase docs owned by PR2 and PR3. +3. **WP2b's `live-transport.ts` part landed in PR2 (High).** PR1 deliberately took + that file from an earlier boundary, so WP2b's helper deletion + import + re-export + was missing there and appeared in PR2's `FINAL` checkout instead. Executed + literally, PR1 would carry two copies of the helper. +4. **The union check proved nothing (Medium).** A whole-range `--stat` is fixed by + its endpoints; it stays identical no matter which layer content lands in. It would + have caught neither `r5`'s doc leak nor `r4`'s source leak. + +r6 also confirmed `git checkout -- ` behaves as assumed, and that the +`live-transport.ts` boundary exception was internally coherent +(`dfb6fb884`: 4 `emittedTerminal`, 0 `classifyTurnFailure`; `6d9744283`: 4 of each). + +## The category error + +Four consecutive failures on the same question, each on a different mechanism. Under +LOOP-REPAIR-01 that is the point to stop patching the answer and re-read the +question. + +I had been requiring the layers to be **subsystem-pure** — each touching only its own +files. That came from `r4` F2 noting the top commit range also edits PR1-owned files, +which I treated as a defect in the split. + +It was not a defect. **A stacked PR promises reviewable increments in dependency +order, not subsystem purity.** Purity requires moving content between commits, the +history is already final, and every mechanism for moving it — cherry-pick, +`rebase -i` split, forward tree copy — broke a different git invariant. `r3` F3 had +the right answer originally; I over-corrected it away. + +## The fix + +`030` now cuts the rebased history at two existing commits and creates branches +there. Every property the four broken versions fought for comes free: + +- ancestry is automatic (one linear history) — closes r6-1; +- union equals the branch by construction (the ranges partition it) — closes r6-4; +- no commit moves, so nothing can be dropped, duplicated, or contaminated — + closes r6-2 and r6-3. + +Step 3 proves it with four commands: two `--is-ancestor` checks and three range +counts that must sum to the whole. + +## What moved as a result + +WP2b and `tests/cursor-interaction-query.test.ts` are now BOTH in PR3, where WP2b +lands chronologically. `r4` F4's principle stands — a change and its contract test +belong together — and this satisfies it in the other direction. PR1 remains correct +without WP2b: PR1 makes a truncated turn reportable, PR3 makes it report tokens. +That is what a stacked increment is. + +PR3's body must name the two things a reviewer would otherwise find odd: it carries +WP2b, and it carries the late honesty corrections to PR1-owned files (`2ea12062d`'s +comments, `be1b881ec`'s two decode docs). + +`030` keeps a fallback: if step 3 fails, open one PR and say why. There is no sixth +splitting scheme. + diff --git a/devlog/_plan/260818_cursor_call_integration/010_phase1.md b/devlog/_plan/260818_cursor_call_integration/010_phase1.md new file mode 100644 index 0000000000..02bb6aa1cd --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/010_phase1.md @@ -0,0 +1,221 @@ +# 010 — WP2: rebase cursor-call onto dev with evidence-based conflict resolution + +> **EXECUTION AUTHORITY: `cursor-call-integration.zsh pin | rebase | push`.** +> The commands below are the reasoning, not the runbook. Seven audit rounds proved a +> markdown file cannot enforce that a variable is bound before it is read (`019`), so +> the script owns what runs and this doc owns why. If they disagree the script is +> right and this doc is stale — fix the doc. + +Two conflicts. Both were investigated by an independent read-only agent before any +rebase step ran; the verdicts below are the resolution contract. + +## Conflict 1 — `src/adapters/cursor/live-transport.ts` (SEMANTIC) + +### The collision + +Our `54f68daf5` and dev's `6a64db19d`+`08eb65d1f`+`1824a0148` fix the SAME defect: +a framed Cursor stream that ends at the HTTP/2 layer with no turn terminal while a +client tool call is still open used to settle as success, so the deferred tool call +vanished. + +| | Ours (`54f68daf5`) | dev (`6a64db19d`..`1824a0148`) | +|---|---|---| +| Mechanism | `settler.settleFail(new CursorStreamTruncatedError(...))` | `for (const e of finalizeTurnEvents(state)) push(e)` then `settleFinish()` | +| Wire result | thrown transport failure | one `{type:"error"}` adapter event naming the open call | +| Extra state | `emittedTerminal` (per-run flag set in `push()`) | `sawAssistantText` | + +### VERDICT — dev's error-event shape survives + +Reasons, in order of weight: + +1. `finalizeTurnEvents` (`src/adapters/cursor/protobuf-events.ts:1361`) is the + established adapter contract for this exact condition and already returns a + fail-closed `error` event. dev `1824a0148` records that CodeRabbit asked for a + throw here and it was **rejected on the merits**: throwing replaces a + domain-specific truncation message with a generic transport failure. +2. Our phase-050 bridge work does the right thing with dev's shape. The error sets + both `errorEvent` and `sawTerminal`, so `buildResponseJSON` returns + `status: "failed"`, attaches no `adapter_eof`, and suppresses compaction + history. Streaming maps it to `response.failed`. No double-report. +3. `settleFinish()` only ends transport iteration; the queued error stays the sole + adapter terminal. + +### What must survive from OUR commit + +`emittedTerminal` is NOT optional: `f145fd513` (unexpected server-side CANCEL +provenance) reads it to avoid flipping an already-completed buffered turn to +failed. Keep all three sites: + +```ts + private emittedTerminal = false; +``` + +```ts + const push = (message: CursorServerMessage) => { + const bytes = new TextEncoder().encode(JSON.stringify(message)).byteLength; + this.reserveTransportBytes(bytes); + if (message.type === "done" || message.type === "error") this.emittedTerminal = true; + queue.push({ message, bytes }); + wake(); + }; +``` + +```ts + this.framesReceived = 0; + this.emittedTerminal = false; + this.sawAssistantText = false; +``` + +### The merged end-handler block (MODIFY) + +Take dev's block and add one guard — `|| this.emittedTerminal` — so EOF +finalization cannot append a second terminal after a mapper error already failed +the turn: + +```ts + if (state.terminated || this.expectedClose || this.emittedTerminal) { + releaseBacklogLease(); + settler.settleFinish(); + return; + } + // Open tools fail closed as a domain-specific truncation event. Throwing here would + // replace that event with a generic transport failure at the Cursor adapter boundary. + if (state.openToolCalls.size > 0) { + for (const event of finalizeTurnEvents(state)) push(event); + releaseBacklogLease(); + settler.settleFinish(); + return; + } + if (this.framesReceived > 0 && this.sawAssistantText) { + for (const event of finalizeTurnEvents(state)) push(event); + releaseBacklogLease(); + settler.settleFinish(); + return; + } + releaseBacklogLease(); + settler.settleFinish(); +``` + +Also drop `CursorStreamTruncatedError` from the `live-transport.ts` import, since +this path no longer throws it. + +### `CursorStreamTruncatedError` itself (`src/adapters/cursor/cursor-errors.ts`) + +Keep the class. It is exported, it documents the condition, and removing it from +the same commit that rewrites the transport would widen the diff for no verified +gain. If the C-phase check shows it is unreferenced anywhere, note it as a +follow-up rather than deleting it inside this rebase. + +### TESTS — our expectation changes + +`tests/cursor-eof-terminal.test.ts` case *"EOF with an open tool call fails instead +of finishing silently"* asserts a THROWN error. Under the surviving shape that +assertion is wrong on the merits: the requirement is "do not finish silently," and +an explicit error event satisfies it more precisely than a thrown transport +failure. Rewrite that case to: + +```ts +expect(failure).toBeUndefined(); +expect(messages.some(m => m.type === "tool_call_end")).toBe(false); +expect(messages.some(m => m.type === "done")).toBe(false); +expect(messages.at(-1)).toMatchObject({ + type: "error", + message: expect.stringContaining("call_open_1"), +}); +``` + +The other three cases in that file pass unchanged. All 33 cases in dev's +`tests/cursor-hardening.test.ts` pass, including *"open tool call plus clean +Connect EOF emits a truncation error, not a thrown failure"* — which is dev's +regression test for exactly this decision. + +**The overlap is real and must be named in the commit message:** our 010 phase is +superseded by dev's independent fix. What our branch still contributes on this +file is `emittedTerminal` and the extra terminal guard. + +## Conflict 2 — `src/adapters/google.ts` (TEXTUAL) + +Our `f73f09c9e` adds buffered `finishReason` forwarding. dev's six commits are all +request-side identity/rename work and never touch the response-parsing block, so +the intent applies unchanged — only the line numbers drifted. + +- `parseResponse` now starts at dev `google.ts:812` (candidate read at `:894`). +- Insertion point moved from former `:939` to current `:946`. + +MODIFY, at dev's current `:946`: + +```diff + const usage = json.usageMetadata as Record | undefined; ++ // Mirror the streaming path: a buffered turn cut off by the token limit or a content filter ++ // must carry its stop reason, or the bridge sees a clean `done` and reports the truncated ++ // turn as completed — and, on a compaction turn, installs the half-written summary as ++ // replacement history (#422). ++ const finishReason = candidates?.[0]?.finishReason as string | undefined; ++ const stopReason = finishReason === "MAX_TOKENS" ++ ? "max_tokens" ++ : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(finishReason ?? "") ++ ? "content_filter" ++ : undefined; + events.push({ + type: "done", + usage: usageFromGemini(usage), ++ ...(stopReason ? { stopReason } : {}), + }); + return finish(events); +``` + +## Procedure + +1. Pin the base first (audit `r3` F1 / `r4` F1). Never rebase onto the tracking ref: + + git fetch origin dev + VERIFIED_BASE=$(git ls-remote origin refs/heads/dev | cut -f1) + git rebase "$VERIFIED_BASE" + + Record `VERIFIED_BASE`; every later phase compares against it and `040` evolves + it through the stack. The snapshot `cursor-call-prerebase-260818` = `fe2237038` is + the recovery path. Observed drift while planning: `87f7f970b` → `e1bdbc1e5` → + `1645bb924`, which is why a cached SHA in this doc is never the rebase target. +2. At the `54f68daf5` conflict: resolve to dev's block plus `emittedTerminal` and + the extra guard; drop the unused import; rewrite the one test expectation. + Amend the commit message to record the supersession. +3. **Expect a SECOND conflict at `f145fd513` (audit `r4` F3).** Step 2 removes + `CursorStreamTruncatedError` from the import line at `live-transport.ts:51`, and + `f145fd513` edits that same line to add `CursorUnexpectedCancelError` while still + listing the removed symbol. Resolve to: + + import { classifyCursorError, CursorUnexpectedCancelError, isCursorBenignCancelError, safeCursorErrorMessage } from "./cursor-errors"; + + Its functional context survives intact — the `emittedTerminal` write in `push()` + and both `classifyTurnFailure` throw sites still exist after the step-2 + resolution (verified at `live-transport.ts:541` and `:642`), so this is an import + line only. Do not let the conflict marker tempt a wider edit. +4. At any `google.ts` conflict: apply the hunk at dev's current location. +5. Every OTHER commit should apply cleanly (zero dev commits on those paths). If one + does not, STOP and investigate rather than resolving mechanically. +6. Adversarial audit round on the resolved diff before pushing. +7. **Push the rebased branch (audit `r7`).** WP3 verifies on lidge by fetching + `origin/cursor-call`, so an unpushed rebase means lidge either fails on an unknown + revision or silently tests the stale pre-rebase code: + + git push --force-with-lease --no-verify origin cursor-call + test "$(git ls-remote origin refs/heads/cursor-call | cut -f1)" = "$(git rev-parse cursor-call)" + + `--force-with-lease` rather than `--force`: the rewrite is expected, clobbering + someone else's push is not. The snapshot `cursor-call-prerebase-260818` remains the + recovery path. + +## Verification (C) + +Local, focused (fast signal only): + +``` +bun test tests/cursor-eof-terminal.test.ts tests/cursor-hardening.test.ts \ + tests/cursor-cancel-provenance.test.ts tests/cursor-tool-result-image.test.ts +bun x tsc --noEmit +rg -n '^<<<<<<<|^>>>>>>>|^=======$' src tests +git merge-base --is-ancestor origin/dev cursor-call # exit 0 +``` + +Authoritative verification is WP3 on `ssh lidge`. Expected: typecheck exit 0; the +focused cursor files green; no conflict markers; dev head an ancestor. diff --git a/devlog/_plan/260818_cursor_call_integration/012_audit_r7.md b/devlog/_plan/260818_cursor_call_integration/012_audit_r7.md new file mode 100644 index 0000000000..6861ad9e77 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/012_audit_r7.md @@ -0,0 +1,65 @@ +# 00A — Audit round r7-20260818040721: NEAR-PASS, gate closed + +Seventh reviewer, scoped to the branch-pointer stack plus a whole-plan consistency +sweep. **NEAR-PASS**: the stack passed on every property, and one execution blocker +plus one naming inconsistency were found and fixed. + +## The stack passed + +Measured, not asserted: + +- History is linear: 39 commits, zero merges. +- Boundary subjects unique and correctly ordered + (`git log --format='%s' ..cursor-call | sort | uniq -d` returns nothing). +- Ranges partition exactly: 17 + 3 + 19 = 39, no commit in two ranges, none omitted. +- Creating branches at those commits adds pointers only — no commit moves. +- A rebase preserves relative order for a linear, non-interactive, non-autosquashed + series, so the boundaries survive the rewrite. + +That is all four properties the previous four schemes fought for, obtained by not +fighting for subsystem purity. + +## Layering judged honest + +- **PR1** is independently gateable: its EOF resolution keeps `emittedTerminal` and + the terminal guard without needing PR2 or PR3, and its test asserts the standalone + error-event shape. +- **PR2** honestly depends on PR1's `emittedTerminal` and adds CANCEL provenance as + its own increment. +- **PR3** is broad but not a dumping ground: bridge/adapter terminal correctness is + its thesis, WP2b extends the same truncated-terminal path with usage, and the + cross-layer edits are identified comment/doc corrections that its body must name. +- Running `cursor-eof-terminal.test.ts` in both PR1 and PR3 is meaningful rather than + redundant: PR1 verifies terminal SHAPE, PR3 adds the usage cases, and PR1's + `toMatchObject` tolerates the added field. +- `tests/cursor-interaction-query.test.ts` is not edited at all — `015` preserves its + imports through the re-export — but PR3 owns running that existing contract. + +## Blocker — the rebased branch was never pushed before remote verification + +`020` has lidge fetch `origin/cursor-call` and create a worktree at the new SHA, and +`010` said the audit happens "before pushing" — but no push command existed anywhere +between them. lidge would have fetched the PRE-rebase branch and then either failed +on an unknown revision or, worse, silently tested stale code without the rebase or +WP2b. `origin/cursor-call` was still at `9f8ccec9d` when `r7` checked. + +**Fixed.** `010` gains step 7: + + git push --force-with-lease --no-verify origin cursor-call + test "$(git ls-remote origin refs/heads/cursor-call | cut -f1)" = "$(git rev-parse cursor-call)" + +`--force-with-lease` rather than `--force`: the rewrite is expected, clobbering +someone else's push is not. `020` also re-confirms the SHA on lidge before installing. + +## Naming inconsistency + +`030`'s prose still called the construction base `EXPECTED_DEV`, which `040` defines +as the EVOLVING merge-time variable, while `030`'s own commands correctly used +`VERIFIED_BASE`. Renamed throughout `030`; `EXPECTED_DEV` now appears only in `040`, +where it is initialized from `VERIFIED_BASE` and advanced per merge. + +## Terminal state of the roadmap cycle + +Seven rounds, 27 findings, all verified against the tree and absorbed. The plan is +executable end to end as written. + diff --git a/devlog/_plan/260818_cursor_call_integration/013_audit_r7_r8.md b/devlog/_plan/260818_cursor_call_integration/013_audit_r7_r8.md new file mode 100644 index 0000000000..58a562105d --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/013_audit_r7_r8.md @@ -0,0 +1,87 @@ +# 010a — Audit rounds r7 (PASS) and r8 (NEAR-PASS): the plan is executable + +## Why two rounds recorded together + +`r7` walked the whole sequence and returned **PASS** with no blocking findings. That +verdict could not be recorded: the goalplan's `activeWorkPhaseId` was `null`, and the +review observer discards a sign-off whose round targets a work-phase that is not +active (`review-observer.ts:99-101`). Every earlier round had been silently +discarded for the same reason, which is why `r2`-`r6` all had to be aborted as +inconclusive after their findings were absorbed. Fixed by setting +`activeWorkPhaseId = wp1-integration-roadmap`. + +`r8` then re-confirmed independently and returned **NEAR-PASS** with exactly one +finding. + +## r7 (PASS) — what it verified + +- The stack cut: both boundary subjects occur exactly once, the 39-commit range is + linear with no merges, and `17 + 3 + 19 = 39`. Neither boundary commit can be + dropped as empty — both boundary docs are absent from `dev`, and the overlapping + EOF commit stays non-empty because of `emittedTerminal`. +- All four `r6` findings are **structurally impossible** under the new procedure, not + merely unlikely: commits are referenced, never copied or rearranged. +- Retargeting is safe: a merge commit preserves PR1's commits, so after PR1 lands its + tip remains the merge base of `dev` and PR2, leaving PR2's effective diff exactly + `PR1_TIP..PR2_TIP`. The no-squash rule is what protects this. +- PR1 without WP2b is a complete, correct change: PR1 makes a truncated turn + reportable, WP2b later makes it report tokens. Nothing in PR1 imports the relocated + helper, and PR1's rewritten test asserts terminal shape without requiring usage. +- PR3's inventory is complete — 27 files, exactly the bridge/adapter work, the + integration unit, and the late corrections `030` requires the PR body to disclose. + +## r8 (NEAR-PASS) — the one finding, accepted + +Step 3's proof was incomplete: it asserted `wire → cancel → tip` but never +`VERIFIED_BASE → wire`. The counts do not cover that gap, because +`git rev-list --count A..B` counts commits reachable from B and not A **even when A +is not an ancestor of B**. The reviewer demonstrated it: against `dev` at +`1645bb924`, `git merge-base --is-ancestor 1645bb924 dfb6fb884` exits 1 while the +three counts still sum correctly. + +So a stack could have passed step 3 while its bottom did not sit on the verified +base — precisely the class of defect this plan has been failing on. Added: + + git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call-wire # exit 0 + +with the reasoning inline so a future reader does not delete it as redundant. + +`r8` also independently re-confirmed both conflict resolutions, WP2b's resolver +choice and re-export, and the governance position, and found no first-failing step +once the assertion is added. + +## r8's blocking finding: the push handed off the wrong tip + +`010` step 7 pushes the rebase tip, and `020` verifies whatever `origin/cursor-call` +points at. But the declared order is WP2 → WP2b → WP3, and WP2b changes code AFTER +step 7 runs while carrying only local checks. So lidge would have authoritatively +verified a tree without WP2b in it, and PR3's WP2b implementation would have reached +`dev` backed by nothing but a local `bun test`. + +The earlier push is not wrong, it is just not the handoff. `015` now ends with its +own push and SHA assertion, and `020` says explicitly which of the two pushes it +consumes: + + git push --force-with-lease --no-verify origin cursor-call + test "$(git ls-remote origin refs/heads/cursor-call | cut -f1)" = "$(git rev-parse cursor-call)" + +This is the same class as `r7`'s finding — a phase boundary where the artifact one +side produces is not the artifact the other side reads — which is why it survived +seven rounds of reading each document on its own terms. + +## Round tally + +| Round | Verdict | Findings | Recorded | +|-------|---------|----------|----------| +| r1 | FAIL | 6 | aborted (observer gap) | +| r2 | NEAR-PASS | 3 | aborted (observer gap) | +| r3 | FAIL | 5 | aborted (observer gap) | +| r4 | FAIL | 4 | aborted (observer gap) | +| r5 | FAIL | 3 | aborted (observer gap) | +| r6 | FAIL | 4 | aborted (observer gap) | +| r7 | PASS | 0 | aborted (observer gap — cause found here) | +| r8 | NEAR-PASS | 1 | recorded | + +26 findings, every one verified against the tree and absorbed. Four of the eight +rounds attacked the same question (how to split the stack) and the fourth failure +was the signal that the question itself was wrong — recorded in `009`. diff --git a/devlog/_plan/260818_cursor_call_integration/014_audit_r10.md b/devlog/_plan/260818_cursor_call_integration/014_audit_r10.md new file mode 100644 index 0000000000..a9d21eb3d3 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/014_audit_r10.md @@ -0,0 +1,60 @@ +# 014 — Audit round r10-20260818042302: NEAR-PASS, the third boundary defect + +Scope: confirm `r8`'s fix, then sweep EVERY phase boundary for the same class of +defect — one phase produces an artifact, the next reads a different one. Two +consecutive rounds had found that shape, so the round was aimed at a third instance +rather than at re-reading settled conclusions. + +It found one. + +## r8's fix confirmed + +- `015` ends with its own `--force-with-lease` push and an exact remote/local SHA + assertion (`015:144`). +- `020` explicitly consumes WP2b's later push, not `010`'s checkpoint (`020:33`). +- Both snippets parse correctly under zsh; substitutions unescaped, `test A = B` + well-formed. +- WP2→WP2b, WP2b→WP3 and WP5→WP6 are coherent; WP6 gates the named merged-`dev` SHA + in a dedicated worktree (`050:18`). + +## The finding: the verified tree was never bound to the merged PR heads + +`020` verifies one specific SHA. But `030` cut branches from `cursor-call` — a +MUTABLE ref — without asserting it still equaled that SHA, and `040` checked only the +live `dev` base before each merge, never the PR's own head. + +The consequence is subtle, which is why it survived nine rounds: a force-push to any +PR head introduces commits no gate has seen, and `040`'s post-merge ancestry check +**still passes**, because the verified tip remains an ancestor of a superset. The +check that was supposed to catch a bad merge is structurally incapable of catching +this one. + +## The fix — one named SHA threaded through three phases + +`020` now records it: + + VERIFIED_TIP=$(git rev-parse cursor-call) # after WP2b's push, before any gate + +`030` step 0 refuses to cut branches unless `cursor-call` still equals it, and step 5 +records each PR's expected head (`PR1_TIP`, `PR2_TIP`, `VERIFIED_TIP`). + +`040` asserts each head immediately before merging: + + gh pr view --json headRefOid --jq .headRefOid # must equal PR_HEAD + +with the reasoning inline, so nobody deletes it as redundant with the base check — +the base check proves `dev` has not moved and says nothing about what the PR points +at. + +## Why this class kept appearing + +Three rounds, three instances, same shape: `r7` (nothing pushed the rebase before +remote verification), `r8` (the push preceded WP2b, so the wrong tip was verified), +`r10` (the verified tip was never bound to what actually merges). Each document was +correct read alone. The defect only exists in the seam. + +The general lesson, recorded so the next unit inherits it: **a multi-phase plan needs +its artifacts NAMED and asserted across every handoff, not merely described +correctly within each phase.** `VERIFIED_BASE`, `EXPECTED_DEV` and now +`VERIFIED_TIP` are that naming; the assertions are what make the naming load-bearing. + diff --git a/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md new file mode 100644 index 0000000000..0beba7d975 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/015_phase2b_eof_usage.md @@ -0,0 +1,158 @@ +# 015 — WP2b: the surviving EOF truncation error must carry partial usage + +Origin: audit `r1` finding F3. This work-phase exists **because** `010` chose dev's +error-event shape; without it, choosing that shape would be a usage regression. + +Revised by audit `r2` finding 1: the value must be PARTIAL-failure usage, not +clean-turn usage. + +## The defect + +Two paths report a truncated Cursor turn, and only one of them reports tokens. + +| Path | Usage | +|------|-------| +| thrown transport failure | `attachPartialUsage` (`live-transport.ts:1193-1197`) → `cursor.ts:181-192` copies `partialUsage` into the error event | +| `finalizeTurnEvents` open-tool branch | none (`protobuf-events.ts:1367-1372`) | + +`CursorServerMessage`'s error variant already carries usage +(`src/adapters/cursor/types.ts:44-48`). So the omission is an oversight in the +open-tool branch, not a design constraint. + +Consequence: a turn that consumed real tokens and then truncated mid-tool-call +reports `usageStatus: unreported` with 0 tokens — the exact failure mode +`attachPartialUsage`'s own doc comment says it exists to prevent. + +## Which resolver (audit r2 finding 1) + +The first draft said `resolvedTurnUsage(state)`. That is wrong in one case, and the +reason is worth stating because it is the same class of mistake this campaign made +once before. + +`resolvedTurnUsage` (`protobuf-events.ts:1340-1352`) is the CLEAN-turn resolver: it +falls back to the session carry-forward, then the request-local estimate, so it +returns a number even when this turn produced no token signal at all. + +`partialUsageFromEventState` (`live-transport.ts:1178-1188`) exists precisely +because that is wrong for a failure. It returns `undefined` unless this turn +produced a checkpoint or a positive output delta, on its own stated grounds: "a +carry-forward value belongs to an earlier successful turn ... cannot by itself prove +that a first-frame failure consumed anything." + +An unconditional `resolvedTurnUsage` would therefore make the EOF error report +stale or inferred consumption exactly where the thrown path correctly reports none. +That trades one wrong number (0) for a different wrong number. + +Use the failure-specific helper. It currently lives in `live-transport.ts` while +`finalizeTurnEvents` lives in `protobuf-events.ts`, and `protobuf-events.ts` imports +nothing from the transport. So the helper moves DOWN to `protobuf-events.ts` (next +to `resolvedTurnUsage`, which it already calls) and `live-transport.ts` imports it +from there. That is the direction the dependency already runs; the reverse would +create a cycle. + +## MODIFY — `src/adapters/cursor/protobuf-events.ts` + +Move `partialUsageFromEventState` here from `live-transport.ts`, keeping its +exported name and its doc comment (it is exported for unit testing and +`live-transport.ts` keeps using it via import). + +Then, in `finalizeTurnEvents`, the open-tool branch: + + for (const callId of openCallIds) state.translatorBudget?.closeCall(callId); + state.openToolCalls.clear(); + // A truncated turn still consumed tokens, and the error variant carries usage + // (types.ts CursorServerMessage). Use the FAILURE resolver, not resolvedTurnUsage: + // a carry-forward or request estimate belongs to an earlier successful turn and must + // not be reported as this turn's consumption. Absent when nothing was proven, which + // matches the thrown path exactly. + const partial = partialUsageFromEventState(state); + return [{ + type: "error", + message: `Cursor stream ended with incomplete tool call(s): ${openIds}. Arguments may be truncated; the call was not committed.`, + ...(partial ? { usage: partial } : {}), + }]; + +Note the spread: no `usage` key at all when this turn proved nothing. + +## MODIFY — `src/adapters/cursor/live-transport.ts` + +Delete the local `partialUsageFromEventState` definition and import it from +`./protobuf-events` alongside the existing `finalizeTurnEvents` import. + +**RE-EXPORT it.** `tests/cursor-interaction-query.test.ts` imports it from +`live-transport.ts` five times (`:150`, `:164`, `:172`, `:189`, `:195`) — that file +is dev's existing contract for partial-usage reporting and this work-phase has no +business rewriting it. So: + + export { partialUsageFromEventState } from "./protobuf-events"; + +keeps every existing import path working while the definition lives in one place. +Verified with `rg -n 'partialUsageFromEventState' tests src`. + +Import-cycle check: `protobuf-events.ts` imports only `../../types`, `./gen/agent_pb`, +`./arg-codec`, `./arg-normalize`, `./types`, and `../../lib/translator-budget` — nothing +from `live-transport.ts`. Moving the helper down therefore adds no cycle; moving +`finalizeTurnEvents` up would have. + +## Confirmed before writing: the consumer forwards it + +`src/adapters/cursor/message-mapper.ts:29` maps an error message to +`{ type: "error", message, ...(message.usage ? { usage: message.usage } : {}) }`, and +`src/adapters/cursor.ts:127-142` emits the mapped event unchanged. The patch site is +right and no mapper change is needed. (`cursor.ts:181-192` is the THROWN path's +`err.partialUsage` handling, unrelated to an event that flowed through the mapper.) + +## TESTS — `tests/cursor-eof-terminal.test.ts` + +Two cases, both driven red first. + +Positive — a real token signal this turn: + + test("an EOF truncation error reports the tokens the turn already consumed", async () => { + // Assistant text plus a tokenDelta (or checkpoint) BEFORE the open tool call, + // then clean EOF with no terminal. Red before the fix: usage is undefined. + expect(errorEvent.usage).toBeDefined(); + expect(errorEvent.usage?.outputTokens ?? 0).toBeGreaterThan(0); + }); + +Negative — carry-forward only, which audit `r2` asked for. Without it, a later +change could satisfy the positive case by reporting a previous turn's tokens: + + test("an EOF truncation with no token signal this turn reports no usage at all", async () => { + // Seed a session carry-forward / request estimate, then open a tool call and EOF + // with NO checkpoint and NO tokenDelta this turn. + expect(errorEvent.usage).toBeUndefined(); + }); + +The rewritten case from `010` asserts the SHAPE (error event, no `done`, no +`tool_call_end`); these assert the USAGE. Keeping them separate means a future +change cannot quietly satisfy one by breaking the other — the mistake this campaign +already made once, when a test titled "carrying usage" never asserted usage. + +`010`'s assertion uses `toMatchObject`, which tolerates the added `usage` property, +so the two docs do not conflict (confirmed in audit `r2`). + +## Verification (C) + + bun test tests/cursor-eof-terminal.test.ts tests/cursor-hardening.test.ts \ + tests/cursor-interaction-query.test.ts + bun x tsc --noEmit + +`tests/cursor-interaction-query.test.ts:148-185` is in the list because it is the +existing contract for partial-usage reporting; this change must not disturb it. + +## Push before handing off to WP3 (audit `r8`) + +`010` step 7 pushes the rebase tip, and that push happens BEFORE this work-phase +exists. WP3 then verifies whatever `origin/cursor-call` points at — so without a +second push here, lidge would authoritatively verify a tip that does not contain +WP2b, and PR3's WP2b implementation would reach `dev` with only local checks behind +it. + +So this work-phase ends with: + + git push --force-with-lease --no-verify origin cursor-call + test "$(git ls-remote origin refs/heads/cursor-call | cut -f1)" = "$(git rev-parse cursor-call)" + +This is the push WP3 hands off from. `010`'s earlier push stays (it is a harmless +checkpoint after the rebase), but it is not the verification handoff. diff --git a/devlog/_plan/260818_cursor_call_integration/016_audit_r13.md b/devlog/_plan/260818_cursor_call_integration/016_audit_r13.md new file mode 100644 index 0000000000..80f8959e37 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/016_audit_r13.md @@ -0,0 +1,59 @@ +# 016 — Audit round r13-20260818042842: NEAR-PASS, the assertions had to become commands + +`r13` did something the previous twelve rounds did not: it RAN the snippets. Four +blockers, all of the same kind — the plan described the right check in prose but did +not express it as something a shell would execute. + +## What it found + +1. **`VERIFIED_TIP` was captured after the gates, not before.** `020` created the + worktree at a placeholder `` and only named `VERIFIED_TIP` in a later + section. If `cursor-call` moved during the ~8-minute suite, the recorded tip and + the tested tree would differ — the exact defect `r10` closed one layer up, reopened + inside the phase that owns it. + +2. **`PR1_HEAD = ` is not an assignment.** The reviewer probed it: zsh + exits 127 with `command not found: PR1_HEAD`, because `NAME = value` runs `NAME` + as a command. The whole PR-head binding was a legend, not code. + +3. **`040` printed `headRefOid` instead of comparing it.** A value the operator has + to eyeball is not a gate. + +4. **`PR1_TIP`/`PR2_TIP` were read by eye** and never asserted against the created + branch tips. The ancestry and count checks prove topology, not identity. + +5. **`MERGED_DEV` was a fresh mutable-ref read**, so a concurrent push after PR3 + landed would be silently attributed to this campaign while the ancestry test still + passed. + +## The fix + +Every one became an executable assertion: + +- `020` captures `VERIFIED_TIP` from `ls-remote` BEFORE the worktree, builds the + worktree at that SHA, and asserts the remote HEAD equals it after checkout. +- `030` step 1 binds `PR1_TIP`/`PR2_TIP` with `git log ... | grep -F ... | cut` + instead of "read them off that list". +- `030` step 5 uses real assignments (`PR1_HEAD=$(git rev-parse ...)`) and then + `test "$PR1_HEAD" = "$PR1_TIP"` — the assertion that actually binds branch tips to + the verified tree. +- `040` merges behind `test "$(gh pr view --json headRefOid --jq .headRefOid)" = "$PR1_HEAD"`. +- `040` takes `MERGED_DEV` from `gh pr view --json mergeCommit` — the merge + itself — and separately asserts `dev` still points there, with an explicit branch + for what to do if someone pushed after us. + +Verified by running the extraction against the real tree: + + PR1_TIP=dfb6fb884df1df819aaf0d9d2ddfd07408860ea3 + PR2_TIP=6d974428396fc1cb283353142e10f07074aecc00 + +which are exactly the two boundary commits `030` names. + +## The pattern, four rounds running + +`r7`, `r8`, `r10` and now `r13` all found the same failure mode at different +altitudes: an artifact that one phase produces and another consumes, where the +binding lives in prose instead of in a command. Each document read correctly on its +own. The plan is only as strong as its seams, and a seam is only real when it is a +`test`. + diff --git a/devlog/_plan/260818_cursor_call_integration/017_audit_r12.md b/devlog/_plan/260818_cursor_call_integration/017_audit_r12.md new file mode 100644 index 0000000000..28985e88b3 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/017_audit_r12.md @@ -0,0 +1,73 @@ +# 016 — Audit round r12: the artifact-chain class, found twice more + +## The pattern, now named + +Rounds `r7`, `r8`, `r10`, `r12` and `r13` all found the SAME class of defect: a +phase boundary where the artifact one phase produces is not the artifact the next +phase reads. + +| Round | The gap | +|-------|---------| +| r7 | No push between the rebase (WP2) and remote verification (WP3) — lidge would fetch the pre-rebase branch | +| r8 | That push ran BEFORE WP2b changed code, so lidge would bless a tree without WP2b | +| r10 | The chain stopped at WP3 — nothing downstream asserted the tree being cut, reviewed, and merged was still the verified SHA | +| r12 | `VERIFIED_TIP` was recorded AFTER the gates ran, and per-layer gates had no execution site at all | +| r13 | `PR1_HEAD = ` was prose, not an assignment — under zsh it exits 127 | + +Each document reads correctly on its own terms, which is why these survived rounds of +reading them one at a time. The defect only surfaces when you ask at every boundary: +what does this side produce, and does the other side bind to it? + +## r12 finding 1 (High) — `VERIFIED_TIP` recorded after it was used + +`020` created the lidge worktree at an unnamed ``, ran every gate, and only then +introduced `VERIFIED_TIP` in a section claiming to be "before any gate". If +`cursor-call` moved during the ~8-minute suite, `030` would bind to a different tree +than the one tested. + +**Closed.** The capture moved to the top of the worktree section: read the LIVE remote +with `git ls-remote`, assert local and remote agree, create the worktree AT that SHA, +re-assert `git rev-parse HEAD` inside it before installing. Every gate then runs in +`/tmp/ocx-cc-${VERIFIED_TIP:0:9}`, so the path itself carries the SHA. + +## r12 finding 2 (High) — per-layer evidence had no execution site + +`020`'s table said WHAT each layer runs and never WHERE. Running PR1's tests at +`VERIFIED_TIP` proves nothing about PR1: that tree already contains PR2's and PR3's +code, so a PR1 test could pass because of something a PR1 reviewer never sees. Yet +`030` requires each PR body to cite commands, output, and a SHA. + +**Closed.** `020` gains the execution procedure and `030` step 5 names the sequencing. +Because the layer branches do not exist until `030` step 2, WP3 is ordered: + +1. gates at `VERIFIED_TIP` — full suite, typecheck, privacy:scan, audit:high, + build:gui. This is PR3's evidence. +2. `030` steps 0-4 — bind, cut, prove the partition, record the head SHAs. +3. one lidge worktree per layer head, pinned and asserted, running that layer's + typecheck plus its own test files. +4. `030` step 6 — push and open the PRs, each citing its own run. + +`PR3_HEAD` equals `VERIFIED_TIP`, so step 1 already covers it. Every layer's evidence +now names the same SHA `040` asserts with `gh pr view --json headRefOid` before +merging. + +## What r12 confirmed holds + +- WP2 → WP2b: checkpoint push and equality assertion exist; WP2b's later push + supersedes them as authoritative. +- WP4 → WP5: expected heads recorded and checked before every merge. + `PR3_HEAD = VERIFIED_TIP` is correct because PR3's head IS `cursor-call`. +- WP5 → WP6: `040` produces `MERGED_DEV`; `050` gates that exact SHA instead of + re-reading a moving `dev`. +- WP6's note requires each gate's command, output, and SHA. +- Stack proof at the audited tip: no duplicate subjects, zero merges, ancestry chain + passes, ranges `17 + 3 + 27 = 47`. +- Both `010` conflict resolutions and `015`'s failure-specific usage design: no drift. + +## Tally + +Thirteen rounds, 35 findings, every one verified against the tree and absorbed. Two +clusters account for most: four rounds on how to split the stack (resolved by +abandoning subsystem purity — `009`), and five on artifact-chain boundaries (resolved +here). + diff --git a/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md b/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md new file mode 100644 index 0000000000..ab9b54fdf3 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/018_audit_r14.md @@ -0,0 +1,111 @@ +# 018 — Audit round r14: three defects found by RUNNING the plan + +`r14` did what `r13` started: it executed every shell fragment in the unit against a +scratch zsh with real SHAs substituted, simulating only the mutating commands. Three +High findings, all closed. + +## F1 — the authoritative gates were not bound to the pinned worktree + +`020` created `/tmp/ocx-cc-` on lidge, asserted its HEAD, and then listed the +gates as bare local commands: + + bun x tsc --noEmit + bun run privacy:scan + ... + +Copied into a shell, those run against whatever directory the operator is in. The +phase could report green for a tree that is not `VERIFIED_TIP` — which is the whole +thing `r10` and `r12` were about. + +**Closed.** The gates are now a loop that runs each one over ssh inside the worktree +and re-asserts the SHA first: + + ssh lidge "cd $CC_WT && test \"\$(git rev-parse HEAD)\" = \"$VERIFIED_TIP\" && $GATE" + +## F2 — the per-layer fragment could not run + +Two separate breakages in one block: + +1. It used `$PR1_HEAD` and `$PR2_HEAD`, which `030` does not assign until step 4, + while `020` ordered this section after step 3. A zsh probe with unset-variable + checking fails outright. +2. `bun test ` is a zsh parse error + (`unmatched '`), not an instruction. A placeholder inside a code block is a bug. + +**Closed.** The section now runs after `030` step 4, spells out `PR1_TESTS` and +`PR2_TESTS` as real variables, and wraps the per-layer work in a `run_layer` function +that pins, asserts, installs, typechecks, and runs that layer's files. + +## F3 — the sixth artifact-chain gap: no PR BASE assertion + +`040` asserted the live `dev` SHA (has `dev` moved?) and each `headRefOid` (has the +PR head moved?). Neither answers "is this PR still pointing at `dev`". A retarget to +`main`, or to a parent branch that has since merged, passes both — and +`gh pr merge` would merge into that base. `030` printed the bases for inspection, +which is not a gate. + +**Closed.** The pre-merge check for every layer is now three assertions together: +`baseRefName == dev`, `headRefOid == EXPECTED_HEAD`, live `dev == EXPECTED_DEV`. + +## The artifact-chain sweep r14 ran + +| Boundary | Artifact | Binding | Result | +|---|---|---|---| +| WP2 → WP2b | rebased `cursor-call`, `VERIFIED_BASE` | push + remote/local equality | PASS | +| WP2b → WP3 | WP2b's remote tip | `VERIFIED_TIP` from live remote, local equality, worktree HEAD assertion | PASS | +| WP3 → WP4 | gate evidence | branch tips bound to `VERIFIED_TIP` | **FAIL → fixed (F1, F2)** | +| WP4 → WP5 | PR heads + base topology | head + live-`dev` checks | **FAIL → fixed (F3)** | +| WP5 → WP6 | PR3 merge OID as `MERGED_DEV` | worktree at `MERGED_DEV` + HEAD equality | PASS | +| WP6 → note | gate outputs | note requires command, output, SHA, fresh refs | PASS | + +## What r14 confirmed + +- Stack at the audited tip: unique subjects, zero merges, ancestry intact, + `17 + 3 + 31 = 51`. +- Both `010` conflict resolutions still coherent against the live `dev`. +- `015`'s failure-specific usage choice, re-export, and no-cycle property. +- `040`'s governance position claims an owner-authorized exception, not compliance. +- It also ran the focused Cursor tests: **71 pass, 0 fail**, and typecheck exit 0. + +## A second r14 pass found three MORE unbound artifacts + +The same round, re-run against the fixes above, went through every named variable +rather than every code block. Six of nine failed: + +1. **`VERIFIED_BASE` was captured twice.** `010:166` pins it before the rebase; + `020` captured it AGAIN afterwards. A second `ls-remote` overwrites the pin with a + newer `dev`, and every later assertion then compares against a base the campaign + never rebased onto. `020` now inherits and asserts it + (`test -n` + `merge-base --is-ancestor "$VERIFIED_BASE" cursor-call`). + +2. **`git branch cursor-call-wire ` fails `zsh -n`.** The angle-bracket form + is not shell syntax. `030` captured the boundary SHAs correctly and then did not + consume them. Fixed in both the branch creation and the `git show --stat` checks. + +3. **`040` carried THREE merge procedures** that disagreed. One referenced `$PRN`, + `$PRN_HEAD` and `$PR_NEXT` — none of which any phase assigns. One omitted both the + head assertion and the `EXPECTED_DEV` update. The sharpest point: the + angle-bracket forms in `040` parse only because zsh reads them as REDIRECTIONS, + which is worse than failing — they run and bind nothing. + + Now there is one `merge_layer` function taking the PR number and its expected + head, asserting base + head + `EXPECTED_DEV` before merging and advancing + `EXPECTED_DEV` from the merge commit inside the function. + +Verified by running the chain under zsh: parses clean, extractions return +`dfb6fb884…` and `6d974428…`, the two boundary commits `030` names. + +### Side effect worth recording + +The reviewer executed one `020` snippet during isolation and left worktree +`/tmp/ocx-L-` plus branch `ocx-L-` on lidge. Both removed; nothing was pushed or +edited there. Worth noting that a READ-ONLY brief still produced remote state — the +snippets are executable now, which is the point, and an auditor running them is a +foreseeable consequence. Later briefs say so explicitly. + +## Tally + +Fourteen rounds, 38 findings, every one verified and absorbed. Six of them are the +artifact-chain class (`r7`, `r8`, `r10`, `r12`, `r13`, `r14`) and four were the +stack-split cluster (`r3`-`r6`). The lesson `r13` and `r14` add: a plan that is only +READ will keep hiding fragments that cannot RUN. diff --git a/devlog/_plan/260818_cursor_call_integration/019_the_plan_becomes_a_program.md b/devlog/_plan/260818_cursor_call_integration/019_the_plan_becomes_a_program.md new file mode 100644 index 0000000000..7dabf115a2 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/019_the_plan_becomes_a_program.md @@ -0,0 +1,185 @@ +# 019 — The plan becomes a program + +Fifteen audit rounds. Six of them (`r7`, `r8`, `r10`, `r12`, `r13`, `r14`, `r15`) +found the same defect class at different altitudes: a binding written as prose that +no shell would enforce. Each round I fixed the instances it named, and the next round +found new ones — `VERIFIED_BASE` captured twice, `git branch cursor-call-wire +` failing `zsh -n`, three contradictory merge ladders, `` that zsh +reads as a redirection, `PR1_TIP` consumed at line 88 and assigned at line 94, PR +numbers referenced but never assigned anywhere. + +Seven rounds of the same failure is not seven mistakes. It is one wrong idea: +**I was trying to make a document behave like a program.** + +A markdown file cannot enforce that a variable is assigned before it is read. It +cannot fail. Every fix I wrote was a promise that the next reader would execute the +fragments in the right order with the right values in scope — and every audit proved +that promise false, because the promise is unenforceable by construction. + +## What changed + +`cursor-call-integration.zsh` in this directory is now the executable form of +`010`/`020`/`030`/`040`/`050`. The decade docs keep their job — the EVIDENCE and the +REASONING for each decision, which is what a reviewer needs and what a script cannot +carry. The script owns what runs. + +Properties the prose could never have: + +- **`set -euo pipefail`** — an unset variable is a hard error, not a silent empty + string. The entire class of finding that consumed seven rounds is now impossible. +- **State on disk** (`.tmp/cursor-call-integration.env`, gitignored). Each step reads + what earlier steps recorded, so a compaction or a disconnect costs nothing, and + `need VERIFIED_TIP` fails loudly instead of proceeding with an empty value. +- **Every assertion is a `test` or an `||die`.** There is no printed value for an + operator to eyeball. +- **Idempotent steps** — `git branch -f`, `worktree add ... || true`, and + `EXPECTED_DEV` re-derived from state on each run. +- **Nothing merges or pushes implicitly.** The operator names the step. + +## Verified, not asserted + + zsh -n cursor-call-integration.zsh -> PARSE_OK + zsh cursor-call-integration.zsh state -> "no state yet" + zsh cursor-call-integration.zsh pin -> recorded VERIFIED_BASE=1645bb924… + zsh cursor-call-integration.zsh state -> the value persisted + zsh cursor-call-integration.zsh cut -> FATAL: VERIFIED_TIP is not set (exit 1) + zsh cursor-call-integration.zsh merge -> FATAL: VERIFIED_TIP is not set (exit 1) + zsh cursor-call-integration.zsh release_gates -> FATAL: MERGED_DEV is not set (exit 1) + zsh cursor-call-integration.zsh record_prs 1 2 -> FATAL: usage (exit 1) + zsh cursor-call-integration.zsh bogus -> FATAL: unknown step (exit 1) + +Those failures are the point: the ordering the prose could only request, the script +enforces. + +## Step map + +| Step | Doc | What it does | +|------|-----|--------------| +| `pin` | `010` | `VERIFIED_BASE` from live `ls-remote`, recorded | +| `rebase` | `010` | rebase onto it, assert ancestry, refuse conflict markers | +| `push` | `010`/`015` | `--force-with-lease`, assert remote == local | +| `verify` | `020` | `VERIFIED_TIP`, lidge worktree at that SHA, five gates each re-asserting HEAD | +| `cut` | `030` | boundaries by subject, three ancestry assertions, count partition, push branches | +| `verify_layers` | `020`/`030` | push the two layer branches, then typecheck + that layer's own tests AT ITS OWN HEAD | +| `record_prs` | `030` | operator records the three PR numbers after `gh pr create` | +| `merge` | `040` | per layer: base==dev, head==expected, live dev==EXPECTED_DEV, then merge and advance from the merge commit | +| `release_gates` | `050` | `MERGED_DEV` worktree on lidge, five gates | +| `release_state` | `050` | live main/dev/tags/dist-tags/releases for the readiness note | +| `cleanup` | `020`/`050` | remove the verification worktrees both phases require removing | + +## What round 15 found in the FIRST version of this script + +Writing the program did not make the program correct — it made its defects findable. +The audit ran it and found seven, three of them fatal: + +- **`LIDGE_HOME=~/Developer/opencodex` expanded LOCALLY.** zsh resolved `~` to + `/Users/jun`, so every ssh command sent a macOS path to a Linux host. The remote + gates could not have run at all. Now single-quoted `'$HOME/Developer/opencodex'`, + expanded by the remote shell. +- **`merge` could not resume.** It restarted at PR1 every time, so a disconnect + between `gh pr merge` and `save` would re-attempt a merged PR. `merge_layer` now + reads the PR state first: `MERGED` adopts its merge commit and returns, `OPEN` + proceeds, anything else is fatal. +- **Layers were never verified at their own heads**, which `AGENTS.md:178-180` + requires. `cut` pushed and moved on. That work is now `verify_layers`, and + `merge` refuses without `LAYERS_GREEN_AT`. + +And four smaller ones: `|| true` on worktree creation swallowed real failures and +accepted a dirty tree at the right HEAD; neither worktree was ever removed; the +conflict scan missed a lone `=======`; `push`/`verify` did not require +`VERIFIED_BASE`; re-running `pin` silently invalidated every downstream artifact; +and the state file was `source`d without validating what went into it. + +Three more I found by running it myself: `save` appended instead of replacing, +`ROOT` counted `..` wrong and wrote state to `devlog/.tmp/`, and two steps never +called `load_state`. + +Ten defects in a 200-line script, none of which fifteen rounds of reading prose had +surfaced. That is the case for the rewrite, and also the case for not trusting the +rewrite until it has been run. + +## And round 16 found eight more, one of which bricked the whole thing + +The fixes for round 15 introduced a fatal bug and left four holes: + +- **The value validator rejected EVERYTHING.** `[[ "$v" == [A-Za-z0-9._/-]## ]]` + needs `EXTENDED_GLOB`, which `set -euo pipefail` does not enable. Every `save` + died, so `pin` could not record a base and nothing downstream could run at all. + The security fix had bricked the script, and `zsh -n` cannot see it because the + syntax is valid — only running it shows the match failing. +- **`LAYERS_GREEN_AT` was a presence check.** It stored `PR2_HEAD` and `merge` + only asked whether it was non-empty, so verifying old layers then re-cutting new + ones inherited the marker. It now stores `${PR1_HEAD}+${PR2_HEAD}` and `merge` + compares it against the current heads. +- **`--repin` invalidated nothing.** The guard refused a silent re-pin, but the + override rewrote `VERIFIED_BASE` and left every downstream artifact looking valid. + It now clears them all, which is what makes the guard meaningful. +- **The MERGED resume path adopted a merge unchecked.** A PR merged by anyone, from + any head, into any base, would have been accepted as campaign output. It now + asserts base, head, and that the merge commit contains the verified head. +- **PR3's merge not being on `dev` was a log line.** Now fatal — the release gates + would otherwise run on a commit that never landed. +- **`cleanup` could not clean a failed run.** The worktree paths were saved only + after all gates passed, so the failure case left them unreachable. Saved on + creation now. +- **`release_state` did not require the release gates**, so a readiness note could + be prepared before anything verified the merged tree. +- **`rebase` restarted instead of continuing.** `010` expects two conflicts; a + re-run mid-rebase would have discarded the resolution. It now detects + `rebase-merge`/`rebase-apply` and continues. + +Plus one of my own: the driver never passed `$@` to `step_pin`, so `--repin` could +not reach the guard it was written for. + +Verified after the fixes: `pin` records, `record_prs` stores three numbers, the +re-pin guard refuses, and `pin --repin` clears every downstream key. + +## Round 16's second pass: seven more, and a rule about save ordering + +- **A MERGED layer did not prove its predecessor.** It checked base, head, and that + the merge contained the verified head — but not that it was merged ONTO the + `EXPECTED_DEV` this campaign produced. A merge made after an unrelated commit + landed on `dev` would have been adopted as the next layer. Now the merge commit's + first parent must equal `EXPECTED_DEV`. +- **`MERGED_DEV` was saved BEFORE its proofs.** A disconnect or a failing ancestry + assertion left a durable key that `release_gates` trusts on the next invocation — + reopening the exact bypass the proof existed to close. It is now the last thing + `merge` does. + + The general rule this produces: **a durable key is a CLAIM that its proofs passed, + so it is written after them, never before.** That is now true of every `save` in + the file except the worktree paths, which are deliberately the opposite — they are + cleanup handles, so they must exist BEFORE the thing that might fail. +- **`--repin` orphaned the remote worktrees.** It cleared `CC_WORKTREE` and + `DEV_WORKTREE` without removing them first, so `cleanup` lost its only handle. It + now removes them before forgetting them. +- **A failed per-layer gate left an untracked worktree.** `LAYER_WORKTREE` is + recorded before the gates and cleared on success. +- **`subject_sha` could return two SHAs.** Two commits sharing a subject would have + produced a two-line "SHA" that every later assertion compared against. It now + fails unless exactly one matches. +- **Gate markers were not evidence.** `020` and `050` require the command, its + output and the SHA. Every gate now appends a receipt to + `.tmp/cursor-call-receipts.log`, including on failure, and the readiness note + quotes from it. +- **`record_prs` recorded three unchecked numbers.** It now verifies each PR points + at its layer head and carries all three template sections. The body's SUBSTANCE + stays the agent's to write; its STRUCTURE is checkable, so it is checked. + +Round 16 also drew the line the script should not cross: creating the PRs and +authoring their descriptions, the `docs-site/` determination, the readiness note's +judgment, and semantic conflict resolution are all work that requires understanding +what the change means. The script validates those afterwards rather than inventing +them. + +`record_prs` is deliberately manual: PR numbers do not exist until `gh pr create` +returns them, and inventing a way to guess them would reintroduce exactly the +unbound-value problem this file exists to end. + +## What the docs still own + +The script says what runs. It does not say why dev's error-event EOF shape beat ours, +why WP2b must use `partialUsageFromEventState` rather than `resolvedTurnUsage`, why +the stack splits where it does, or why the merge is an owner-authorized exception +rather than policy compliance. Those live in `010`, `015`, `030` and `040`, and a +reviewer needs them more than they need the commands. diff --git a/devlog/_plan/260818_cursor_call_integration/020_phase2.md b/devlog/_plan/260818_cursor_call_integration/020_phase2.md new file mode 100644 index 0000000000..7766d89d0c --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/020_phase2.md @@ -0,0 +1,210 @@ +# 020 — WP3: full remote verification on ssh lidge + +> **EXECUTION AUTHORITY: `cursor-call-integration.zsh verify`.** +> The commands below are the reasoning, not the runbook. Seven audit rounds proved a +> markdown file cannot enforce that a variable is bound before it is read (`019`), so +> the script owns what runs and this doc owns why. If they disagree the script is +> right and this doc is stale — fix the doc. + +Revised by audit `r1` F5 (gates moved before the PR) and audit `r3` F1+F2 (base +pinning, and never `checkout -f` a shared checkout). + +## Why remote, and why the FULL suite + +The campaign touches `src/bridge.ts`, `src/adapters/google.ts`, +`src/adapters/anthropic.ts`, `src/adapters/command-code.ts` — shared runtime, not a +scoped adapter change. `AGENTS.md` §Commands requires `bun run typecheck` and +`bun run test` before a non-trivial PR is review-ready. + +Standing user contract: the authoritative suite runs on `ssh lidge`, never locally. + +lidge: `/home/lidgeai/Developer/opencodex`, bun 1.3.14, 16 cores. + +`--isolate` is required: the flat suite bleeds environment between files without it. + +## Use a DEDICATED worktree, never `checkout -f` the shared clone (r3 F2) + +`~/Developer/opencodex` is a shared working checkout, and `git checkout -f` there +would silently discard any tracked uncommitted work. `git worktree list` on lidge +already shows a dozen `/tmp/ocx-*` verification worktrees, so this is the +established pattern there: + +Capture the tip FIRST and build the worktree AT it, so the SHA this phase records is +provably the SHA it tested (audit `r13`): + + VERIFIED_TIP=$(git ls-remote origin refs/heads/cursor-call | cut -f1) + test "$VERIFIED_TIP" = "$(git rev-parse cursor-call)" # local and remote agree + ssh lidge "cd ~/Developer/opencodex && git fetch origin cursor-call dev && git worktree add /tmp/ocx-cc-${VERIFIED_TIP:0:9} $VERIFIED_TIP" + ssh lidge "cd /tmp/ocx-cc-${VERIFIED_TIP:0:9} && test \"\$(git rev-parse HEAD)\" = \"$VERIFIED_TIP\" && bun install --frozen-lockfile" + +Every gate below then runs in `/tmp/ocx-cc-${VERIFIED_TIP:0:9}`. + +`VERIFIED_TIP` is the tip pushed at the END of WP2b, not `010`'s post-rebase checkpoint +push (audit `r8`). WP2b changes code after `010` step 7 runs, so verifying the +earlier tip would authoritatively bless a tree without WP2b in it. Both work-phases +push and assert `git ls-remote` matches `git rev-parse cursor-call`; this phase +consumes the later one. Confirm the SHA here too before installing: + +``` +ssh lidge 'cd ~/Developer/opencodex && git rev-parse origin/cursor-call' # == local rebase tip +``` + +Remove the worktree when the phase closes (`git worktree remove`), and never touch +the shared checkout's HEAD. + +## Pin the base (r3 F1), and remember it EVOLVES (r4 F1) + +`dev` moves, so `VERIFIED_BASE` is captured ONCE — in `010` step 1, at the moment +the rebase runs — and inherited here. Do NOT re-capture it in this phase (audit +`r14`): a second `ls-remote` after the rebase would silently overwrite the pin with +a newer `dev`, and every later assertion would then compare against a base the +campaign never rebased onto. Assert instead: + + test -n "$VERIFIED_BASE" + git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call # exit 0 + +`VERIFIED_BASE` is what `010` step 1 rebased ONTO — not +`origin/dev`, which can be minutes stale (`scripts/release.ts:327-335` uses +`ls-remote` for exactly this reason). Observed drift during planning alone: +`87f7f970b` → `e1bdbc1e5` → `1645bb924`. + +`VERIFIED_BASE` is the value `040` checks before merging PR1. It then becomes each +layer's merge result in turn (`040`'s `EXPECTED_DEV`), because after PR1 lands the +live `dev` head legitimately differs from the original. + +## Gates + +Every gate runs INSIDE the pinned worktree, over ssh, with the SHA re-asserted first +(audit `r14`). Written as bare local commands they would execute against whatever +directory the operator happens to be in, and could pass for a tree that is not +`VERIFIED_TIP`: + + CC_WT="/tmp/ocx-cc-${VERIFIED_TIP:0:9}" + for GATE in \ + "bun x tsc --noEmit" \ + "bun run privacy:scan" \ + "bun run audit:high" \ + "bun run build:gui" \ + "bun test --isolate tests" + do + ssh lidge "cd $CC_WT && test \"\$(git rev-parse HEAD)\" = \"$VERIFIED_TIP\" && $GATE" + done + +`build:gui` precedes the suite only because it is the shorter of the two long gates; +order is not load-bearing. Run the suite as a managed background session and poll. + +`audit:high` and `privacy:scan` are in `scripts/release.ts:374,380`. +`build:gui` is here because `prepublishOnly` (`package.json:49`) runs it on every +publish regardless of whether `gui/` changed, and it also runs `prepare:package`. +"No gui/ path changed" is therefore not a reason to skip it for a readiness claim. + +Run the suite and the gui build as managed background sessions and poll. + +## Expected evidence + +- `bun x tsc --noEmit` → exit 0, no output. +- `bun run privacy:scan` → exit 0. +- `bun run audit:high` → exit 0. If it reports a pre-existing advisory that also + fails at `VERIFIED_BASE`, record that comparison rather than blaming this branch. +- `bun test --isolate tests` → **0 fail**. Pass counts move as dev grows; the bar is + 0 fail. (Data points: 12761 at the old base, 12800 at the campaign tip.) +- `bun run build:gui` → exit 0. + +## Platform gap (state it, do not paper over it) + +lidge is Linux. Repository CI covers Linux, Windows, and macOS. This campaign's +28-path diff contains no shim, installer, PowerShell, platform dispatch, or Windows +path handling — verified in audit `r3`. That is why Linux evidence is adequate *for +this diff*, and it is not a claim that Linux equals CI. + +## Known flake (do NOT call it a regression without isolation) + +`tests/request-pacing.test.ts` and `tests/codex-auth-api.test.ts` have failed under +parallel load and passed in isolation on BOTH the pre- and post-campaign SHAs. If +either fails, re-run that file alone first. + +## Repair discipline + +LOOP-REPAIR-01: read the failure delta, repair only that delta, re-verify. Two +consecutive failed repairs of the same failure → root-cause mode. Three → back to P +with a changed plan. + +## Verification (C) + +Typecheck, privacy:scan, audit:high, and build:gui each exit 0, and `0 fail` from +`bun test --isolate tests` — each quoted with the SHA it ran against, plus the +recorded `VERIFIED_BASE`. + +## Record `VERIFIED_TIP` (audit `r10`) + +The SHA these gates ran against is the ONLY tree this campaign has authoritative +evidence for. It is captured ABOVE, before the worktree is created — not here, and +not after the gates (audit `r13`): a value read afterwards could differ from the tree +that was actually tested if `cursor-call` moved during the ~8-minute suite. + +Every later phase binds to it: `030` refuses to cut branches unless `cursor-call` +still equals `VERIFIED_TIP`, and `040` compares each PR's `headRefOid` against its +expected SHA immediately before merging. Without that chain, a force-push to any PR +head could introduce commits nobody verified while `040`'s post-merge ancestry check +still passes — the verified tip stays an ancestor either way. + +## Per-layer verification (r3 F3) + +Because `030` now opens a real 3-PR stack, each layer needs its own evidence +(`AGENTS.md:178-180`). Full suite on the TOP of the stack; per-layer verification is +typecheck plus the tests that layer owns: + +| Layer | Focused tests | +|-------|---------------| +| PR1 (Cursor EOF + tool-result wire) | `tests/cursor-eof-terminal.test.ts`, `tests/cursor-hardening.test.ts`, `tests/cursor-tool-result-image.test.ts`, `tests/cursor-request-builder.test.ts` | +| PR2 (unexpected CANCEL) | `tests/cursor-cancel-provenance.test.ts`, `tests/cursor-hardening.test.ts` | +| PR3 (bridge/adapter terminals + **WP2b**) | `tests/bridge-nonstreaming-terminal.test.ts`, `tests/anthropic-error-stop-reason.test.ts`, `tests/command-code-error-finish.test.ts`, `tests/google-buffered-stop-reason.test.ts`, `tests/cursor-eof-terminal.test.ts`, `tests/cursor-interaction-query.test.ts` + FULL suite | + +WP2b and `tests/cursor-interaction-query.test.ts` are BOTH in PR3 (audit `r6` +finding 3 resolved this way rather than by moving WP2b down). `r4` F4's rule was +right — a change and its contract test belong in the same layer — and the honest +placement is PR3, where WP2b lands chronologically. PR1 stays correct without it: +PR1 makes a truncated turn reportable, PR3 makes it report tokens. + +`tests/cursor-eof-terminal.test.ts` appears in both PR1 and PR3 because WP2b adds +cases to it. Each layer runs the file as it stands at that layer. + +### Run them AT the layer tips, not at the stack tip (audit `r12`) + +The table above says WHAT each layer runs; without this it never said WHERE. Running +PR1's tests at `VERIFIED_TIP` proves nothing about PR1, because that tree already +contains PR2's and PR3's code — a PR1 test could pass only because of something a +reviewer of PR1 will never see. + +The layer branches do not exist until `030` step 2, and their head variables are not +assigned until `030` step 4 (audit `r14`: an earlier draft of this section used +`$PR1_HEAD` before that assignment). So this half of WP3 runs AFTER `030` step 4: + +1. `020` first half: the gate loop above at `VERIFIED_TIP` — the stack-tip evidence + PR3 cites. +2. `030` steps 0-4: bind to `VERIFIED_TIP`, cut `cursor-call-wire` and + `cursor-call-cancel`, prove the partition, and assign `PR1_HEAD`/`PR2_HEAD`. +3. `020` this half: one worktree per layer, pinned to that layer's head. The test + file list is spelled out per layer rather than left as a placeholder — a literal + `` is a zsh parse error, not an instruction: + + PR1_TESTS="tests/cursor-eof-terminal.test.ts tests/cursor-hardening.test.ts tests/cursor-tool-result-image.test.ts tests/cursor-request-builder.test.ts" + PR2_TESTS="tests/cursor-cancel-provenance.test.ts tests/cursor-hardening.test.ts" + + run_layer() { + local SHA="$1" TESTS="$2" WT="/tmp/ocx-L-${1:0:9}" + ssh lidge "cd ~/Developer/opencodex && git fetch origin && git worktree add $WT $SHA" + ssh lidge "cd $WT && test \"\$(git rev-parse HEAD)\" = \"$SHA\" && bun install --frozen-lockfile" + ssh lidge "cd $WT && bun x tsc --noEmit" + ssh lidge "cd $WT && bun test $TESTS" + } + + run_layer "$PR1_HEAD" "$PR1_TESTS" + run_layer "$PR2_HEAD" "$PR2_TESTS" + + `PR3_HEAD` equals `VERIFIED_TIP` and step 1 already covered it — do not re-run. +4. `030` step 6: push the branches and open the PRs, each citing ITS OWN run. + +Every layer's evidence therefore names a SHA equal to that PR's head, which is the +same SHA `040` asserts with `gh pr view --json headRefOid` before merging. Remove the +worktrees when the phase closes. diff --git a/devlog/_plan/260818_cursor_call_integration/030_phase3.md b/devlog/_plan/260818_cursor_call_integration/030_phase3.md new file mode 100644 index 0000000000..6a8e5d26a8 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/030_phase3.md @@ -0,0 +1,194 @@ +# 030 — WP4: the stacked pull requests against dev + +> **EXECUTION AUTHORITY: `cursor-call-integration.zsh cut | record_prs`.** +> The commands below are the reasoning, not the runbook. Seven audit rounds proved a +> markdown file cannot enforce that a variable is bound before it is read (`019`), so +> the script owns what runs and this doc owns why. If they disagree the script is +> right and this doc is stale — fix the doc. + +Five versions. `r1` F4 killed a fabricated split; `r3` F3 found phase boundaries; +`r4` F2 killed the commit-range version for "ownership impurity"; `r5` killed the +ownership version's procedure; `r6` killed the forward-construction version on +ancestry, overbroad pathspecs, and WP2b landing in the wrong layer. + +Four consecutive failures on the same question is the signal to re-examine the +question, not to patch the fifth answer (LOOP-REPAIR-01). + +## The mistake was mine, and it was a category error + +I was requiring the stack layers to be **subsystem-pure** — each layer touching only +its own files. That came from `r4` F2's observation that the top commit range also +edits PR1-owned files, which I read as a defect in the split. + +It is not a defect. **A stacked PR does not promise subsystem purity. It promises +reviewable increments in dependency order.** Every mechanism I then invented to +achieve purity — cherry-pick, `rebase -i` split, forward tree copy — broke a +different git invariant, because purity requires moving content between commits and +the history is already final. + +The natural stack is the rebased history itself, cut at existing commits. + +## The stack + +After the WP2 rebase, `VERIFIED_BASE..cursor-call` is one linear history. Cut it: + +| PR | Head branch | Base | Range | Content | +|----|-------------|------|-------|---------| +| 1 | `cursor-call-wire` | `VERIFIED_BASE` | `VERIFIED_BASE..PR1_TIP` | Decode research docs + Cursor wire hardening: the EOF resolution (`emittedTerminal`), tool-result image encoder, and their tests | +| 2 | `cursor-call-cancel` | `cursor-call-wire` | `PR1_TIP..PR2_TIP` | Unexpected server-side CANCEL provenance (reads PR1's `emittedTerminal`) | +| 3 | `cursor-call` | `cursor-call-cancel` | `PR2_TIP..cursor-call` | Bridge/adapter terminal semantics, WP2b, the integration unit, and the late honesty corrections to PR1's files | + +`PR1_TIP` = the rebased commit whose subject is +`docs(devlog): record what shipped for 010 and 020, and why 030 did not`. +`PR2_TIP` = the rebased `docs(devlog): record what shipped for 040`. +Both subjects are unique in the range — verified in `r5`: +`git log --format='%s' VERIFIED_BASE..cursor-call | sort | uniq -d` returns nothing. + +Every property the previous four versions fought for is now free: + +- **Ancestry** is automatic — all three branches are commits on one linear history, + so `cursor-call-wire` is an ancestor of `cursor-call-cancel` is an ancestor of + `cursor-call`. This was `r6`'s finding 1. +- **Union = the branch** is automatic — the three ranges partition the history + exactly. This was `r4` F2, `r5` F2, and `r6` F4. +- **No commit ever moves**, so nothing can be dropped or duplicated. + +## What PR3 legitimately contains, stated up front + +PR3's range includes three kinds of change a reviewer should expect: + +1. Bridge/adapter terminal work — its main subject. +2. **WP2b** (the EOF truncation error's partial usage). It edits + `protobuf-events.ts`, which PR1's EOF resolution selects, so a reader might expect + it in PR1. It is in PR3 because that is where it lands chronologically, and PR1 is + not *wrong* without it — PR1 makes truncation reportable, PR3 makes it report + tokens. That is a normal stacked increment. `tests/cursor-interaction-query.test.ts` + (WP2b's contract test) is therefore also PR3's, which resolves `r4` F4 the other + way: both move together. +3. **Late corrections to PR1-owned files** — `2ea12062d`'s comment fixes in + `request-builder.ts` and two cursor tests, and `be1b881ec`'s two decode docs. These + are the honesty corrections from audits `r1`/`r2`. Say so in PR3's body rather + than letting a reviewer wonder why a bridge PR touches a cursor comment. + +## Procedure + +Run after the WP2 rebase and WP2b are on `cursor-call`. Nothing here rewrites +anything. + +0. **Bind to the verified tree (audit `r10`).** `cursor-call` is mutable and `020` + verified one specific SHA: + + test "$(git rev-parse cursor-call)" = "$VERIFIED_TIP" + + If it fails, the branch moved after verification and the gates no longer describe + what is about to be reviewed. Re-run `020` rather than cutting branches from an + unverified tree. + +1. Find the boundaries in the REBASED history (the rebase preserves order, and the + original SHAs no longer exist): + + git log --format='%h %s' "$VERIFIED_BASE"..cursor-call + + Read `PR1_TIP` and `PR2_TIP` off that list by subject, then confirm each: + + git show --stat "$PR1_TIP" # must be the 010/020 shipped-record doc commit + git show --stat "$PR2_TIP" # must be the 040 shipped-record doc commit + + Bind them to variables rather than reading them by eye, so step 5's assertions + have something to compare against: + + PR1_TIP=$(git log --format='%H %s' "$VERIFIED_BASE"..cursor-call | grep -F 'record what shipped for 010 and 020' | cut -d' ' -f1) + PR2_TIP=$(git log --format='%H %s' "$VERIFIED_BASE"..cursor-call | grep -F 'record what shipped for 040' | cut -d' ' -f1) + test -n "$PR1_TIP" && test -n "$PR2_TIP" + +2. Create the branches at those commits, consuming the captured variables (audit + `r14`: the angle-bracket form is not shell syntax and fails `zsh -n`): + + git branch cursor-call-wire "$PR1_TIP" + git branch cursor-call-cancel "$PR2_TIP" + +3. Prove the stack mechanically — all three ancestry assertions plus the count + identity must pass: + + git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call-wire # exit 0 + git merge-base --is-ancestor cursor-call-wire cursor-call-cancel # exit 0 + git merge-base --is-ancestor cursor-call-cancel cursor-call # exit 0 + git rev-list --count "$VERIFIED_BASE"..cursor-call-wire + git rev-list --count cursor-call-wire..cursor-call-cancel + git rev-list --count cursor-call-cancel..cursor-call + # the counts must sum to: + git rev-list --count "$VERIFIED_BASE"..cursor-call + + The FIRST assertion is not redundant (audit `r8`): `rev-list --count A..B` counts + commits reachable from B and not A even when A is not an ancestor of B, so the + three counts can sum correctly while the bottom of the stack does not actually sit + on the verified base. Demonstrated: against `dev` at `1645bb924`, + `git merge-base --is-ancestor 1645bb924 dfb6fb884` exits 1 while the counts still + add up. Only the ancestry chain `VERIFIED_BASE → wire → cancel → tip`, together + with the counts, establishes the partition. + +4. **Record each PR's expected head SHA as real variables.** `040` asserts these + immediately before merging. Written as executable assignments, not a legend — a + probe of the earlier prose form exited 127 under zsh because `NAME = value` runs + `NAME` as a command (audit `r13`): + + PR1_HEAD=$(git rev-parse cursor-call-wire) + PR2_HEAD=$(git rev-parse cursor-call-cancel) + PR3_HEAD=$(git rev-parse cursor-call) + + Then assert they are the SHAs step 1 identified and step 0 pinned, which is what + binds the branch tips to the verified tree (the ancestry and count checks above + prove topology, not identity): + + test "$PR1_HEAD" = "$PR1_TIP" + test "$PR2_HEAD" = "$PR2_TIP" + test "$PR3_HEAD" = "$VERIFIED_TIP" + +5. **Run each layer's gates before opening its PR (audit `r12`).** The layer branches + only exist from step 2 onward, which is why `020`'s per-layer section runs HERE + rather than earlier: one lidge worktree pinned to each layer head, per the + procedure in `020`. A PR body must cite a run at ITS OWN head — the stack-tip run + belongs to PR3 alone, because PR1's tests passing at the stack tip prove nothing + about a tree that excludes PR2 and PR3. + +6. Push the two new branches and open the PRs bottom-up, each citing its own run. + +## Policy constraints (`AGENTS.md`) + +- `dev` is the only integration target. Never `main`. +- Stacked children targeting an OPEN parent's head branch are intentional; + `enforce-target` skips the wrong-base gate for them (`AGENTS.md:218-225`). + Retarget each child to `dev` after its parent lands. +- `.github/PULL_REQUEST_TEMPLATE.md` requires **Summary**, **Verification**, + **Checklist**; `enforce-target` rejects thin descriptions. +- Each layer carries its OWN verification evidence (`AGENTS.md:178-180`), per `020`. + +## Description content + +- **Summary** — the defect and the wire behavior before/after for that layer. Three + mandatory honest notes: (a) in PR1, that dev independently fixed the clean-EOF + defect and our surviving contribution is `emittedTerminal` plus one guard; + (b) wherever tool-result images appear, that the ENCODER supports them and nothing + reaches Cursor today because all Cursor models are in `noVisionModels`; (c) in PR3, + that its range also carries WP2b and the late corrections to PR1-owned files, and + why. +- **Verification** — that layer's own commands, output, and SHA. +- **Checklist** — three boxes, honestly, `docs-site/` determination made here. +- No `Closes #`. + +## Verification (C) + +``` +gh pr list --state open --json number,baseRefName,headRefName,title +gh pr view --json body +``` + +PR1 base `dev`; PR2 base `cursor-call-wire`; PR3 base `cursor-call-cancel`; step 3's +ancestry chain and count identity recorded; all three template sections non-thin in +each. + +## Fallback + +If step 3 fails — which would mean the rebase did not preserve order as expected — +open ONE PR from `cursor-call` to `dev` and say why in the body. Do not invent a +sixth splitting scheme. diff --git a/devlog/_plan/260818_cursor_call_integration/040_phase4.md b/devlog/_plan/260818_cursor_call_integration/040_phase4.md new file mode 100644 index 0000000000..5c3d542e73 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/040_phase4.md @@ -0,0 +1,160 @@ +# 040 — WP5: merge the stack onto dev + ancestry proof + +> **EXECUTION AUTHORITY: `cursor-call-integration.zsh merge`.** +> The commands below are the reasoning, not the runbook. Seven audit rounds proved a +> markdown file cannot enforce that a variable is bound before it is read (`019`), so +> the script owns what runs and this doc owns why. If they disagree the script is +> right and this doc is stale — fix the doc. + +Revised by `r1` F2 (governance honesty), `r3` F1 (base pinning), and `r4` F1 (the +pin has to EVOLVE through the stack). + +## Authority, stated precisely + +The user granted admin merge authority for this branch ("admin 권한으로") and waived +CI checking. That is the repository owner exercising owner authority. + +What it is NOT: compliance with `MAINTAINERS.md:48-49`, which requires maintainer +approval **and** successful required CI checks before merge. `AGENTS.md:251-253` +makes `MAINTAINERS.md` authoritative. + +So each merge is an **owner-authorized exception**: + +- lidge is Linux; CI covers Linux + Windows + macOS. +- This diff touches no Windows-sensitive surface — no shims, installer, PowerShell, + platform dispatch, or Windows path handling (verified in `r3` and `r4`). +- `050`'s note may say "gates green on Linux; CI waived by the owner". It may **not** + say "policy-compliant" or "all required checks passed". + +If the user wants full compliance instead, let required CI run on each PR head +before merging. One-line change to this plan. + +## `EXPECTED_DEV` evolves — it is not one frozen SHA (r4 F1) + +`VERIFIED_BASE` (the SHA WP3 verified against) is correct as the value to check +before PR1. After PR1 merges, `dev` legitimately moves to PR1's merge result, so +comparing PR2 against the original value would fail by construction. + +The invariant is: **before merging layer N, the live `dev` head must equal the SHA +that layer N's base was verified against.** Maintain one variable: + + EXPECTED_DEV="$VERIFIED_BASE" # from 020, the rebase target + +`EXPECTED_DEV` advances to the MERGE COMMIT of the layer just landed, not to a fresh +read of `dev` — same reason as `MERGED_DEV` below (audit `r13`). The single +executable merge ladder is in **Procedure** below; this section only states the +invariant (audit `r14`: three scattered half-procedures disagreed with each other). + +Read the live head with `git ls-remote origin refs/heads/dev` every time, never +`origin/dev` — the tracking ref goes stale within minutes +(`scripts/release.ts:327-335` uses `ls-remote` for exactly this reason). Observed +drift during planning alone: `87f7f970b` → `e1bdbc1e5` → `1645bb924`. + +**If a check fails**, someone else pushed to `dev`. Stop: rebase the remaining +layers onto the new head, re-run the affected gates from `020`, and update +`EXPECTED_DEV`. Merging a stale base lets GitHub construct a merge result nobody +tested and put it on `dev` — and the ancestry check below runs afterwards, too late +to prevent it. + +## Procedure + +Merge in dependency order. The full ladder is at the end of this section; the two +subsections below explain why each of its three assertions exists. + +### Also assert the PR HEAD, not just the base (audit `r10`) + +The base check proves `dev` has not moved. It says nothing about what the PR itself +now points at. A force-push to a PR head — by anyone, including a well-meaning +rebase — would merge commits that never went through `020`'s gates, and the +post-merge ancestry check below would still pass, because the verified tip remains an +ancestor of a superset. + +So before EACH merge, compare the PR's live head against the SHA `030` step 5 +recorded: + + test "$(gh pr view --json headRefOid --jq .headRefOid)" = "$PR1_HEAD" + gh pr merge --merge --admin + +`test`, not a printed value: a comparison the operator has to eyeball is not a gate +(audit `r13`). Repeat with `$PR2_HEAD` and `$PR3_HEAD` for the other two layers. + +### And assert the PR's live BASE (audit `r14`) + +The two checks above cover "has `dev` moved" and "has the PR head moved". Neither +covers "is this PR still pointing at `dev`". A retarget — to `main`, or to a parent +branch that has since merged — passes both, and `gh pr merge` would then merge into +whatever base the PR now names. `030` prints the bases for inspection, which is not a +gate. + +So the pre-merge check for EVERY layer is all three at once. This is the ONE merge +ladder for the whole phase — `PR1`/`PR2`/`PR3` are the numbers `030` step 4 returns +when the PRs are opened, and `PR1_HEAD`/`PR2_HEAD`/`PR3_HEAD` are the SHAs `030` +step 5 captured: + + merge_layer () { # $1 = PR number, $2 = its expected head SHA + local pr="$1" expected_head="$2" + test "$(gh pr view "$pr" --json baseRefName --jq .baseRefName)" = "dev" + test "$(gh pr view "$pr" --json headRefOid --jq .headRefOid)" = "$expected_head" + test "$(git ls-remote origin refs/heads/dev | cut -f1)" = "$EXPECTED_DEV" + gh pr merge "$pr" --merge --admin + EXPECTED_DEV=$(gh pr view "$pr" --json mergeCommit --jq .mergeCommit.oid) + test -n "$EXPECTED_DEV" + } + + merge_layer "$PR1" "$PR1_HEAD" + gh pr edit "$PR2" --base dev # parent landed; retarget the child + merge_layer "$PR2" "$PR2_HEAD" + gh pr edit "$PR3" --base dev + merge_layer "$PR3" "$PR3_HEAD" + +`EXPECTED_DEV` advances inside the function, from the merge commit of the layer just +landed — so the next layer's base assertion compares against what THIS campaign +produced, not against a fresh read that would absorb someone else's push. + +`dev` is the only acceptable base for all three layers at merge time: PR1 targets it +from the start, and PR2/PR3 are retargeted to it as their parents land +(`AGENTS.md:218-225`). A base of `main` would be a policy violation, and a base still +naming a merged parent branch would produce an empty or wrong diff. + +PR3's expected head is `VERIFIED_TIP` — the exact SHA `020` ran the full suite +against. If any head differs, stop: either re-verify that tree through `020` or +reset the branch to the recorded SHA. Never merge a head no gate has seen. + +Do NOT squash. The commit-by-commit history is the audit trail for five campaign +phases plus four integration audit rounds, and the devlog references specific SHAs. + +## Ancestry proof (the actual criterion) + +A merge API response is not proof: + +``` +git fetch origin dev +git merge-base --is-ancestor origin/dev # exit 0 +git log --oneline -10 origin/dev +``` + +## Verification (C) + +For each layer: the pre-merge `ls-remote` SHA equal to the then-current +`EXPECTED_DEV`, recorded. Then exit 0 from `--is-ancestor` for the final tip, plus +the `origin/dev` log showing all three merges. + +## Hand `MERGED_DEV` to WP6 + +After PR3 merges, take the result from the MERGE ITSELF rather than re-reading a +mutable ref — a fresh `ls-remote` would silently pick up a concurrent push and +attribute someone else's commit to this campaign (audit `r13`): + + MERGED_DEV=$(gh pr view --json mergeCommit --jq .mergeCommit.oid) + test -n "$MERGED_DEV" + git fetch origin dev + test "$(git ls-remote origin refs/heads/dev | cut -f1)" = "$MERGED_DEV" # nobody pushed after us + git merge-base --is-ancestor "$VERIFIED_TIP" "$MERGED_DEV" # exit 0 + +If the third assertion fails, someone pushed after PR3 landed. That is not +necessarily wrong, but `050` must then gate `MERGED_DEV` explicitly and say in the +readiness note that `dev` has moved past it. + +`050` gates exactly that SHA. Same reason as every other named artifact here: a +phase that re-reads a mutable ref is not verifying what the previous phase produced +(audits `r7`, `r8`, `r10`). diff --git a/devlog/_plan/260818_cursor_call_integration/050_phase5.md b/devlog/_plan/260818_cursor_call_integration/050_phase5.md new file mode 100644 index 0000000000..6cb8ee20d8 --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/050_phase5.md @@ -0,0 +1,101 @@ +# 050 — WP6: release gates on dev + go/no-go note + +> **EXECUTION AUTHORITY: `cursor-call-integration.zsh release_gates`.** +> The commands below are the reasoning, not the runbook. Seven audit rounds proved a +> markdown file cannot enforce that a variable is bound before it is read (`019`), so +> the script owns what runs and this doc owns why. If they disagree the script is +> right and this doc is stale — fix the doc. + +Revised by audit `r1` F5 (these are a RE-RUN, not first contact — first contact is +`020`, before the PRs) and audit `r3` F2/F4/F5. + +## Scope boundary (explicit) + +IN: re-running the gates on merged `dev` and writing an evidence-backed readiness +note. + +OUT unless the user says otherwise: `npm publish`, any version bump, `main` +promotion, tag creation. `scripts/release.ts` is the release authority and the +repository's OIDC workflow is the only publish mechanism — never a direct +`npm publish`. + +## Gates (dedicated worktree, r3 F2) + +`MERGED_DEV` is inherited from `040` — the OID of PR3's merge commit, not a fresh +read. Build the worktree at it and assert what landed there: + + DEVDIR=/tmp/ocx-dev-${MERGED_DEV:0:9} + ssh lidge "cd ~/Developer/opencodex && git fetch origin dev && git worktree add $DEVDIR $MERGED_DEV" + ssh lidge "cd $DEVDIR && test \"\$(git rev-parse HEAD)\" = \"$MERGED_DEV\" && bun install --frozen-lockfile" + ssh lidge "cd $DEVDIR && bun x tsc --noEmit" + ssh lidge "cd $DEVDIR && bun run privacy:scan" + ssh lidge "cd $DEVDIR && bun run audit:high" + ssh lidge "cd $DEVDIR && bun run build:gui" + ssh lidge "cd $DEVDIR && bun test --isolate tests" + +`MERGED_DEV` is the SHA `dev` carried when PR3 landed, taken from the merge commit +itself in `040` and already proven to descend from `VERIFIED_TIP`. Do not +substitute a fresh read of `origin/dev`: if someone else pushed in between, these +gates would describe a tree this campaign never produced, and a green result would be +attributed to work that is not ours (audit `r13` sweep). + +Never `checkout -f` the shared `~/Developer/opencodex`. Remove the worktree when +done. + +`build:gui` is NOT optional for a readiness claim even though no `gui/` path +changed: `prepublishOnly` (`package.json:49`) runs `audit:high`, `typecheck`, and +`build:gui` on every publish, and `build:gui` also runs `prepare:package` +(`package.json:46-47`). `lint:gui` stays N/A with its evidence +(`git diff --name-only` showing no `gui/` paths). + +## Docs-site determination (must already be made at `030`) + +- Cursor tool-result images: the encoder supports them, production strips them + upstream (`005` F1). **Do not document a capability that does not reach the + provider.** If `docs-site/` says the Cursor adapter cannot send images, that text + is still accurate end-to-end and stays. +- Truncated-turn reporting (`failed` instead of `completed`) is a correctness fix in + a failure path, not a documented feature. No docs change. + +Record the determination and its reasoning; a bare "no docs needed" is not evidence. + +## Live refs, read at write time (r3 F5) + +Do not copy a ref from this plan into the note. Re-read them, using the live-remote +discipline of `scripts/release.ts:327-335`: + +``` +git ls-remote origin refs/heads/main refs/heads/dev +git ls-remote --tags origin | tail -5 +npm view @bitkyc08/opencodex dist-tags +gh release list --limit 3 +``` + +Known drift already observed: `main` was `474584bcd` when the campaign started, +then `0013b2347`, and the plan's own draft was stale within the hour. `v2.24.2` the +TAG still points at `474584bcd`, which is a different thing from the `main` tip — +state both, do not conflate them. + +## Go/no-go note + +Write `060_release_readiness.md` with: + +- every gate, its command, its output, and the SHA it ran against; +- the governance position from `040` verbatim: gates green on Linux, CI waived by + the owner, each merge an owner-authorized exception; +- **what publication would still require even after a go decision**: the release + authority waits for a successful Cross-platform CI run AND a successful Service + lifecycle run at the exact release SHA (`scripts/release.ts:393-401`). A readiness + note that omits this implies publishing is one command away when it is not; +- the open follow-ups from `000` a reader would otherwise assume were fixed — + especially F1, since the campaign's own docs previously overstated it; +- whether `dev` is releasable as-is; +- an explicit recommendation on cutting a version, with the reason, against the + freshly-read version state. A provider-correctness batch of this size is a + minor-bump candidate, but the decision is the maintainer's — state the + recommendation, do not act on it. + +## Verification (C) + +All gate commands exit 0 at a named `dev` SHA, the live refs in the note match a +`git ls-remote` run recorded alongside them, and the note is committed. diff --git a/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh new file mode 100755 index 0000000000..cc7db1499c --- /dev/null +++ b/devlog/_plan/260818_cursor_call_integration/cursor-call-integration.zsh @@ -0,0 +1,434 @@ +#!/usr/bin/env zsh +# cursor-call integration driver — the executable form of +# devlog/_plan/260818_cursor_call_integration/{010,020,030,040,050}. +# +# Fifteen audit rounds found the same defect class over and over: a binding written +# as prose that no shell would enforce. Six rounds of fixing the prose kept producing +# new unbound variables, because a document is not a program. This file is the program. +# The decade docs explain WHY each assertion exists; this decides WHAT runs. +# +# Every step is idempotent to re-run and refuses to continue on a failed assertion. +# Nothing here merges or pushes without the operator invoking that step by name. + +set -euo pipefail +# `##` in a pattern needs EXTENDED_GLOB. Without it the validator below rejected +# every value, including SHAs — the script could not save anything at all (audit r16). +setopt EXTENDED_GLOB + +# Ask git for the root rather than counting `..` — the script lives three levels down +# (devlog/_plan//), and an off-by-one put the state file in devlog/.tmp/, which +# is a different directory that happens to also be gitignored. A wrong-but-hidden +# path is exactly the kind of thing this script exists to stop trusting. +ROOT="$(cd "${0:A:h}" && git rev-parse --show-toplevel)" || { print -r -- "[cc] FATAL: not in a git repo" >&2; exit 1 } +cd "$ROOT" +STATE="$ROOT/.tmp/cursor-call-integration.env" +mkdir -p "${STATE:h}" + +log () { print -r -- "[cc] $*" >&2 } +die () { print -r -- "[cc] FATAL: $*" >&2; exit 1 } +# Rewrite the key rather than appending: a re-run must not leave two rows for one +# artifact, because `source` would take the last and a reader would see the first. +save () { + local key="$1" value="$2" tmp="$STATE.tmp" + # The state file is `source`d, so an unvalidated value is code. Only SHAs, PR + # numbers and plain identifiers ever go in here (audit r15). + [[ "$value" == [A-Za-z0-9._/+-]## ]] \ + || die "refusing to save $key: value is not a plain token" + : >| "$tmp" + [[ -f "$STATE" ]] && grep -v "^${key}=" "$STATE" >> "$tmp" || true + print -r -- "${key}=${value}" >> "$tmp" + mv "$tmp" "$STATE" + typeset -g "$key"="$value" + log "recorded $key=$value" +} + +# Re-reading state is what makes each step independently re-runnable after a +# compaction, a disconnect, or a day off. +load_state () { [[ -f "$STATE" ]] && source "$STATE" || true } + +# 020 and 050 require the command, its output and the SHA it ran against as evidence. +# A marker in the state file is not that, so every gate also appends a receipt here +# (audit r16). The readiness note quotes from it. +RECEIPTS="$ROOT/.tmp/cursor-call-receipts.log" +receipt () { + local sha="$1" cmd="$2"; shift 2 + { + print -r -- "=== $(date -u +%Y-%m-%dT%H:%M:%SZ) sha=$sha" + print -r -- "$ cd && $cmd" + print -r -- "$@" + } >> "$RECEIPTS" +} + +need () { + local name="$1" + [[ -n "${(P)name:-}" ]] || die "$name is not set — run the earlier step first (state: $STATE)" +} + +live_dev () { git ls-remote origin refs/heads/dev | cut -f1 } +live_branch () { git ls-remote origin "refs/heads/$1" | cut -f1 } + +# Create the worktree only if it is missing, then prove it is at the expected SHA AND +# clean. `|| true` on the add would swallow a real failure, and a pre-existing dirty +# worktree can sit at the right HEAD while its tree says something else (audit r15). +remote_worktree () { + local wt="$1" sha="$2" + ssh lidge "cd $LIDGE_HOME && { git worktree list --porcelain | grep -qx 'worktree $wt' || git worktree add $wt $sha; }" \ + || die "could not create $wt at $sha on lidge" + ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$sha\" && test -z \"\$(git status --porcelain)\"" \ + || die "$wt is not a clean checkout of $sha" +} + +remote_worktree_remove () { + ssh lidge "cd $LIDGE_HOME && git worktree remove --force $1" \ + || log "NOTE: could not remove $1 — remove it by hand" +} + +# ---------------------------------------------------------------- 010: rebase + +step_pin () { + load_state + # Re-pinning after downstream artifacts exist silently invalidates them: every + # later assertion would compare against a base the branch was never rebased onto + # (audit r15). Force the operator to be explicit. + if [[ -n "${VERIFIED_TIP:-}" && "${1:-}" != "--repin" ]]; then + die "VERIFIED_TIP already exists; re-pinning invalidates it. Re-run from scratch, or pass --repin and then re-run rebase/push/verify/cut." + fi + # --repin means every downstream artifact describes a base that no longer applies. + # Drop them rather than leaving stale values that still look valid (audit r16). + if [[ "${1:-}" == "--repin" && -f "$STATE" ]]; then + # Remove the remote worktrees BEFORE forgetting their paths, or --repin orphans + # them on lidge with no handle left to clean them up (audit r16). + [[ -n "${CC_WORKTREE:-}" ]] && remote_worktree_remove "$CC_WORKTREE" + [[ -n "${DEV_WORKTREE:-}" ]] && remote_worktree_remove "$DEV_WORKTREE" + local keep; keep="$STATE.keep" + grep -vE '^(VERIFIED_BASE|VERIFIED_TIP|GATES_GREEN_AT|LAYERS_GREEN_AT|PR[123]_HEAD|PR[123]|EXPECTED_DEV|MERGED_DEV|RELEASE_GATES_GREEN_AT|CC_WORKTREE|DEV_WORKTREE)=' "$STATE" > "$keep" || : >| "$keep" + mv "$keep" "$STATE" + log "--repin: cleared every downstream artifact; re-run rebase, push, verify, cut, verify_layers" + fi + git fetch origin dev + local base; base="$(live_dev)" + [[ -n "$base" ]] || die "could not read live dev" + save VERIFIED_BASE "$base" +} + +step_rebase () { + load_state; need VERIFIED_BASE + git rev-parse --verify cursor-call-prerebase-260818 >/dev/null \ + || die "snapshot branch missing — it is the only recovery path" + # 010 EXPECTS two conflicts. Re-running this step mid-rebase must continue, never + # restart — `git rebase ` on a conflicted tree aborts with its own error and + # would lose the resolution (audit r16). + if [[ -d "$(git rev-parse --git-path rebase-merge)" || -d "$(git rev-parse --git-path rebase-apply)" ]]; then + log "a rebase is in progress — continuing it" + git rebase --continue + else + git rebase "$VERIFIED_BASE" + fi + git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call \ + || die "rebase did not land on VERIFIED_BASE" + ! grep -rEn "^(<<<<<<<|>>>>>>>|=======$)" src tests >/dev/null 2>&1 \ + || die "conflict markers survived the rebase" +} + +step_push () { + load_state + need VERIFIED_BASE + git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call \ + || die "cursor-call is not on VERIFIED_BASE — run rebase before pushing" + git push --force-with-lease --no-verify origin cursor-call + [[ "$(live_branch cursor-call)" == "$(git rev-parse cursor-call)" ]] \ + || die "remote cursor-call does not match local after push" +} + +# ------------------------------------------------------- 020: remote verification + +# Single-quoted and NOT tilde-expanded: zsh would expand ~ to the LOCAL home, and +# /Users/jun/... does not exist on lidge. The remote shell expands this (audit r15). +LIDGE_HOME='$HOME/Developer/opencodex' + +step_verify () { + load_state + need VERIFIED_BASE + git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call \ + || die "cursor-call is not on VERIFIED_BASE — verifying the wrong tree" + local tip; tip="$(live_branch cursor-call)" + [[ "$tip" == "$(git rev-parse cursor-call)" ]] \ + || die "local and remote cursor-call disagree — push first" + save VERIFIED_TIP "$tip" + local wt="/tmp/ocx-cc-${tip:0:9}" + ssh lidge "cd $LIDGE_HOME && git fetch origin cursor-call dev" || die "lidge fetch failed" + remote_worktree "$wt" "$tip" + save CC_WORKTREE "$wt" + ssh lidge "cd $wt && bun install --frozen-lockfile" + local gate + for gate in "bun x tsc --noEmit" "bun run privacy:scan" "bun run audit:high" "bun run build:gui" "bun test --isolate tests"; do + log "gate: $gate" + local out + out="$(ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$tip\" && $gate" 2>&1)" \ + || { receipt "$tip" "$gate" "$out"; die "gate failed at $tip: $gate" } + receipt "$tip" "$gate" "$(print -r -- "$out" | tail -20)" + done + save GATES_GREEN_AT "$tip" +} + +# --------------------------------------------------------------- 030: the stack + +# Exactly one match, or fail. Two commits sharing a subject would otherwise return +# two SHAs and every later assertion would compare against a two-line string (r16). +subject_sha () { + local hits; hits="$(git log --format="%H %s" "$VERIFIED_BASE"..cursor-call | grep -F "$1" || true)" + local n; n="$(print -r -- "$hits" | grep -c . || true)" + [[ "$n" -eq 1 ]] || die "subject '$1' matched $n commits, expected exactly 1" + print -r -- "$hits" | cut -d" " -f1 +} + +step_cut () { + load_state; need VERIFIED_BASE; need VERIFIED_TIP; need GATES_GREEN_AT + [[ "$(git rev-parse cursor-call)" == "$VERIFIED_TIP" ]] \ + || die "cursor-call moved since verification — re-run step_verify" + [[ "$GATES_GREEN_AT" == "$VERIFIED_TIP" ]] \ + || die "the gates were green for a different tree" + local p1 p2 + p1="$(subject_sha "record what shipped for 010 and 020")" + p2="$(subject_sha "record what shipped for 040")" + [[ -n "$p1" && -n "$p2" ]] || die "could not locate both stack boundaries by subject" + [[ "$(print -r -- "$p1" | wc -l)" -eq 0 ]] || true + git branch -f cursor-call-wire "$p1" + git branch -f cursor-call-cancel "$p2" + git merge-base --is-ancestor "$VERIFIED_BASE" cursor-call-wire || die "wire is not on the verified base" + git merge-base --is-ancestor cursor-call-wire cursor-call-cancel || die "cancel is not on wire" + git merge-base --is-ancestor cursor-call-cancel cursor-call || die "tip is not on cancel" + local a b c total + a="$(git rev-list --count "$VERIFIED_BASE"..cursor-call-wire)" + b="$(git rev-list --count cursor-call-wire..cursor-call-cancel)" + c="$(git rev-list --count cursor-call-cancel..cursor-call)" + total="$(git rev-list --count "$VERIFIED_BASE"..cursor-call)" + (( a + b + c == total )) || die "layers $a+$b+$c do not partition $total" + log "partition ok: $a + $b + $c = $total" + save PR1_HEAD "$p1" + save PR2_HEAD "$p2" + save PR3_HEAD "$VERIFIED_TIP" + log "layers cut; run 'verify_layers' before pushing (AGENTS.md:178-180 wants each layer verified at its own SHA)" +} + +# AGENTS.md requires each non-trivial PR to carry its own verification, so a layer is +# gated at ITS head, not at the tip's. Full suite stays on the tip (step_verify); +# here each lower layer gets typecheck plus the tests it owns. +PR1_TESTS="tests/cursor-eof-terminal.test.ts tests/cursor-hardening.test.ts tests/cursor-tool-result-image.test.ts tests/cursor-request-builder.test.ts" +PR2_TESTS="tests/cursor-cancel-provenance.test.ts tests/cursor-hardening.test.ts" + +verify_layer () { + local sha="$1" tests="$2" wt="/tmp/ocx-layer-${1:0:9}" + ssh lidge "cd $LIDGE_HOME && git fetch origin cursor-call-wire cursor-call-cancel" || die "lidge fetch failed" + # Recorded before the gates so a failure leaves a cleanup handle (audit r16). + save LAYER_WORKTREE "$wt" + remote_worktree "$wt" "$sha" + ssh lidge "cd $wt && bun install --frozen-lockfile" + ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$sha\" && bun x tsc --noEmit" \ + || die "typecheck failed at layer $sha" + ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$sha\" && bun test $tests" \ + || die "focused tests failed at layer $sha" + remote_worktree_remove "$wt" + save LAYER_WORKTREE "none" +} + +step_verify_layers () { + load_state; need PR1_HEAD; need PR2_HEAD; need GATES_GREEN_AT + git push --no-verify origin cursor-call-wire cursor-call-cancel + verify_layer "$PR1_HEAD" "$PR1_TESTS" + verify_layer "$PR2_HEAD" "$PR2_TESTS" + # Record WHICH heads were verified, so a later re-cut invalidates the marker + # instead of inheriting it (audit r16). + save LAYERS_GREEN_AT "${PR1_HEAD}+${PR2_HEAD}" + log "layers verified — open the PRs bottom-up, then run: record_prs " +} + +# PR numbers are recorded by the operator right after `gh pr create`, because only +# then do they exist. Every later step asserts them rather than assuming. +step_record_prs () { + load_state + [[ $# -eq 3 ]] || die "usage: step_record_prs " + need PR1_HEAD; need PR2_HEAD; need PR3_HEAD + # 030 requires each PR to point at its layer head and to carry all three template + # sections. Recording three unchecked numbers would let a mislabeled PR through + # (audit r16). The BODY's substance is the agent's to write; its STRUCTURE is + # checkable, so check it. + local i=1 pr head body + for pr in "$1" "$2" "$3"; do + [[ "$pr" == [0-9]## ]] || die "PR $pr is not a number" + case $i in + 1) head="$PR1_HEAD" ;; + 2) head="$PR2_HEAD" ;; + 3) head="$PR3_HEAD" ;; + esac + [[ "$(gh pr view "$pr" --json headRefOid --jq .headRefOid)" == "$head" ]] \ + || die "PR $pr does not point at its layer head $head" + body="$(gh pr view "$pr" --json body --jq .body)" + local section + for section in "## Summary" "## Verification" "## Checklist"; do + print -r -- "$body" | grep -qF "$section" \ + || die "PR $pr is missing the '$section' section the template requires" + done + (( i++ )) + done + save PR1 "$1"; save PR2 "$2"; save PR3 "$3" +} + +# ---------------------------------------------------------------- 040: the merge + +# Retargeting a merged PR is an error; retargeting one already on dev is a no-op. +retarget_to_dev () { + local pr="$1" + local state; state="$(gh pr view "$pr" --json state --jq .state)" + [[ "$state" == "OPEN" ]] || { log "PR $pr is $state — no retarget needed"; return 0 } + [[ "$(gh pr view "$pr" --json baseRefName --jq .baseRefName)" == "dev" ]] \ + && { log "PR $pr already targets dev"; return 0 } + gh pr edit "$pr" --base dev +} + +merge_layer () { + local pr="$1" expected_head="$2" + [[ -n "$pr" && -n "$expected_head" ]] || die "merge_layer needs a PR number and its expected head" + # Resume, not restart. A merged layer is DONE: adopt its merge commit as the + # current EXPECTED_DEV and move on, so a disconnect between `gh pr merge` and + # `save` costs nothing and a re-run is a no-op (audit r15). + local state; state="$(gh pr view "$pr" --json state --jq .state)" + if [[ "$state" == "MERGED" ]]; then + local oid; oid="$(gh pr view "$pr" --json mergeCommit --jq .mergeCommit.oid)" + [[ -n "$oid" ]] || die "PR $pr reports MERGED with no merge commit" + # Adopting a merge unchecked would accept a PR someone merged from a different + # head, or into a different base, as this campaign's output (audit r16). + [[ "$(gh pr view "$pr" --json baseRefName --jq .baseRefName)" == "dev" ]] \ + || die "PR $pr was merged into a base other than dev" + [[ "$(gh pr view "$pr" --json headRefOid --jq .headRefOid)" == "$expected_head" ]] \ + || die "PR $pr was merged from a head we never verified" + git fetch origin dev >/dev/null 2>&1 || true + git merge-base --is-ancestor "$expected_head" "$oid" \ + || die "PR $pr's merge commit does not contain the verified head" + # And it must have been merged ONTO the predecessor this campaign produced. A + # merge made after an unrelated commit landed on dev is someone else's history, + # not the next layer of this stack (audit r16). First parent == base at merge. + local first_parent; first_parent="$(git rev-parse "${oid}^1" 2>/dev/null || true)" + [[ "$first_parent" == "$EXPECTED_DEV" ]] \ + || die "PR $pr was merged onto $first_parent, not the expected $EXPECTED_DEV" + save EXPECTED_DEV "$oid" + log "PR $pr already merged — adopted $oid" + return 0 + fi + [[ "$state" == "OPEN" ]] || die "PR $pr is $state, neither OPEN nor MERGED" + [[ "$(gh pr view "$pr" --json baseRefName --jq .baseRefName)" == "dev" ]] \ + || die "PR $pr does not target dev" + [[ "$(gh pr view "$pr" --json headRefOid --jq .headRefOid)" == "$expected_head" ]] \ + || die "PR $pr head moved off the verified SHA" + [[ "$(live_dev)" == "$EXPECTED_DEV" ]] \ + || die "dev moved since the last layer — rebase and re-verify" + gh pr merge "$pr" --merge --admin + EXPECTED_DEV="$(gh pr view "$pr" --json mergeCommit --jq .mergeCommit.oid)" + [[ -n "$EXPECTED_DEV" ]] || die "could not read the merge commit for PR $pr" + save EXPECTED_DEV "$EXPECTED_DEV" +} + +step_merge () { + load_state + need VERIFIED_BASE; need VERIFIED_TIP + need PR1; need PR2; need PR3 + need PR1_HEAD; need PR2_HEAD; need PR3_HEAD + need LAYERS_GREEN_AT + [[ "$LAYERS_GREEN_AT" == "${PR1_HEAD}+${PR2_HEAD}" ]] \ + || die "the layer gates were green for different heads ($LAYERS_GREEN_AT) — re-run verify_layers" + EXPECTED_DEV="${EXPECTED_DEV:-$VERIFIED_BASE}" + merge_layer "$PR1" "$PR1_HEAD" + retarget_to_dev "$PR2" + merge_layer "$PR2" "$PR2_HEAD" + retarget_to_dev "$PR3" + merge_layer "$PR3" "$PR3_HEAD" + local merged; merged="$(gh pr view "$PR3" --json mergeCommit --jq .mergeCommit.oid)" + [[ -n "$merged" ]] || die "PR3 has no merge commit" + git fetch origin dev + git merge-base --is-ancestor "$VERIFIED_TIP" "$merged" \ + || die "the verified tip is not an ancestor of the merge result" + # dev may legitimately advance past our merge, but the merge must BE on dev — a + # merge commit that never landed there would send the release gates somewhere else. + git merge-base --is-ancestor "$merged" "$(live_dev)" \ + || die "PR3's merge commit is not on dev" + # Saved LAST: a durable MERGED_DEV is a claim that both proofs passed, and + # release_gates trusts it on a later invocation (audit r16). + save MERGED_DEV "$merged" + [[ "$(live_dev)" == "$merged" ]] \ + || log "NOTE: dev has moved past our merge — 050 must say so in the readiness note" +} + +# ------------------------------------------------------- 050: release gates on dev + +step_release_gates () { + load_state; need MERGED_DEV + local wt="/tmp/ocx-dev-${MERGED_DEV:0:9}" + ssh lidge "cd $LIDGE_HOME && git fetch origin dev" || die "lidge fetch failed" + remote_worktree "$wt" "$MERGED_DEV" + save DEV_WORKTREE "$wt" + ssh lidge "cd $wt && bun install --frozen-lockfile" + local gate + for gate in "bun x tsc --noEmit" "bun run privacy:scan" "bun run audit:high" "bun run build:gui" "bun test --isolate tests"; do + log "dev gate: $gate" + local out + out="$(ssh lidge "cd $wt && test \"\$(git rev-parse HEAD)\" = \"$MERGED_DEV\" && $gate" 2>&1)" \ + || { receipt "$MERGED_DEV" "$gate" "$out"; die "release gate failed on dev: $gate" } + receipt "$MERGED_DEV" "$gate" "$(print -r -- "$out" | tail -20)" + done + save RELEASE_GATES_GREEN_AT "$MERGED_DEV" +} + +# 050 requires the readiness note to quote LIVE release state, never a cached ref. +# Printed for the note, not saved: these are facts about a moment, not artifacts the +# later steps assert against. +step_release_state () { + load_state; need MERGED_DEV; need RELEASE_GATES_GREEN_AT + [[ "$RELEASE_GATES_GREEN_AT" == "$MERGED_DEV" ]] \ + || die "the release gates were green for $RELEASE_GATES_GREEN_AT, not $MERGED_DEV" + print -r -- "MERGED_DEV=$MERGED_DEV" + print -r -- "live main=$(git ls-remote origin refs/heads/main | cut -f1)" + print -r -- "live dev=$(live_dev)" + print -r -- "--- latest tags" + git ls-remote --tags origin | tail -5 + print -r -- "--- npm dist-tags" + npm view @bitkyc08/opencodex dist-tags 2>&1 | head -10 + print -r -- "--- releases" + gh release list --limit 3 2>&1 | head -5 + print -r -- "--- write 060_release_readiness.md with the gate output, these refs, the" + print -r -- "--- governance position from 040, the open follow-ups, and a go/no-go." +} + +# 020 and 050 both require the verification worktrees to be removed when their phase +# closes. Kept as its own step so a failed gate leaves the tree available to inspect. +step_cleanup () { + load_state + [[ -n "${CC_WORKTREE:-}" ]] && remote_worktree_remove "$CC_WORKTREE" + [[ -n "${DEV_WORKTREE:-}" ]] && remote_worktree_remove "$DEV_WORKTREE" + [[ -n "${LAYER_WORKTREE:-}" && "$LAYER_WORKTREE" != "none" ]] && remote_worktree_remove "$LAYER_WORKTREE" + log "cleanup done" +} + +# ------------------------------------------------------------------------ driver + +main () { + local step="${1:-}" + [[ -n "$step" ]] || die "usage: cursor-call-integration.zsh [args] — steps: pin rebase push verify cut verify_layers record_prs merge release_gates release_state cleanup state" + shift + case "$step" in + pin) step_pin "$@" ;; + rebase) step_rebase ;; + push) step_push ;; + verify) step_verify ;; + cut) step_cut ;; + verify_layers) step_verify_layers ;; + record_prs) step_record_prs "$@" ;; + merge) step_merge ;; + release_gates) step_release_gates ;; + release_state) step_release_state ;; + cleanup) step_cleanup ;; + state) load_state; [[ -f "$STATE" ]] && cat "$STATE" || log "no state yet" ;; + *) die "unknown step: $step" ;; + esac +} + +main "$@" diff --git a/src/adapters/anthropic.ts b/src/adapters/anthropic.ts index 6eb8085be0..fd141ccfb8 100644 --- a/src/adapters/anthropic.ts +++ b/src/adapters/anthropic.ts @@ -989,6 +989,18 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti const emitDone = function* (): Generator { if (emittedDone) return; emittedDone = true; + // An `error` stop reason is a failed generation, not a stop. Forwarding it as `done` + // lets the turn report success and install replacement history on a compaction turn. + if (pendingStopReason === "error") { + yield { + type: "error", + message: "upstream ended the turn with stop_reason \"error\"", + status: 502, + errorType: "upstream_error", + usage: usageFromAnthropic(pendingUsage), + }; + return; + } yield { type: "done", usage: usageFromAnthropic(pendingUsage), @@ -1143,6 +1155,21 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti if (!emittedDone) { // Fail closed on transport EOF. Compatible providers may omit message_stop after message_delta.stop_reason. if (pendingStopReason !== undefined) { + // Same rule as emitDone: an `error` stop reason is a failed generation, not a stop. + // This branch bypasses emitDone entirely (it exists for providers that close after + // message_delta without message_stop), so the check has to be repeated here or the + // EOF route silently reports success. + if (pendingStopReason === "error") { + emittedDone = true; + yield { + type: "error", + message: "upstream ended the turn with stop_reason \"error\"", + status: 502, + errorType: "upstream_error", + usage: usageFromAnthropic(pendingUsage), + }; + return; + } const stopReason = pendingStopReason === "max_tokens" ? "max_tokens" : pendingStopReason === "refusal" || pendingStopReason === "content_filter" @@ -1210,6 +1237,21 @@ export function createAnthropicAdapter(provider: OcxProviderConfig, cacheRetenti } const usage = json.usage as Record | undefined; const stopReason = typeof json.stop_reason === "string" ? json.stop_reason : undefined; + // An Anthropic-compatible upstream can forward an `error` stop reason verbatim. As a + // `done` it reads as a clean completion, so the turn reports success and — on a compaction + // turn — installs its partial summary as replacement history (#422). Usage is preserved: + // a failed turn still consumed tokens. + if (stopReason === "error") { + events.push({ + type: "error", + message: "upstream ended the turn with stop_reason \"error\"", + status: 502, + errorType: "upstream_error", + usage: usageFromAnthropic(usage), + }); + retainTranslatedEventBatch(events, budget); + return events; + } events.push({ type: "done", usage: usageFromAnthropic(usage), diff --git a/src/adapters/command-code.ts b/src/adapters/command-code.ts index 5299b455c2..156ba5130a 100644 --- a/src/adapters/command-code.ts +++ b/src/adapters/command-code.ts @@ -553,6 +553,23 @@ export function createCommandCodeAdapter(provider: OcxProviderConfig): ProviderA sawFinish = true; const usageValue = event.totalUsage ?? event.usage; const stopReason = typeof event.rawFinishReason === "string" ? event.rawFinishReason : typeof event.finishReason === "string" ? event.finishReason : undefined; + // The AI SDK's `error` finish reason means the generation failed upstream, not that it + // stopped. Reporting it as a `done` left the bridge to infer failure from a stop-reason + // string, which either read as a clean completion or (once classified) mislabelled an + // upstream error as a content filter and rejected it from the replay cache for the + // wrong reason. + if (stopReason === "error") { + // Keep the usage: a failed turn still consumed tokens, and dropping it makes the + // turn look free in accounting and reports zeros to the client. + yield { + type: "error", + message: "Command Code upstream ended the turn with finishReason \"error\"", + status: 502, + errorType: "upstream_error", + usage: usage(usageValue), + }; + break; + } yield { type: "done", usage: usage(usageValue), stopReason }; break; } diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 5344689c99..da003f85a0 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -207,10 +207,12 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri case "image": // User-message images are still flattened here: this path builds the plain-text prompt, and // the schema slot that could carry them (UserMessage.selectedContext.selectedImages) is not - // populated by this adapter. Tool-result images DO reach Cursor as real McpImageContent + // populated by this adapter. The tool-result ENCODER does build real McpImageContent // (see protobuf-request.ts), so the old "unsupported by Cursor adapter" wording is no - // longer true of the adapter as a whole. Kept the same length to avoid shifting any - // byte-budgeted prompt path. + // longer true of the encoder — but note that nothing reaches Cursor today either way: + // every Cursor model is in noVisionModels (providers/registry.ts), so the vision sidecar + // describes or strips images before this adapter runs. Kept the same length to avoid + // shifting any byte-budgeted prompt path. return `[image omitted from this Cursor text prompt: ${part.detail ?? "auto"}]`; case "toolCall": // Cursor does not accept OpenAI Responses assistant tool-call parts as native history here. diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 7f42938c93..ac496d46d6 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -946,9 +946,20 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } const usage = json.usageMetadata as Record | undefined; + // Mirror the streaming path: a buffered turn cut off by the token limit or a content filter + // must carry its stop reason, or the bridge sees a clean `done` and reports the truncated + // turn as completed — and, on a compaction turn, installs the half-written summary as + // replacement history (#422). + const finishReason = candidates?.[0]?.finishReason as string | undefined; + const stopReason = finishReason === "MAX_TOKENS" + ? "max_tokens" + : ["SAFETY", "RECITATION", "BLOCKLIST", "PROHIBITED_CONTENT", "SPII"].includes(finishReason ?? "") + ? "content_filter" + : undefined; events.push({ type: "done", usage: usageFromGemini(usage), + ...(stopReason ? { stopReason } : {}), }); return finish(events); }, diff --git a/src/bridge.ts b/src/bridge.ts index c34e91734a..ebbf2c7cb9 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -9,6 +9,7 @@ import type { import { coerceIntegerToolArguments } from "./lib/tool-argument-integers"; import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode, type OcxErrorPayload } from "./lib/errors"; import { encodeCompactionSummary } from "./responses/compaction"; +import { isTruncatedStopReason, truncationReasonFor } from "./responses/truncated-stop-reason"; import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope"; import { rememberReasoningForCall } from "./responses/reasoning-replay-cache"; import { @@ -1154,7 +1155,11 @@ export function bridgeToResponsesSSE( // After every close above, so the blob lands AFTER the assistant message it belongs // to and the parser's backwards pairing finds it. flushKiroRedactedReasoning(); - if (options?.compaction) { + // Truncated turns must never install replacement history (#422). The buffered path + // has always checked this; streaming emitted the item BEFORE reading stopReason, so + // a max_tokens/content_filter turn shipped a half-written summary and then declared + // itself incomplete — the same hazard, one branch over. + if (options?.compaction && !isTruncatedStopReason(event.stopReason)) { // Exactly one compaction item per turn; codex-rs takes the first and fatals on 0. const item = { type: "compaction", id: `cmp_${uuid()}`, @@ -1164,14 +1169,18 @@ export function bridgeToResponsesSSE( retainFinishedItem(item as OutputItem, compactionTextBytes); outputIndex++; } - if (event.stopReason === "max_tokens" || event.stopReason === "content_filter") { + // Recognize every adapter's truncation vocabulary, not just the canonical pair. + // Suppression and terminal status must agree: withholding the compaction item while + // still reporting success hands codex-rs a completed response with zero compaction + // items, which it treats as fatal. + if (truncationReasonFor(event.stopReason)) { // Upstream stopped before a normal completion. Surface as incomplete so the // client can distinguish a truncated/filtered turn from a finished one. const response = { ...responseSnapshot("incomplete", finishedItems, event.endTurn), usage: responsesUsage(event.usage), incomplete_details: { - reason: event.stopReason === "max_tokens" ? "max_output_tokens" : "content_filter", + reason: truncationReasonFor(event.stopReason) ?? "content_filter", }, }; // Cache max-output partials so previous_response_id replay can continue them; @@ -1467,7 +1476,15 @@ function buildResponseJSONWithBudget( let incompleteEvent: Extract | undefined; let endTurn: boolean | undefined; let stopReason: string | undefined; + // The adapter's stop reason exactly as it arrived. `stopReason` above is deliberately narrowed + // to the two reasons that map onto a Responses `incomplete_details`; the raw value is what the + // truncation guard needs, because adapters disagree on vocabulary (`length`, `refusal`, ...). + let rawStopReason: string | undefined; let cleanDone = false; + // Whether the adapter emitted ANY terminal (done/error/incomplete). Distinct from `cleanDone`, + // which is only true for a `done` without a stop reason. A buffered turn whose adapter simply + // stopped emitting has no terminal at all, and must not be reported as a success. + let sawTerminal = false; let compactionText = ""; let compactionTextBytes = 0; @@ -1782,20 +1799,30 @@ function buildResponseJSONWithBudget( break; case "error": errorEvent = e; + sawTerminal = true; usage = e.usage ?? usage; break; case "incomplete": incompleteEvent = e; + sawTerminal = true; endTurn = e.endTurn; if (e.providerState) options?.onProviderState?.(e.providerState); break; case "done": usage = e.usage; + sawTerminal = true; endTurn = e.endTurn; cleanDone = e.stopReason === undefined; + rawStopReason = e.stopReason; if (e.providerState) options?.onProviderState?.(e.providerState); // Match streaming: max_tokens and content_filter both terminate as incomplete. - if (e.stopReason === "max_tokens" || e.stopReason === "content_filter") stopReason = e.stopReason; + // Normalize every adapter's truncation vocabulary to the canonical pair, so a raw + // `length` or `refusal` reaches the status/incomplete_details logic below instead of + // silently reading as a clean stop. + { + const truncation = truncationReasonFor(e.stopReason); + if (truncation) stopReason = truncation === "max_output_tokens" ? "max_tokens" : "content_filter"; + } break; } if (budget) releaseTranslatedEvent(e, budget); @@ -1803,8 +1830,11 @@ function buildResponseJSONWithBudget( flushText(cleanDone && !errorEvent && !incompleteEvent ? "final_answer" : undefined); flushSummaryReasoning(); flushRawReasoning(); - // Open tool call on a failed/incomplete turn must not land as status:"completed". - if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent ? "incomplete" : "completed"); + // Open tool call on a failed/incomplete turn must not land as status:"completed" — and neither + // must one left open by a stream that stopped without any terminal at all. That case previously + // fell through to "completed", handing back a function_call whose arguments were half-written + // JSON, inside a turn also marked completed. + if (currentToolCallId) flushToolCall(errorEvent || incompleteEvent || !sawTerminal ? "incomplete" : "completed"); if (batchKiroRedacted) { // pushOutput reserves the item itself and releases the retained raw blob it replaces. pushOutput({ @@ -1820,8 +1850,12 @@ function buildResponseJSONWithBudget( options?.compaction && !errorEvent && !incompleteEvent - && stopReason !== "max_tokens" - && stopReason !== "content_filter" + // A stream that stopped without any terminal did not complete either. The original guard + // could only see explicit failure events, so an adapter EOF slipped past it and installed a + // truncated summary as replacement history — the exact #422 hazard, reached by a route that + // did not exist when the guard was written. + && sawTerminal + && !isTruncatedStopReason(rawStopReason) ) { pushOutput({ type: "compaction", id: `cmp_${uuid()}`, encrypted_content: encodeCompactionSummary(compactionText) }, compactionTextBytes); } @@ -1831,7 +1865,13 @@ function buildResponseJSONWithBudget( ? "failed" : incompleteEvent || stopReason === "max_tokens" || stopReason === "content_filter" ? "incomplete" - : "completed"; + : sawTerminal + ? "completed" + // The adapter stopped emitting without any terminal, so the turn was cut short. Streaming + // already reports this as response.incomplete / adapter_eof (see the !terminated branch); + // defaulting the buffered path to "completed" handed callers a truncated turn — including + // one carrying a never-closed tool call with half-written JSON arguments — as a success. + : "incomplete"; options?.onUsage?.(incompleteEvent?.usage ?? usage); return { id: responseId, object: "response", @@ -1851,6 +1891,10 @@ function buildResponseJSONWithBudget( incomplete_details: { reason: "max_output_tokens" }, } : stopReason === "content_filter" ? { incomplete_details: { reason: "content_filter" }, + } : !sawTerminal ? { + // Same reason string the streaming path uses, so a caller sees one signal for one condition + // regardless of which surface it asked for. + incomplete_details: { reason: "adapter_eof" }, } : {}), usage: responsesUsage(incompleteEvent?.usage ?? usage), }; diff --git a/src/responses/truncated-stop-reason.ts b/src/responses/truncated-stop-reason.ts new file mode 100644 index 0000000000..85042a763f --- /dev/null +++ b/src/responses/truncated-stop-reason.ts @@ -0,0 +1,60 @@ +/** + * Whether a `done` event's `stopReason` means the turn was cut short rather than finishing, and + * which Responses `incomplete_details.reason` it maps to. + * + * `stopReason` is an open-ended string and adapters do not agree on a vocabulary: openai-chat and + * google normalize to `max_tokens`/`content_filter`, Command Code forwards the raw provider or AI + * SDK value (`length`, `content-filter`, `error`), and Anthropic forwards `stop_reason` verbatim + * (`refusal`, `model_context_window_exceeded`, ...). A guard that matched only the two canonical + * strings let those turns read as completed — and, on a compaction turn, install a half-written + * summary as replacement history (#422). + * + * Classifying here keeps that decision independent of which adapter produced the event, and keeps + * suppression and terminal status in agreement: a turn whose compaction item is withheld must not + * also report success, or codex-rs receives a completed response with zero compaction items and + * fatals. + * + * Unknown reasons are deliberately NOT truncated. This must never turn a healthy turn into a + * failure, and an unrecognized value is far more likely an ordinary stop. + */ +type TruncationKind = "max_output_tokens" | "content_filter"; + +const TRUNCATED_STOP_REASONS = new Map([ + // canonical (openai-chat, google) + ["max_tokens", "max_output_tokens"], + ["content_filter", "content_filter"], + // raw OpenAI / Command Code (AI SDK) finish reasons + ["length", "max_output_tokens"], + ["content-filter", "content_filter"], + // raw Anthropic stop reasons + ["max_output_tokens", "max_output_tokens"], + ["model_context_window_exceeded", "max_output_tokens"], + ["refusal", "content_filter"], + // Anthropic documents `pause_turn` as a long-running turn that the client is expected to + // CONTINUE. Whatever was produced so far is by definition unfinished, so it must not be + // installed as replacement history. + ["pause_turn", "max_output_tokens"], + // raw Gemini / Vertex finish reasons + ["malformed_function_call", "content_filter"], + ["malformed_response", "content_filter"], + ["unexpected_tool_call", "content_filter"], + ["safety", "content_filter"], + ["recitation", "content_filter"], + ["blocklist", "content_filter"], + ["prohibited_content", "content_filter"], + ["spii", "content_filter"], + ["image_safety", "content_filter"], + ["language", "content_filter"], + // Kiro + ["model_context_window_exceeded_exception", "max_output_tokens"], +]); + +/** The `incomplete_details.reason` a truncated stop maps to, or undefined for a normal stop. */ +export function truncationReasonFor(stopReason: string | undefined): TruncationKind | undefined { + if (stopReason === undefined) return undefined; + return TRUNCATED_STOP_REASONS.get(stopReason.trim().toLowerCase()); +} + +export function isTruncatedStopReason(stopReason: string | undefined): boolean { + return truncationReasonFor(stopReason) !== undefined; +} diff --git a/tests/anthropic-error-stop-reason.test.ts b/tests/anthropic-error-stop-reason.test.ts new file mode 100644 index 0000000000..d4b2a750d3 --- /dev/null +++ b/tests/anthropic-error-stop-reason.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test } from "bun:test"; +import { createAnthropicAdapter as createAnthropicAdapterProduction } from "../src/adapters/anthropic"; +import { buildResponseJSON } from "../src/bridge"; +import type { AdapterEvent, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createAnthropicAdapter = (...args: Parameters) => + withTestTranslatorBudget(createAnthropicAdapterProduction(...args)); + +const provider: OcxProviderConfig = { + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + apiKey: "test-key", +}; + +/** + * These drive the REAL adapter parsers. An earlier version of this suite constructed the + * downstream error event by hand, so it stayed green while the adapter itself still emitted a + * clean `done` — the gap an audit caught. + */ +describe("an upstream error stop_reason is a failure, not a stop", () => { + test("buffered: stop_reason error yields an error event carrying usage", async () => { + const adapter = createAnthropicAdapter(provider); + const body = JSON.stringify({ + content: [{ type: "text", text: "partial" }], + stop_reason: "error", + usage: { input_tokens: 10, output_tokens: 4 }, + }); + const events = await adapter.parseResponse!(new Response(body, { status: 200 })) as AdapterEvent[]; + + const error = events.find(e => e.type === "error") as { usage?: { inputTokens?: number } } | undefined; + expect(error).toBeDefined(); + expect(events.some(e => e.type === "done")).toBe(false); + // A failed turn still consumed tokens; dropping usage makes it look free in accounting. + expect(error?.usage?.inputTokens).toBe(10); + }); + + test("streaming: stop_reason error yields an error event carrying usage", async () => { + const adapter = createAnthropicAdapter(provider); + const frames = [ + 'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":10}}}\n\n', + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n', + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"error"},"usage":{"output_tokens":4}}\n\n', + 'event: message_stop\ndata: {"type":"message_stop"}\n\n', + ].join(""); + const events: AdapterEvent[] = []; + for await (const e of adapter.parseStream(new Response(frames, { + status: 200, headers: { "content-type": "text/event-stream" }, + }))) events.push(e); + + const error = events.find(e => e.type === "error") as { usage?: { inputTokens?: number; outputTokens?: number } } | undefined; + expect(error).toBeDefined(); + expect(events.some(e => e.type === "done")).toBe(false); + // Assert the usage this test is named for: without it the title was a claim the test + // never checked, and removing usage would have kept it green. + expect(error?.usage?.inputTokens).toBe(10); + expect(error?.usage?.outputTokens).toBe(4); + }); + + test("streaming EOF without message_stop also fails, and keeps usage", async () => { + // A compatible provider may close after message_delta without message_stop. That branch + // bypasses emitDone entirely, so it needs its own check — an audit found it still + // reporting success while the two other terminal paths were fixed. + const adapter = createAnthropicAdapter(provider); + const frames = [ + 'event: message_start\ndata: {"type":"message_start","message":{"usage":{"input_tokens":10}}}\n\n', + 'event: content_block_start\ndata: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}\n\n', + 'event: content_block_delta\ndata: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"partial"}}\n\n', + 'event: message_delta\ndata: {"type":"message_delta","delta":{"stop_reason":"error"},"usage":{"output_tokens":4}}\n\n', + // no message_stop: the stream just ends + ].join(''); + const events: AdapterEvent[] = []; + for await (const e of adapter.parseStream(new Response(frames, { + status: 200, headers: { "content-type": "text/event-stream" }, + }))) events.push(e); + + const error = events.find(e => e.type === "error") as { usage?: { inputTokens?: number } } | undefined; + expect(error).toBeDefined(); + expect(events.some(e => e.type === "done")).toBe(false); + expect(error?.usage?.inputTokens).toBe(10); + + const json = buildResponseJSON(events, "anthropic/claude-opus-5", { compaction: true }); + expect(json.status).toBe("failed"); + expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false); + }); + + test("a turn that failed upstream installs no compaction history", async () => { + const adapter = createAnthropicAdapter(provider); + const body = JSON.stringify({ + content: [{ type: "text", text: "half a summary" }], + stop_reason: "error", + usage: { input_tokens: 10, output_tokens: 4 }, + }); + const events = await adapter.parseResponse!(new Response(body, { status: 200 })) as AdapterEvent[]; + const json = buildResponseJSON(events, "anthropic/claude-opus-5", { compaction: true }); + + expect(json.status).toBe("failed"); + expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false); + }); + + test("an ordinary stop_reason still completes normally", async () => { + const adapter = createAnthropicAdapter(provider); + const body = JSON.stringify({ + content: [{ type: "text", text: "a whole answer" }], + stop_reason: "end_turn", + usage: { input_tokens: 10, output_tokens: 4 }, + }); + const events = await adapter.parseResponse!(new Response(body, { status: 200 })) as AdapterEvent[]; + + expect(events.some(e => e.type === "done")).toBe(true); + expect(events.some(e => e.type === "error")).toBe(false); + }); +}); diff --git a/tests/bridge-nonstreaming-terminal.test.ts b/tests/bridge-nonstreaming-terminal.test.ts new file mode 100644 index 0000000000..a137b676a2 --- /dev/null +++ b/tests/bridge-nonstreaming-terminal.test.ts @@ -0,0 +1,312 @@ +import { describe, expect, test } from "bun:test"; +import { bridgeToResponsesSSE, buildResponseJSON } from "../src/bridge"; +import { isTruncatedStopReason, truncationReasonFor } from "../src/responses/truncated-stop-reason"; +import type { AdapterEvent } from "../src/types"; + +async function sseText(events: AdapterEvent[]): Promise { + async function* source(): AsyncGenerator { + for (const e of events) yield e; + } + return await new Response(bridgeToResponsesSSE(source(), "routed/model")).text(); +} + +function terminalEventNames(text: string): string[] { + return text.split("\n\n") + .map(f => f.trim()) + .map(f => f.split("\n").find(l => l.startsWith("event: "))?.slice(7) ?? "") + .filter(n => n === "response.completed" || n === "response.incomplete" || n === "response.failed"); +} + +describe("buffered turns without an adapter terminal", () => { + test("text with no done/error is not reported as completed", () => { + const json = buildResponseJSON([{ type: "text", text: "partial answer" }], "routed/model"); + + // The adapter stopped emitting mid-turn. Calling that a success is the shape that let a + // truncated Cursor turn look finished. + expect(json.status).toBe("incomplete"); + expect((json as { incomplete_details?: { reason?: string } }).incomplete_details?.reason).toBe("adapter_eof"); + }); + + test("a tool call left open is never returned as a completed function call", () => { + const json = buildResponseJSON([ + { type: "tool_call_start", id: "call_1", name: "js" }, + { type: "tool_call_delta", arguments: '{"code":"tru' }, + ], "routed/model"); + + // The worst shape: a caller trusting `status` would try to execute half-written JSON. + expect(json.status).toBe("incomplete"); + const call = json.output.find(o => (o as { type: string }).type === "function_call") as + { status?: string; arguments?: string } | undefined; + expect(call).toBeDefined(); + expect(call?.status).toBe("incomplete"); + expect(call?.arguments).toBe('{"code":"tru'); + }); + + test("streaming and buffered agree on the terminal for the same events", async () => { + const events: AdapterEvent[] = [ + { type: "tool_call_start", id: "call_1", name: "js" }, + { type: "tool_call_delta", arguments: '{"code":"tru' }, + ]; + + // Parity is the property that keeps these two paths from drifting apart again. + expect(terminalEventNames(await sseText(events))).toEqual(["response.incomplete"]); + expect(buildResponseJSON(events, "routed/model").status).toBe("incomplete"); + }); + + test("an explicit done still completes", () => { + const json = buildResponseJSON([ + { type: "text", text: "answer" }, + { type: "done" }, + ], "routed/model"); + + expect(json.status).toBe("completed"); + expect((json as { incomplete_details?: unknown }).incomplete_details).toBeUndefined(); + }); + + test("explicit error and explicit incomplete keep their own outcomes", () => { + const failed = buildResponseJSON([ + { type: "text", text: "partial" }, + { type: "error", message: "upstream failed" }, + ], "routed/model"); + expect(failed.status).toBe("failed"); + + const incomplete = buildResponseJSON([ + { type: "text", text: "partial" }, + { type: "incomplete", reason: "max_output_tokens" }, + ], "routed/model"); + expect(incomplete.status).toBe("incomplete"); + // The adapter's own reason must survive, not be overwritten by adapter_eof. + expect((incomplete as { incomplete_details?: { reason?: string } }).incomplete_details?.reason) + .toBe("max_output_tokens"); + }); + + test("a completed tool call with a done event is unaffected", () => { + const json = buildResponseJSON([ + { type: "tool_call_start", id: "call_1", name: "js" }, + { type: "tool_call_delta", arguments: '{"code":"ok"}' }, + { type: "tool_call_end", id: "call_1" }, + { type: "done" }, + ], "routed/model"); + + expect(json.status).toBe("completed"); + const call = json.output.find(o => (o as { type: string }).type === "function_call") as + { status?: string } | undefined; + expect(call?.status).toBe("completed"); + }); +}); + +describe("compaction is never installed from a truncated turn", () => { + // #422: a compaction item becomes REPLACEMENT HISTORY. The original guard could only see + // explicit error/incomplete events, so a stream that stopped without any terminal slipped + // past it — installing a truncated summary as the conversation's new past. + test("no compaction item when the adapter emitted no terminal", () => { + const json = buildResponseJSON( + [{ type: "text", text: "half a summary" }], + "routed/model", + { compaction: true }, + ); + + expect(json.status).toBe("incomplete"); + expect(json.output.some(o => (o as { type: string }).type === "compaction")).toBe(false); + }); + + test("compaction still emitted for a genuinely completed turn", () => { + const json = buildResponseJSON( + [{ type: "text", text: "a whole summary" }, { type: "done" }], + "routed/model", + { compaction: true }, + ); + + expect(json.status).toBe("completed"); + expect(json.output.some(o => (o as { type: string }).type === "compaction")).toBe(true); + }); + + test("compaction stays suppressed for explicit failure terminals", () => { + for (const terminal of [ + { type: "error", message: "upstream failed" } as const, + { type: "incomplete", reason: "max_output_tokens" } as const, + ]) { + const json = buildResponseJSON( + [{ type: "text", text: "partial" }, terminal], + "routed/model", + { compaction: true }, + ); + expect(json.output.some(o => (o as { type: string }).type === "compaction")).toBe(false); + } + }); +}); + +describe("streaming compaction respects the same #422 guard", () => { + async function streamCompaction(events: AdapterEvent[]): Promise<{ hasCompaction: boolean; terminals: string[] }> { + async function* source(): AsyncGenerator { + for (const e of events) yield e; + } + const text = await new Response(bridgeToResponsesSSE( + source(), "routed/model", undefined, undefined, undefined, undefined, 2_000, { compaction: true }, + )).text(); + return { hasCompaction: text.includes('"type":"compaction"'), terminals: terminalEventNames(text) }; + } + + const delta = (t: string) => ({ type: "text_delta", text: t }) as AdapterEvent; + + test("a max_tokens turn ships no compaction item", async () => { + // Streaming emitted the item BEFORE reading stopReason, so a truncated summary was installed + // as replacement history and the turn then declared itself incomplete. + const { hasCompaction, terminals } = await streamCompaction([ + delta("half a summary"), + { type: "done", stopReason: "max_tokens" }, + ]); + + expect(hasCompaction).toBe(false); + expect(terminals).toEqual(["response.incomplete"]); + }); + + test("a content_filter turn ships no compaction item", async () => { + const { hasCompaction, terminals } = await streamCompaction([ + delta("half a summary"), + { type: "done", stopReason: "content_filter" }, + ]); + + expect(hasCompaction).toBe(false); + expect(terminals).toEqual(["response.incomplete"]); + }); + + test("a clean compaction turn still ships exactly one compaction item", async () => { + // codex-rs takes the first compaction item and fatals on zero, so suppression must not widen. + const { hasCompaction, terminals } = await streamCompaction([ + delta("a whole summary"), + { type: "done" }, + ]); + + expect(hasCompaction).toBe(true); + expect(terminals).toEqual(["response.completed"]); + }); +}); + +describe("truncation is recognized regardless of adapter vocabulary", () => { + // stopReason is an open-ended string and adapters disagree: openai-chat normalizes to + // max_tokens/content_filter, Command Code forwards the raw "length", Anthropic forwards + // stop_reason verbatim. Matching only the canonical pair let those turns install a + // half-written summary as replacement history (#422). + const delta = (t: string) => ({ type: "text_delta", text: t }) as AdapterEvent; + + async function streamTerminal(events: AdapterEvent[]): Promise { + async function* source(): AsyncGenerator { + for (const e of events) yield e; + } + const text = await new Response(bridgeToResponsesSSE( + source(), "routed/model", undefined, undefined, undefined, undefined, 2_000, { compaction: true }, + )).text(); + return terminalEventNames(text)[0] ?? ""; + } + + async function streamCompactionItems(events: AdapterEvent[]): Promise { + async function* source(): AsyncGenerator { + for (const e of events) yield e; + } + const text = await new Response(bridgeToResponsesSSE( + source(), "routed/model", undefined, undefined, undefined, undefined, 2_000, { compaction: true }, + )).text(); + // Count emitted ITEMS, not mentions: the compaction item also appears inside the terminal + // response snapshot. A duplicate emission would slip past a boolean presence check. + return text.split("\n\n") + .filter(f => f.includes("event: response.output_item.done") && f.includes('"type":"compaction"')) + .length; + } + + function bufferedCompactionItems(events: AdapterEvent[]): number { + const json = buildResponseJSON(events, "routed/model", { compaction: true }); + return (json.output as { type: string }[]).filter(o => o.type === "compaction").length; + } + + const truncatedReasons = [ + "length", // Command Code / raw OpenAI + "max_tokens", // canonical + "content_filter", // canonical + "refusal", // raw Anthropic + "MAX_TOKENS", // raw Gemini + "MALFORMED_FUNCTION_CALL", + "SAFETY", + ]; + + for (const reason of truncatedReasons) { + test(`stopReason "${reason}" installs no compaction history (streaming and buffered)`, async () => { + const events: AdapterEvent[] = [delta("half a summary"), { type: "done", stopReason: reason }]; + + expect(await streamCompactionItems(events)).toBe(0); + expect(bufferedCompactionItems(events)).toBe(0); + // Suppression and terminal status must agree. Withholding the item while still reporting + // success hands codex-rs a completed response with ZERO compaction items, which is fatal. + expect(await streamTerminal(events)).toBe("response.incomplete"); + expect(buildResponseJSON(events, "routed/model", { compaction: true }).status).toBe("incomplete"); + }); + } + + test("a clean turn still ships EXACTLY ONE compaction item on both paths", async () => { + // codex-rs takes the first compaction item and fatals on zero, so suppression must not + // widen — and a duplicate would be just as wrong. + const events: AdapterEvent[] = [delta("a whole summary"), { type: "done" }]; + + expect(await streamCompactionItems(events)).toBe(1); + expect(bufferedCompactionItems(events)).toBe(1); + }); + + test("an unrecognized stop reason is treated as a normal stop", async () => { + // Unknown values must not fail healthy turns: an unrecognized reason is far more likely a + // provider's ordinary stop than a silent truncation. + const events: AdapterEvent[] = [delta("a whole summary"), { type: "done", stopReason: "end_turn" }]; + + expect(await streamCompactionItems(events)).toBe(1); + expect(bufferedCompactionItems(events)).toBe(1); + }); +}); + +describe("truncated-stop-reason classifier", () => { + test("matches every adapter vocabulary case-insensitively", () => { + for (const reason of [ + "max_tokens", "content_filter", // canonical + "length", "content-filter", // Command Code / AI SDK + "pause_turn", // Anthropic: turn needs continuation + "refusal", "model_context_window_exceeded", // Anthropic + "MAX_TOKENS", "SAFETY", "MALFORMED_FUNCTION_CALL", "IMAGE_SAFETY", "LANGUAGE", // Gemini + "Safety", "safety", // mixed case must not slip through + ]) { + expect(isTruncatedStopReason(reason)).toBe(true); + } + }); + + test("normal stops are never treated as truncation", () => { + // A false positive costs a compaction item, and codex-rs fatals on zero. + for (const reason of ["end_turn", "stop", "stop_sequence", "tool_use", "STOP", "tool-calls", undefined]) { + expect(isTruncatedStopReason(reason)).toBe(false); + } + }); + + test("truncation maps to the right incomplete_details reason", () => { + expect(truncationReasonFor("length")).toBe("max_output_tokens"); + expect(truncationReasonFor("model_context_window_exceeded")).toBe("max_output_tokens"); + expect(truncationReasonFor("refusal")).toBe("content_filter"); + expect(truncationReasonFor("SAFETY")).toBe("content_filter"); + expect(truncationReasonFor("end_turn")).toBeUndefined(); + }); +}); + +describe("Command Code finishReason error is a failure, not a stop", () => { + test("an error finish reason produces an adapter error terminal", () => { + // The AI SDK's "error" means generation FAILED upstream. As a done+stopReason it either read + // as a clean completion or, once classified, mislabelled an upstream error as a content + // filter — rejecting it from the replay cache for the wrong reason. + expect(isTruncatedStopReason("error")).toBe(false); + }); + + test("a turn that failed upstream reports failed, not incomplete", () => { + const json = buildResponseJSON([ + { type: "text", text: "partial" }, + { type: "error", message: 'Command Code upstream ended the turn with finishReason "error"', status: 502, errorType: "upstream_error" }, + ], "routed/model", { compaction: true }); + + expect(json.status).toBe("failed"); + // A failed turn must not install replacement history either. + expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false); + }); +}); diff --git a/tests/command-code-error-finish.test.ts b/tests/command-code-error-finish.test.ts new file mode 100644 index 0000000000..fc1ff3fcbc --- /dev/null +++ b/tests/command-code-error-finish.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, test } from "bun:test"; +import { createCommandCodeAdapter } from "../src/adapters/command-code"; +import { buildResponseJSON } from "../src/bridge"; +import { createTestTranslatorBudget } from "./helpers/translator-budget"; +import type { AdapterEvent, OcxProviderConfig } from "../src/types"; + +const provider: OcxProviderConfig = { + adapter: "command-code", + baseUrl: "https://api.command.example", + apiKey: "test-key", +}; + +function ndjsonResponse(lines: unknown[]): Response { + return new Response(lines.map(l => JSON.stringify(l) + "\n").join(""), { + status: 200, + headers: { "content-type": "application/x-ndjson" }, + }); +} + +/** + * Drives the REAL parser. An earlier suite hand-constructed the downstream error event, so it + * stayed green while the adapter still emitted a clean `done` — the gap an audit named. + */ +describe("Command Code finishReason error", () => { + const errorFinish = [ + { type: "text-delta", text: "partial" }, + { type: "finish", finishReason: "error", totalUsage: { inputTokens: 10, outputTokens: 4 } }, + ]; + + test("streaming: yields an error terminal, not a done, and keeps usage", async () => { + const adapter = createCommandCodeAdapter(provider); + const events: AdapterEvent[] = []; + for await (const e of adapter.parseStream(ndjsonResponse(errorFinish), createTestTranslatorBudget())) { + events.push(e); + } + + const error = events.find(e => e.type === "error") as { usage?: { inputTokens?: number; outputTokens?: number } } | undefined; + expect(error).toBeDefined(); + expect(events.some(e => e.type === "done")).toBe(false); + // A failed turn still consumed tokens; dropping usage makes it look free in accounting. + expect(error?.usage?.inputTokens).toBe(10); + expect(error?.usage?.outputTokens).toBe(4); + }); + + test("buffered: the turn reports failed and installs no compaction history", async () => { + const adapter = createCommandCodeAdapter(provider); + const events = await adapter.parseResponse!(ndjsonResponse(errorFinish), createTestTranslatorBudget()) as AdapterEvent[]; + const json = buildResponseJSON(events, "command-code/model", { compaction: true }); + + expect(json.status).toBe("failed"); + expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false); + }); + + test("an ordinary finish still completes and keeps its usage", async () => { + const adapter = createCommandCodeAdapter(provider); + const events: AdapterEvent[] = []; + for await (const e of adapter.parseStream(ndjsonResponse([ + { type: "text-delta", text: "a whole answer" }, + { type: "finish", finishReason: "stop", totalUsage: { inputTokens: 10, outputTokens: 4 } }, + ]), createTestTranslatorBudget())) events.push(e); + + const done = events.find(e => e.type === "done") as { usage?: { inputTokens?: number } } | undefined; + expect(done).toBeDefined(); + expect(events.some(e => e.type === "error")).toBe(false); + expect(done?.usage?.inputTokens).toBe(10); + }); + + test("a length finish is still a truncation, not an error", async () => { + const adapter = createCommandCodeAdapter(provider); + const events = await adapter.parseResponse!(ndjsonResponse([ + { type: "text-delta", text: "half a summary" }, + { type: "finish", finishReason: "length", totalUsage: { inputTokens: 10, outputTokens: 4 } }, + ]), createTestTranslatorBudget()) as AdapterEvent[]; + const json = buildResponseJSON(events, "command-code/model", { compaction: true }); + + expect(json.status).toBe("incomplete"); + expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false); + }); +}); + diff --git a/tests/cursor-request-builder.test.ts b/tests/cursor-request-builder.test.ts index 47da383fc1..f3aaafd656 100644 --- a/tests/cursor-request-builder.test.ts +++ b/tests/cursor-request-builder.test.ts @@ -199,8 +199,10 @@ describe("Cursor request builder", () => { expect(request.messages[0]?.content).toContain("see"); // A USER-message image is still flattened here (this path builds the plain-text prompt). - // Tool-result images do reach Cursor as real McpImageContent, so the placeholder no longer - // claims the adapter as a whole is unable to send images. + // The tool-result ENCODER does build real McpImageContent, so the placeholder no longer + // claims the encoder as a whole is unable to send images. (Neither kind reaches Cursor in + // production today: every Cursor model is in noVisionModels, so the vision sidecar runs + // first — see devlog/_plan/260817_cursor_toolcall_decode/020_*.md.) expect(request.messages[0]?.content).toContain("image omitted from this Cursor text prompt"); expect(request.messages[0]?.content).toContain("high"); }); diff --git a/tests/cursor-tool-result-image.test.ts b/tests/cursor-tool-result-image.test.ts index 94c03dfba7..610c25e3c4 100644 --- a/tests/cursor-tool-result-image.test.ts +++ b/tests/cursor-tool-result-image.test.ts @@ -26,7 +26,13 @@ function blobData(blobId: Uint8Array): Uint8Array { return kv.message.value.blobData; } -/** Every content item Cursor will see for the tool result attached to the assistant's tool call. */ +/** + * Every content item the ENCODER emits for the tool result attached to the assistant's tool call. + * This is encoder-level: it calls encodeCursorRunRequest directly, so it deliberately bypasses the + * server's vision preprocessing. In production every Cursor model is in noVisionModels, so images + * are described or stripped before the adapter runs — these assertions prove encoder support, not + * end-to-end delivery. + */ function toolResultItems(bytes: Uint8Array) { const msg = fromBinary(AgentClientMessageSchema, bytes); const run = msg.message.case === "runRequest" ? msg.message.value : undefined; @@ -88,7 +94,7 @@ describe("Cursor tool-result image passthrough", () => { expect(items!.length).toBe(2); expect(items![0].content.case).toBe("text"); expect(items![0].content.case === "text" ? items![0].content.value.text : "").toBe("here is the screen"); - // The decisive assertion: the model receives the actual bytes, not a placeholder. + // The decisive assertion: the encoder emits the actual bytes, not a placeholder. expect(items![1].content.case).toBe("image"); if (items![1].content.case !== "image") throw new Error("expected image content"); expect(items![1].content.value.mimeType).toBe("image/png"); diff --git a/tests/google-buffered-stop-reason.test.ts b/tests/google-buffered-stop-reason.test.ts new file mode 100644 index 0000000000..5934f04a40 --- /dev/null +++ b/tests/google-buffered-stop-reason.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, test } from "bun:test"; +import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; +import { buildResponseJSON } from "../src/bridge"; +import type { AdapterEvent, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createGoogleAdapter = (...args: Parameters) => + withTestTranslatorBudget(createGoogleAdapterProduction(...args)); + +const provider: OcxProviderConfig = { + adapter: "google", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "test-key", +}; + +function geminiResponse(finishReason: string, text = "half a summary"): Response { + return new Response(JSON.stringify({ + candidates: [{ content: { parts: [{ text }] }, finishReason }], + usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5 }, + }), { status: 200 }); +} + +describe("Google buffered parseResponse carries its stop reason", () => { + // The streaming path already mapped MAX_TOKENS to stopReason "max_tokens"; parseResponse + // emitted a clean `done`. The bridge therefore reported a truncated buffered turn as + // completed — and on a compaction turn installed the half-written summary as replacement + // history, the #422 hazard. + test("MAX_TOKENS becomes stopReason max_tokens", async () => { + const adapter = createGoogleAdapter(provider); + const events = await adapter.parseResponse!(geminiResponse("MAX_TOKENS")) as AdapterEvent[]; + const done = events.find(e => e.type === "done") as { stopReason?: string } | undefined; + + expect(done).toBeDefined(); + expect(done?.stopReason).toBe("max_tokens"); + }); + + test("a safety finish reason becomes stopReason content_filter", async () => { + const adapter = createGoogleAdapter(provider); + const events = await adapter.parseResponse!(geminiResponse("SAFETY")) as AdapterEvent[]; + const done = events.find(e => e.type === "done") as { stopReason?: string } | undefined; + + expect(done?.stopReason).toBe("content_filter"); + }); + + test("a normal STOP carries no stop reason", async () => { + const adapter = createGoogleAdapter(provider); + const events = await adapter.parseResponse!(geminiResponse("STOP", "a whole answer")) as AdapterEvent[]; + const done = events.find(e => e.type === "done") as { stopReason?: string } | undefined; + + expect(done).toBeDefined(); + expect(done?.stopReason).toBeUndefined(); + }); + + test("a truncated buffered compaction turn installs no replacement history", async () => { + const adapter = createGoogleAdapter(provider); + const events = await adapter.parseResponse!(geminiResponse("MAX_TOKENS")) as AdapterEvent[]; + const json = buildResponseJSON(events, "google/gemini-3-pro", { compaction: true }); + + expect(json.status).toBe("incomplete"); + expect((json.output as { type: string }[]).some(o => o.type === "compaction")).toBe(false); + }); +});