diff --git a/README.md b/README.md index 0191637..8ec05b5 100644 --- a/README.md +++ b/README.md @@ -38,18 +38,23 @@ OpenAI-compatible proxy on demand and routes requests through Cursor's gRPC API. ## Models -Grok 4.6 is supported. The plugin registers it as `grok-4.6` in the fallback -model catalog, and live discovery returns whatever models Cursor serves. - -- Default context: 256K tokens (Cursor-facing default) -- Reasoning: supported — Cursor lists Grok 4.6 with Agent + Thinking -- Short-context pricing: $2 input / $6 output per million tokens - -The plugin proxies Cursor's Connect API, so model metadata mirrors Cursor's -published surface. The xAI direct API advertises a 500K window and accepts -image input; neither applies to this plugin, which is text-in / text-out for -every model. Image and vision input are never forwarded, so Grok 4.6 is -text-only here. +Grok 4.6 is supported on two surfaces that stay consistent: + +- **Fallback catalog id `grok-4.6`** — the plugin's resilience path when live + discovery is unavailable. It carries the Cursor-facing metadata: 256K default + context, reasoning enabled (Cursor lists Grok 4.6 with Agent + Thinking), and + 64K max output. +- **Live-discovered variants** — `GetUsableModels` is authoritative and returns + whatever Cursor serves, typically qualified variant ids. Live-verified: + OpenCode's model name `cursor/cursor-grok-4.6-high` is sent to Cursor's Run + API as the wire id `cursor-grok-4.6-high`. The bare `grok-4.6` id remains the + fallback catalog entry. + +Cursor-facing short-context pricing is **$2 input / $6 output per million +tokens**, with a **$0.50 cached-read** rate. The xAI direct API advertises a +500K window and accepts image input; neither applies to this plugin, which is +text-in / text-out for every model. Image and vision input are never forwarded, +so Grok 4.6 is text-only here. ## How it works @@ -100,6 +105,45 @@ checkpoints are persisted to `~/.cache/opencode-cursor/conversations/` so context survives restarts. Set `CURSOR_PROXY_DEBUG=1` to log the KV blob handshake and exec traffic when debugging. +## Configuration and timeouts + +All defaults work out of the box; **no configuration is required.** Every +timeout below is read by the code path that enforces it and has a hard cap, so +an invalid environment value can never disable the guard. + +### Diagnostics and logging + +| Variable | Default | Behavior | +|---|---|---| +| `CURSOR_PROXY_DIAGNOSTICS` | off | `1` emits one-line JSON lifecycle records to stderr: `auth_access_start/complete`, `auth_refresh_start/complete/failed`, `proxy_entry`, `bridge_spawn`, `bridge_start`, `h2_connect`, `connect_stream`, `bridge_data`, `connect_frame`, `message_dispatch`, `outbound_model`, `tool_pause`, `tool_resume`, `sse_emission`, `end_stream`, `turn_ended`, and `terminal`. Fields are allowlisted — stage names, elapsed times, byte lengths, status/exit codes, error class names, the outbound Cursor wire `modelId`, and parent-observed transport state (`transportState`, `writable`). Tokens, authorization headers, cookies, prompt text, tool payloads, and blob/checkpoint IDs are never serialized. | +| `CURSOR_PROXY_DEBUG` | off | Verbose KV-blob, exec-redirect, and end-stream debug lines on stderr. | + +### Timeouts + +| Variable | Default | Cap | Bounds | +|---|---|---|---| +| `CURSOR_REFRESH_TIMEOUT_MS` | 15 000 (15s) | 60 000 (60s) | Token refresh fetch; a stall returns a sanitized `token_refresh_timeout` 504. | +| `CURSOR_PROXY_FIRST_PROGRESS_TIMEOUT_MS` | 900 000 (15 min) | 3 600 000 (60 min) | Wait for the first semantic progress (text, thinking, positive token delta, or exec transition). | +| `CURSOR_PROXY_SEMANTIC_PROGRESS_TIMEOUT_MS` | 600 000 (10 min) | 1 800 000 (30 min) | Max silence between semantic progress events. Heartbeat, checkpoint, and query traffic never defer it; slow-but-progressing streams are preserved. | +| `CURSOR_BRIDGE_PING_INTERVAL_MS` | 15 000 (15s) | 300 000 (5 min) | HTTP/2 PING cadence that validates the transport path. | +| `CURSOR_BRIDGE_PING_TIMEOUT_MS` | 10 000 (10s) | 60 000 (60s) | PING response window; a miss fails the session explicitly. | +| `CURSOR_BRIDGE_SESSION_TIMEOUT_MS` | 120 000 (2 min) | 600 000 (10 min) | HTTP/2 session inactivity bound; expiry fails the session explicitly. | +| `CURSOR_BRIDGE_FAILURE_EXIT_DELAY_MS` | 0 | 1 000 (1s) | **Internal test seam — not recommended for configuration.** Briefly retains a failed bridge child so tests can exercise parent-side handling of a process-alive, transport-dead resume. Leave unset. | + +Fixed transport constants (not environment-tunable): the initial Connect bound +is 30s, the bridge activity/idle bound is 120s, the proxy writes a client +heartbeat every 5s, and unary discovery RPCs time out after 5s. + +### Endpoint and path overrides + +| Variable | Default | Notes | +|---|---|---| +| `CURSOR_API_URL` | `https://api2.cursor.sh` | Connect endpoint override, read once at module load. The smoke harness uses it to point at the fake Cursor backend. | +| `CURSOR_REFRESH_URL` | `https://api2.cursor.sh/auth/exchange_user_api_key` | Token refresh endpoint override, read once at module load. Test harness seam. | +| `XDG_CACHE_HOME` | `~/.cache` | Base for the `opencode-cursor/conversations` disk cache. | +| `XDG_DATA_HOME` | `~/.local/share` | Base for the `opencode/auth.json` credential lookup. | +| `OPENCODE_AUTH_CONTENT` | — | Replaces on-disk `auth.json` contents; testing seam. | + ## Develop locally ```sh diff --git a/docs/cursor-hang-root-cause.md b/docs/cursor-hang-root-cause.md index 2b3c82e..bdbf7fa 100644 --- a/docs/cursor-hang-root-cause.md +++ b/docs/cursor-hang-root-cause.md @@ -1,11 +1,11 @@ # Cursor Model Hang — Root Cause Analysis -Status: **Evidence baseline, lifecycle map, and terminal-contract runtime evidence complete.** This -document opens the hang investigation: it preserves the speculative uncommitted overlay that -predated this investigation, classifies every hunk with an explicit disposition, records -baseline-vs-overlay behavior, and then (Part 2) enumerates every open-SSE path and termination -route with source-linked matrices. No behavioral fix is implemented here; the matrices exist -so the termination-contract work lands directly against a complete map. +Status: **Root cause settled, termination contract shipped and live-verified.** This document +preserves the speculative uncommitted overlay that predated this investigation, classifies every +hunk with an explicit disposition, records baseline-vs-overlay behavior, and (Part 2) enumerates +every open-SSE path and termination route with source-linked matrices. Part 3 closes the +investigation: the settled root cause, the shipped termination contract, and the isolated live +verification that confirmed the two previously runtime-deferred assumptions (A1 and A7). ## Grounding fact: nothing upstream rescues a stuck request @@ -155,15 +155,25 @@ records carry the same ID and include the parent elapsed time at spawn for cross The stage inventory is: `auth_access_start`, `auth_access_complete`, refresh start/complete/fail, `proxy_entry`, `bridge_spawn`, `bridge_start`, `h2_connect`, `connect_stream`, first -`bridge_data`, first `connect_frame`, `end_stream`, `message_dispatch`, `tool_pause`, -`tool_resume`, first `sse_emission`, and `terminal`. Conditional tool stages appear only for -tool-call requests. Bridge timeout kills are distinguishable: `terminal` with -`errorName: "Timeout:connect"` (initial 30s connect bound) or `"Timeout:idle"` (120s activity +`bridge_data`, first `connect_frame`, `message_dispatch`, `outbound_model`, `tool_pause`, +`tool_resume`, first `sse_emission`, `end_stream`, `turn_ended`, and `terminal`. Conditional tool +stages appear only for tool-call requests. Bridge timeout kills are distinguishable: `terminal` +with `errorName: "Timeout:connect"` (initial 30s connect bound) or `"Timeout:idle"` (120s activity bound) versus `errorName` from a client/stream error and `exitCode` from normal exit. See Part 2 §2.8 for the instrumentation added in this pass. +Two stages exist specifically to answer the live-verification questions: + +- `outbound_model` carries `modelId` — the exact Cursor wire model id the Run request sends + (after the `auto` → `default` mapping). This is the single request-derived field admitted to + diagnostics, and it is how live evidence confirmed the Cursor-facing id. +- `turn_ended` carries `transportState` and `writable` — the parent-observed bridge transport + state captured *before* terminal cleanup tears the bridge down, proving whether Cursor ended a + turn while its HTTP/2 transport remained usable. + Diagnostics use a strict allowlist: source, stage, request ID, elapsed time, byte length, message -case, interaction case, tool count, status/exit code, and error class. They never serialize auth +case, interaction case, tool count, status/exit code, error class, the outbound `modelId`, and the +`turn_ended` transport fields (`transportState`, `writable`). They never serialize auth tokens or headers, cookies, prompt/message text, tool arguments/results, or blob/checkpoint IDs. The parent always drains child stderr even while diagnostics are disabled, preventing pipe backpressure from stalling the bridge. @@ -450,9 +460,11 @@ proxy and child records can be ordered across the process boundary. | `terminal` with `exitCode: 0` after `end_stream` | the normal clean cascade (T1→T4) | **Emitted-only-allowlist reminder:** stages, request ID, elapsed time, byte lengths, message -case names, tool counts, status/exit codes, and error class names. Tokens, authorization -headers, prompt text, tool arguments/results, and blob/checkpoint IDs are never serialized -(`src/auth.ts:48-67`; parent drain filter `src/proxy.ts:344-365`). +case names, tool counts, status/exit codes, error class names, the outbound wire `modelId` +(`outbound_model`), and the parent-observed transport fields `transportState`/`writable` +(`turn_ended`). Tokens, authorization headers, prompt text, tool arguments/results, and +blob/checkpoint IDs are never serialized (`src/auth.ts:66-88`; parent drain filter +`src/proxy.ts:365-409`). ## 2.10 Runtime reproduction verdicts @@ -519,8 +531,77 @@ bridge H2 stream is closed makes the child exit non-zero rather than being silen | Empty Connect end-stream is an error | **Refuted after fix** | The zero-byte fixture completes cleanly with exactly one `[DONE]`. | | Missing end-stream / abrupt H2 close becomes clean success or retry fodder | **Confirmed before fix; closed** | Missing-end, stream-reset, and abrupt-session-close fixtures now emit one explicit error and one `[DONE]`; no empty success is observed. This closes the local boundary implicated by issue #33. | | Malformed Connect terminal or protobuf message is silently lost | **Confirmed before fix; closed** | Partial/malformed terminal and malformed protobuf fixtures each reach one explicit error terminal. | -| Bridge write failure is silently dropped | **Confirmed by source; propagation covered** | A rejected KV response write propagates out of dispatch into the terminal path; the bridge also exits non-zero when stdin reaches a closed H2 stream. The process-alive, transport-dead resume race remains deferred to the resume-liveness work. | +| Bridge write failure is silently dropped | **Confirmed by source; propagation covered** | A rejected KV response write propagates out of dispatch into the terminal path; the bridge also exits non-zero when stdin reaches a closed H2 stream. **Closed in Wave 4 (`64edca6`):** a paused resume now requires a child-reported writable transport and fails explicitly in bounded time instead of dropping the write silently (Part 3 §3.2). | | Tool-result pause must retain a live bridge | **Confirmed and preserved** | The healthy tool-resume fixture still returns `resumed-correctly`; pause is not terminal cleanup. | | Heartbeat/checkpoint traffic can stand in for semantic progress | **Confirmed before classification; not a terminal-contract result** | Fixtures classify heartbeat/turn-ended traffic as non-progress and report transport closure explicitly. Stall detection itself remains separate work. | -| Unbounded refresh and non-streaming collection | **Confirmed and deferred** | Their quarantined bounded observations still show no headers at 125ms; no timeout, watchdog, or deadline was added here. | -| Bridge-key collision | **Confirmed and deferred** | The quarantined same-opening-conversation assertion still reproduces cross-routing; key derivation was not changed. | +| Unbounded refresh and non-streaming collection | **Confirmed and deferred** | Their quarantined bounded observations still show no headers at 125ms; no timeout, watchdog, or deadline was added here. **Closed in later waves:** refresh is bounded by `CURSOR_REFRESH_TIMEOUT_MS` (15s default / 60s cap → sanitized 504 `token_refresh_timeout`) and non-streaming collection by the semantic watchdog (504 `response_stall_timeout`); defaults are documented in README and Part 3 §3.2. | +| Bridge-key collision | **Confirmed and deferred** | The quarantined same-opening-conversation assertion still reproduces cross-routing; key derivation was not changed. **Closed in Wave 4:** resume identity is now an opaque per-pause UUID plus Cursor tool-call ids — never content-derived — so identical-opening conversations stay isolated (promoted smoke assertions; Part 3 §3.2). | + +--- + +# Part 3 — Settled root cause, termination contract, and live closure + +This part closes the investigation. It states the settled root cause, records the termination +contract exactly as shipped, and attaches the isolated live evidence that resolved the two +previously runtime-deferred assumptions (A1 and A7). Deterministic fixture evidence and live +observation are deliberately kept distinct throughout. + +## 3.1 Settled root cause + +The indefinite thinking/typing stall was a **termination gap on the normal completion path**, +not a missing timeout. Cursor can signal a finished turn with `InteractionUpdate.turnEnded` +while leaving its HTTP/2 stream open — no Connect end-stream follows. The inherited proxy +treated `turnEnded` as non-terminal, so a completed turn produced no SSE terminal of its own; +the only bound left was the semantic-progress watchdog, which (correctly) does not count +heartbeat/checkpoint traffic as progress and therefore waits out a long, quiet post-turn window. +Combined with a raw-byte idle timer that every inbound frame and the proxy's own 5s heartbeat +defeated, a normal completion could sit open until OpenCode or the user intervened. + +Two contributing gaps were closed in earlier passes and are part of the settled contract: +silent truncation (a non-zero bridge exit or an abrupt H2 close could surface as a clean +`finish_reason: "stop"`), and bridge reuse that keyed on process aliveness alone, so a +process-alive but transport-dead paused bridge accepted a resume write that vanished silently. + +## 3.2 The termination contract as shipped + +Every request reaches exactly one terminal outcome through one idempotent owner +(`finishTerminal` in the streaming path, `finishCollection` in the non-streaming path). + +| Signal | Behavior | +|---|---| +| `turnEnded` | **Normal completion.** Closes cleanly with `finish_reason: "stop"`, usage, and exactly one `[DONE]` even though the HTTP/2 stream may remain open. A later transport close cannot double-report. | +| Connect end-stream, no error (including zero-byte) | Clean completion through the same owner; exactly one `[DONE]`. | +| Connect end-stream with `error` | Error content, `stop`, usage, one `[DONE]`; exactly one explicit error. | +| Closure without `turnEnded` and without a Connect end-stream | **Terminal-less closure error** — exactly one explicit error and one `[DONE]`, regardless of bridge exit code. Never a clean `finish_reason: "stop"` without a completion signal. | +| Semantic-progress watchdog | **Abnormal-path bound only.** Counts only semantic progress (text, thinking, positive token deltas, exec transitions); heartbeat, checkpoint, KV, and interaction-query traffic never defer it. Normal completion terminates on its protocol signal, never on watchdog expiry. Slow-but-progressing streams are preserved (fixture `slow-semantic-progress`). | +| Transport-aware resume | A paused bridge resumes only when the child reports a writable HTTP/2 transport (`transportState: "writable"`). A process-alive but transport-dead bridge fails explicitly with the sanitized error `Cursor bridge transport is unavailable for tool resume`, exactly one `[DONE]`, in bounded time. A fully exited bridge falls back to a fresh request rebuilt from checkpoint/blob/tool history. Resume identity is an opaque per-pause UUID plus Cursor's tool-call ids — never derived from message content — so identical-opening conversations cannot cross-route results. | +| Safe query refusal | Generated interaction queries with a refusal arm (web search, ask-question, switch-mode, Exa search/fetch, plan) receive typed rejections and the same turn continues to a clean `turnEnded`. `setupVmEnvironmentArgs` and `execServerControlMessage` remain explicit errors because no safe refusal arm exists. | + +## 3.3 Live closure (A1 and A7) and the idle case + +The isolated tmux live run (`docs/live-verification-evidence.md`, commits `29c6d6d`/`89668b7`) +resolved both previously runtime-deferred assumptions for the tested normal completion: + +- **A1 — Cursor-facing wire id: confirmed for the tested variant.** OpenCode's model name + `cursor/cursor-grok-4.6-high` is sent to the Run API as the wire id `cursor-grok-4.6-high` + (observed via the sanitized `outbound_model modelId=cursor-grok-4.6-high` record). The bare + `grok-4.6` fallback catalog id remains the resilience path, not a live wire id. +- **A7 — `turnEnded` while H2 writable: confirmed.** The sanitized record + `turn_ended transportState=writable writable=true elapsedMs=2372` followed by + `terminal elapsedMs=2373` proves Cursor ended the turn at 2372ms while the transport was + still writable, immediately before terminal cleanup. The strict Wave 3 contract was retained. + +**Live idle case did not induce a provider stall.** The deliberate-idle run (a non-destructive +idle prompt, 10.214s wall-clock) completed without forcing a provider stall, so it is recorded +as live pass with limitation rather than as stall evidence. Stall bounding remains +deterministically demonstrated by fixture evidence only (`heartbeat-trickle-kept-open` → +explicit `response_stall_timeout`; `turn-ended-semantic-stall` → prompt clean terminal before +watchdog). Live and fixture evidence are never mixed. + +## 3.4 Diagnostics additions that closed the loop + +`outbound_model` (wire `modelId`) and `turn_ended` (`transportState`, `writable`) are the two +allowlisted fields that made A1/A7 observable without exposing any payload. The full +sanitization boundary is stated in the "Safe lifecycle diagnostics" section above and asserted +in `test/smoke.ts:testLifecycleDiagnostics` (schema, allowlist, disabled silence, secret +redaction). diff --git a/docs/grok-4-6-provenance.md b/docs/grok-4-6-provenance.md index efb750a..a3f0a9d 100644 --- a/docs/grok-4-6-provenance.md +++ b/docs/grok-4-6-provenance.md @@ -39,11 +39,15 @@ match Grok 4.5/4.6 short-context pricing; `grok-4.20` itself is a different xAI These items are **not** asserted as verified. Each is labelled with its confidence grade from the source Field Note. -- **Cursor-facing model id string: UNVERIFIED (inference).** Presumed `grok-4.6`, matching the xAI - id and Cursor's docs path `/docs/models/grok-4-6`, but not yet confirmed against a live - `GetUsableModels` response (`fn_20260812_vaamzb8z`, "INFERENCE / UNCERTAIN"). Spec assumption A1 - and the risk table treat a differing id as an open risk; live confirmation is scheduled for Wave 5. - The fallback catalog entry is a best-effort resilience path, not the authoritative discovery path. +- **Cursor-facing model id string: PARTIALLY RESOLVED by live verification (Wave 5).** The + original inference presumed `grok-4.6`, matching the xAI id and Cursor's docs path + `/docs/models/grok-4-6`, but that was not yet confirmed against a live `GetUsableModels` + response (`fn_20260812_vaamzb8z`, "INFERENCE / UNCERTAIN"). Spec assumption A1 and the risk + table treated a differing id as an open risk. Wave 5 live validation resolved it for the + tested variant: OpenCode's qualified model name `cursor/cursor-grok-4.6-high` is sent to + Cursor's Run API as the wire id `cursor-grok-4.6-high` (see [§9](#9-live-id-verification-addendum-wave-5)). + The fallback catalog entry `grok-4.6` remains a best-effort resilience path, not the + authoritative discovery path. - **Billing pool: NO CLAIM MADE.** Whether Grok 4.6 on Cursor draws from the first-party pool or the API/third-party pool is UNCONFIRMED. Launch-day Reddit reports are anecdotal and internally inconsistent with Cursor's own grok page @@ -163,3 +167,25 @@ Evidence: origin PR [#34](https://github.com/ephraimduncan/opencode-cursor/pull/ - `bun run build` — PASS (documentation-only change; no source, tests, or dependencies touched). - `git diff --check` — PASS for the new document. + +## 9. Live id verification addendum (Wave 5) + +Wave 5 live validation (`docs/live-verification-evidence.md`, commits `29c6d6d`/`89668b7`) +confirmed the Cursor-facing wire id for the tested variant through sanitized proxy diagnostics. +This addendum records the live facts; it does not alter the prior-art verdicts in §3–§8. + +| Fact | Value | Evidence | +|---|---|---| +| OpenCode model name (discovered) | `cursor/cursor-grok-4.6-high` | Isolated CLI discovery; the bare string `cursor-grok-4.6` was rejected by OpenCode 1.15.3 (`docs/live-verification-evidence.md`). | +| Cursor Run wire id (observed) | `cursor-grok-4.6-high` | Sanitized diagnostic `outbound_model modelId=cursor-grok-4.6-high` from the branch-local plugin. | +| Normal-completion behavior (observed) | `turnEnded` at 2.372s with `transportState=writable`, `writable=true`; terminal at 2.373s | Sanitized `turn_ended`/`terminal` records; A7 confirmed for this completion. | + +Implications for the catalog: live discovery returns qualified variant ids such as +`cursor/cursor-grok-4.6-high` rather than the bare `grok-4.6`; the fallback entry keeps the +bare id as its resilience path and the Cursor-facing metadata (256K context, reasoning, 64K +output). No catalog change was required: `normalizeSingleModel` already reuses fallback limit +metadata for any discovered id, and the proxy routes whatever id it receives. + +The live evidence is a direct observation of one authenticated normal completion on the high +variant; broader variant and failure-path coverage remains outside that rerun, and no product +change is justified by these observations. diff --git a/docs/live-verification-evidence.md b/docs/live-verification-evidence.md new file mode 100644 index 0000000..8231c79 --- /dev/null +++ b/docs/live-verification-evidence.md @@ -0,0 +1,81 @@ +# Live verification evidence + +**Date:** 2026-08-12 +**Branch:** `test/cursor-live-validation` +**Commit under test:** `29c6d6d` +**Evidence class:** live validation with authenticated read-only state; lifecycle evidence is sanitized and limited to the proxy allowlist + +## Isolation and safety + +- Dedicated tmux session: `cursor-live-validation`. +- Session was created detached and was never attached to, renamed, killed, or reused from another session. +- Isolated process environment used `CURSOR_PROXY_DIAGNOSTICS=1` and separate non-secret paths under `/var/tmp/james/opencode/cursor-live-validation/{config,cache,state}`. +- The OpenCode CLI reported its data and log roots as global paths rather than honoring a separate data-home override. No global credential file was copied, edited, or exposed. +- **Historical blocked attempt:** the first run from `73681d7` had no usable isolated credential store and returned sanitized `UnknownError` events. No login or secret handling was attempted. +- **Authenticated resume:** with explicit authorization, the separate process referenced the existing credential state read-only. Its database, config, cache, and state paths remained under `/var/tmp/james/opencode/cursor-live-validation/`; no credential file was copied or edited. +- The process loaded the branch-local plugin at `file:///home/james/Documents/opencode-cursor-fork/dist/index.js`, rather than a globally installed plugin. +- CLI discovery proved that the requested bare string `cursor-grok-4.6` is not a valid model id in this OpenCode version. Cursor exposes qualified variants such as `cursor/cursor-grok-4.6-high`; the authenticated runs used that discovered id and `--variant high`. +- Before the conclusive rerun, `bun run build` succeeded and refreshed the branch-local `dist/index.js`. + +## Run record + +| Run | Intended case | Result | Elapsed | Evidence status | +|---|---|---|---:|---| +| 1 | Normal short completion | Clean text response; proxy terminal diagnostic | 4.755 s | live pass | +| 2 | Long-reasoning turn | Clean response, `end_stream` status `clean`, bridge exit code 0 | 6.896 s | live pass | +| 3 | Tool-pause/resume turn | Tool pause and subsequent resume both completed; one tool observed | 5.117 s + 5.117 s | live pass | +| 4 | Idle/deliberately stalled turn | Non-destructive idle prompt completed; no forced stall was induced | 10.214 s | live pass with limitation | + +The four authenticated cases completed through the branch-local proxy. Deterministic fixture and smoke evidence remain separate and are not promoted to live evidence. + +## Conclusive normal-completion rerun + +The minimum normal case was rerun after rebuilding the branch-local plugin. The wall-clock elapsed time was **6.537 s**; the proxy lifecycle elapsed time to terminal cleanup was **2.373 s**. The sanitized diagnostics recorded: + +```text +outbound_model modelId=cursor-grok-4.6-high +message_dispatch messageCase=interactionUpdate interactionCase=heartbeat +turn_ended transportState=writable writable=true elapsedMs=2372 +terminal elapsedMs=2373 +``` + +The raw OpenCode response completed successfully. Raw output and diagnostics remain outside the repository. + +## A1 and A7 verdicts + +- **A1, Cursor-facing wire id:** **confirmed as `cursor-grok-4.6-high` for the tested high variant.** OpenCode's qualified model name is also `cursor/cursor-grok-4.6-high`; the proxy's `outbound_model.modelId` matched the provider-qualified id without the slash. This is a high-variant wire id, not the bare `grok-4.6` catalog id. +- **A7, `turnEnded` is normal completion and H2 remains open:** **confirmed for the tested normal completion.** The sanitized `turn_ended` event occurred at 2.372 s with `transportState=writable` and `writable=true` before cleanup, proving the H2 transport remained writable when `turnEnded` arrived. The terminal event followed at 2.373 s. + +Existing deterministic evidence and prior-art research support the implementation direction, but neither answers these live-verification questions. + +## Sanitization check + +The captured artifacts were inspected only for structure and marker presence. Sanitized lifecycle records contain allowlisted stage names, elapsed times, byte lengths, status, exit code, and coarse message-case names. They contained no authorization headers, cookies, bearer tokens, or prompt text. Raw run output remains outside the repository and was not committed. + +## Reproduction requirement + +Residual limitations: the CLI requires a qualified variant model id, and this conclusive run tested the high variant rather than the bare catalog id. The idle case intentionally did not induce a provider stall. The A1/A7 conclusions are direct observations of one authenticated normal completion; broader variant and failure-path coverage remains outside this rerun. No product change is justified by these observations. + +## Final stacked regression gates + +**Date:** 2026-08-12 +**Commit under test:** `89668b75e413fe268f8914ca3b799be7cc89e3c2` +**Evidence class:** final local regression gate on the fully stacked branch + +All commands were run unpiped with their exit status captured directly: + +| Gate | Exact command | Exit | Result | +|---|---|---:|---| +| Typecheck | `npx tsc -p tsconfig.json --noEmit` | 0 | PASS; no TypeScript errors | +| Full smoke | `bun test/smoke.ts` | 0 | PASS; `✓ All smoke tests passed` | +| Build | `bun run build` | 0 | PASS; `tsc -p tsconfig.json && node scripts/copy-runtime.mjs` | + +Static/runtime count evidence: `test/smoke.ts` contains **185 assertion call sites**, **17 test functions**, and runtime reports **28 selectable fake Cursor scenarios**. All inherited stability cases and Grok 4.6 catalog/routing cases passed. No quarantines or known-failure markers appeared in the current smoke source/output. + +The smoke suite writes conversation cache data under `~/.cache/opencode-cursor`; this cache location is expected and was not committed. + +## Package script inspection and no-regression finding + +`package.json` was inspected directly. Scripts present are `build`, `test`, and `prepublishOnly`. There is **no `lint` script** and **no root `typecheck` script**; the explicit TypeScript command above is the repository's typecheck entry point. No lint command was silently skipped. + +Existing model behavior remains unchanged alongside Grok 4.6. Smoke confirms `grok-code-fast-1` retains its prior 128K context and non-reasoning metadata, while Grok 4.6 exposes 256K context, reasoning enabled, and 64K output metadata with explicit $2/$0.50 cached/$6 pricing. Both streaming and non-streaming Grok 4.6 routing assertions pass. **No regression found in existing model metadata or cost behavior.** diff --git a/src/auth.ts b/src/auth.ts index 104b276..7f5a12c 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -37,12 +37,14 @@ export type LifecycleDiagnosticStage = | "auth_access_start" | "auth_access_complete" | "auth_refresh_start" | "auth_refresh_complete" | "auth_refresh_failed" | "proxy_entry" | "bridge_spawn" | "connect_frame" | "end_stream" - | "message_dispatch" + | "message_dispatch" | "outbound_model" | "turn_ended" | "tool_pause" | "tool_resume" | "sse_emission" | "terminal"; type LifecycleDiagnosticDetails = Readonly<{ byteLength?: number; messageCase?: string; interactionCase?: string; toolCount?: number; exitCode?: number; errorName?: string; status?: string; + modelId?: string; transportState?: "connecting" | "writable" | "closed" | "failed"; + writable?: boolean; }>; /** Diagnostics are opt-in because even safe metadata is operational noise. */ @@ -71,7 +73,7 @@ export function emitLifecycleDiagnostic( const store = lifecycleDiagnosticContext.getStore(); if (!store || store.emittedStages.has(stage)) return; store.emittedStages.add(stage); - const event: Record = { source, stage, requestId: context.requestId, elapsedMs: context.elapsedMs }; + const event: Record = { source, stage, requestId: context.requestId, elapsedMs: context.elapsedMs }; if (typeof details.byteLength === "number") event.byteLength = details.byteLength; if (typeof details.messageCase === "string") event.messageCase = details.messageCase; if (typeof details.interactionCase === "string") event.interactionCase = details.interactionCase; @@ -79,6 +81,9 @@ export function emitLifecycleDiagnostic( if (typeof details.exitCode === "number") event.exitCode = details.exitCode; if (typeof details.errorName === "string") event.errorName = details.errorName; if (typeof details.status === "string") event.status = details.status; + if (typeof details.modelId === "string") event.modelId = details.modelId; + if (typeof details.transportState === "string") event.transportState = details.transportState; + if (typeof details.writable === "boolean") event.writable = details.writable; console.error(JSON.stringify(event)); } diff --git a/src/proxy.ts b/src/proxy.ts index 494bf5e..aae5e47 100644 --- a/src/proxy.ts +++ b/src/proxy.ts @@ -1019,6 +1019,10 @@ function buildCursorRequest( // "auto" is the proxy's pseudo-model for Cursor's server-side Auto // routing; the Run API expects modelId "default" for it. const cursorModelId = modelId === "auto" ? "default" : modelId; + // A model identifier is the sole request-derived field admitted to lifecycle + // diagnostics. Payloads, prompts, headers, and conversation state remain + // deliberately unobservable. + emitLifecycleDiagnostic("proxy", "outbound_model", { modelId: cursorModelId }); const displayName = modelId === "auto" ? "Auto" : modelId; const requestedModel = create(RequestedModelSchema, { modelId: cursorModelId, @@ -1929,7 +1933,16 @@ function createBridgeStreamResponse( } }, finishTerminal, - () => finishTerminal(), + () => { + // Capture the parent-observed H2 state before terminal cleanup + // tears down the bridge. This establishes whether Cursor ended + // a turn while its transport remained writable. + emitLifecycleDiagnostic("proxy", "turn_ended", { + transportState: bridge.transportState, + writable: bridge.writable, + }); + finishTerminal(); + }, ); if (!terminal) semanticWatchdog.observe(); emitLifecycleDiagnostic("proxy", "message_dispatch", { diff --git a/test/smoke.ts b/test/smoke.ts index 7debd34..0e0b8fb 100644 --- a/test/smoke.ts +++ b/test/smoke.ts @@ -669,6 +669,7 @@ async function testFakeCursorScenarioHarness() { interface SseObservation { body: string; timedOut: boolean; + diagnostics: string[]; } function doneCount(body: string): number { @@ -726,7 +727,7 @@ async function observeProxyScenario( new Response(child.stderr).text(), ]); assertEqual(exitCode, 0, `Scenario subprocess failed (${scenario}): ${stderr}`); - return JSON.parse(stdout.trim()) as SseObservation; + return { ...JSON.parse(stdout.trim()) as Omit, diagnostics: stderr.trim().split("\n").filter(Boolean) }; } finally { await fixture.close(); } @@ -1289,10 +1290,23 @@ async function testLifecycleDiagnostics(modules: TestModules) { for (const stage of [ "proxy_entry", "auth_access_start", "auth_access_complete", "bridge_spawn", "bridge_start", "h2_connect", "connect_stream", "bridge_data", "connect_frame", - "message_dispatch", "sse_emission", "terminal", + "message_dispatch", "outbound_model", "sse_emission", "terminal", ]) { assert(stages.has(stage), `Missing diagnostic lifecycle stage: ${stage}`); } + const outboundModel = events.find((event) => event.stage === "outbound_model"); + assertEqual(outboundModel?.modelId, "test", "Outbound diagnostic must expose the selected Cursor wire model ID"); + const allowedKeys = new Set([ + "source", "stage", "requestId", "elapsedMs", "byteLength", "messageCase", "interactionCase", + "toolCount", "exitCode", "errorName", "status", "modelId", "transportState", "writable", + "parentElapsedMs", + ]); + for (const event of events) { + for (const [key, value] of Object.entries(event)) { + assert(allowedKeys.has(key), `Diagnostic event emitted non-allowlisted key: ${key}`); + assert(["string", "number", "boolean"].includes(typeof value), `Diagnostic value must be scalar: ${key}`); + } + } const requestIds = new Set(events.map((event) => event.requestId)); assertEqual(requestIds.size, 1, "Expected one shared diagnostic correlation ID"); assert(events.every((event) => typeof event.elapsedMs === "number"), "Expected monotonic diagnostic timing"); @@ -1300,6 +1314,19 @@ async function testLifecycleDiagnostics(modules: TestModules) { for (const secret of [fakeToken, secretPrompt, secretToolPayload]) { assert(!output.includes(secret), `Diagnostic output leaked secret: ${secret}`); } + + const turnEnded = await observeProxyScenario("turn-ended-transport-close", 125, { + CURSOR_PROXY_DIAGNOSTICS: "1", + }); + const turnEndedEvents = turnEnded.diagnostics.map((line) => JSON.parse(line) as Record); + const turnEndedEvent = turnEndedEvents.find((event) => event.stage === "turn_ended"); + assert(turnEndedEvent, "turnEnded dispatch must emit a lifecycle diagnostic"); + assertEqual(turnEndedEvent.transportState, "writable", "turnEnded diagnostic must capture pre-cleanup transport state"); + assertEqual(turnEndedEvent.writable, true, "turnEnded diagnostic must capture pre-cleanup writability"); + const turnEndedOutput = turnEnded.diagnostics.join("\n"); + for (const secret of ["test-token", "reproduce termination behavior"]) { + assert(!turnEndedOutput.includes(secret), `turnEnded diagnostic leaked secret or prompt: ${secret}`); + } } finally { if (originalSetting === undefined) delete process.env.CURSOR_PROXY_DIAGNOSTICS; else process.env.CURSOR_PROXY_DIAGNOSTICS = originalSetting;