diff --git a/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md b/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md new file mode 100644 index 0000000000..75685484b4 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/000_plan.md @@ -0,0 +1,54 @@ +# 260822 — senpi Cursor transfer investigation + +Docs-only research unit. No production patches in this cycle. +Session `01a02665-e4c1-75a3-9660-c71284a1bba2`. Goalplan `investigate-whether-opencodex-can-adopt-any-curs`. + +## Loop spec + +- Loop archetype: satisfy-spec research (inventory + classify). Not an optimization loop. +- Trigger: user asked whether OpenCodex can take Cursor-runtime mechanisms from senpi, with unlimited explorer dispatch, no model-name overrides. +- Goal: evidence-bearing transfer verdict in this unit. Every comparison row cites OpenCodex `path:line` and senpi GitHub blob/commit. +- Non-goals: production `src/` edits; copying senpi protobuf wholesale; starring repos; live Cursor account mutation; spawning `cursor-agent` CLI; extracting secrets. +- Verifier: files exist under this unit; `git status` shows no production `src/` diffs from this loop; 090 table rows have both-codebase citations. +- Stop: 090 locked and wp0 criteria captured. Implementation is a later appended work-phase, not this cycle. +- Memory artifact: this directory. +- Terminal: DONE (research lock) / NOOP (no residual gaps) / NEEDS_HUMAN (ToS) / UNSAFE (native-app patching). +- Escalation: live Cursor probes, ToS/product-policy, or proto-regen risk. + +## Sources + +- OpenCodex tree: local checkout (explorers also cited `dev` `a228ed74` / GitHub `lidge-jun/opencodex`). +- senpi: `code-yeongyu/senpi` `main` SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717` (2026-08-21), files also fetched as current default-branch blobs. +- Explorer lanes (inherit parent model; no model field): Helmholtz (protocol), Planck (auth/catalog), Hypatia (exec), Leibniz (stream/usage), Pasteur (senpi protocol), Archimedes (senpi auth/catalog), Plato (exec-bridge + CLI), Ohm (overflow/RE). + +## Docs + +- 000 (this file) — unit map + later-implementation slice order. +- 001 — OpenCodex Cursor inventory. +- 002 — senpi Cursor inventory. +- 003 — protocol / transport compare. +- 004 — auth / catalog / effort / max-mode. +- 005 — exec / interactionQuery / tool pairing. +- 006 — stream completion / usage / overflow / rotation. +- 007 — CLI fallback lane. +- 090 — transfer verdict (ADOPT / ADAPT / REJECT / ALREADY-HAVE / NEEDS_HUMAN). + +## Work-phase map (dependency order, not effort) + +1. **wp0 (this cycle, docs-only):** inventories + 090 lock. Independent of later code. +2. **wp1 (010, later):** Cursor error mapping + 0-token `resource_exhausted` surface. Owner: `src/adapters/cursor/cursor-errors.ts`, `src/lib/errors.ts`, `src/adapters/cursor/transport-retry.ts`. +3. **wp2 (020, later):** `turnEnded` as application-complete + adapter stream-health. Owner: `src/adapters/cursor/live-transport.ts`, `src/adapters/cursor/protobuf-events.ts`. +4. **wp3 (030, later):** unknown-exec typed reply (`ExecClientThrow` + stream-close) and optional newer exec oneofs as refusals. Owner: `src/adapters/cursor/native-exec.ts`. Do not regenerate protobuf in the same cycle as error mapping. +5. **wp4 (040, later, optional):** live `GetUsableModels.maxMode` + richer catalog decode. Owner: `src/adapters/cursor/live-models.ts`, `src/adapters/cursor/protobuf-request.ts`, `src/adapters/cursor/discovery.ts`. + +Do not implement two slices in one B. Do not start wp1 until this research cycle D-locks 090. + +## IN / OUT + +IN: this `devlog/_plan/260822_senpi_cursor_transfer/` directory. +OUT: `src/`, `tests/`, `gui/`, `docs-site/`; senpi vendored proto copy; CLI spawn of `cursor-agent`. + +## Already-have headline + +OpenCodex is not missing a Cursor provider. It already speaks `agent.v1.AgentService/Run` over Connect, answers `interactionQuery`, owns HTTP/1 `RunSSE` fallback, conversation-keyed `usedTokens` accounting, native-exec policy, and Responses-tool suspend. senpi's newer work is mostly overflow classification, turn-end close, exec-frame completeness, and a CLI fallback lane that OpenCodex deliberately does not have. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md b/devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md new file mode 100644 index 0000000000..1f950e5026 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/001_opencodex_cursor_inventory.md @@ -0,0 +1,50 @@ +# 001 — OpenCodex Cursor inventory + +Research only. Local tree + explorer Helmholtz / Planck / Hypatia / Leibniz. + +## Layout + +`src/adapters/cursor/` owns the live protobuf adapter. Supporting files: + +- Transport: `live-transport.ts` (1443 lines), `transport.ts`, `transport-retry.ts`, `http1-bidi.ts`, `framing.ts` +- Request: `request-builder.ts`, `protobuf-request.ts`, `tool-definitions.ts` +- Events / usage: `protobuf-events.ts`, `checkpoint-store.ts`, `thread-continuity.ts`, `kv-store.ts` +- Exec: `native-exec.ts` + `native-exec-*.ts`, `exec-policy.ts`, `mcp-manager.ts`, `mcp-config.ts` +- Catalog: `discovery.ts`, `live-models.ts`, `effort-map.ts` +- Errors: `cursor-errors.ts` +- Generated proto: `gen/agent_pb.ts` +- OAuth: `src/oauth/cursor.ts` (not under adapters) +- Adapter entry: `src/adapters/cursor.ts` +- Tests: `tests/cursor-*.test.ts` (39 files) + +## Protocol + +OpenCodex posts `POST /agent.v1.AgentService/Run` as Connect proto, 5s `clientHeartbeat`, client version `cli-2026.07.08-0c04a8a`: + +```90:92:src/adapters/cursor/live-transport.ts +const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run"; +const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a"; +const HEARTBEAT_MS = 5_000; +``` + +HTTP/1 fallback exists: `RunSSE` + `BidiAppend` in `http1-bidi.ts:10-11`. First-frame timeout is 30s (`live-transport.ts:93`). After that, liveness is the Responses bridge stall watchdog (default 300s, `src/stall-timeout.ts:8`), kept alive by synthetic `heartbeat` events on swallowed progress frames (`live-transport.ts:1304-1309`). + +`turnEnded` maps to `finalizeTurnEvents` (`protobuf-events.ts:1327-1328`). Transport still waits for Connect EOF. If EOF arrives after assistant text without `turnEnded`, it synthesizes `done` (`live-transport.ts:1147-1150`). Client-tool Responses path **intentionally** ends turn 1 without waiting for `turnEnded` (`live-transport.ts:203-206`). + +Unknown `interactionQuery` replies empty with matching id so the server unblocks (`live-transport.ts:376-382`, issue #116). Web/exa queries are approved; askQuestion/switchMode rejected (`live-transport.ts:287-366`). + +## Auth / catalog + +Same Cursor PKCE poll as senpi: `loginDeepControl`, `auth/poll`, `exchange_user_api_key` (`src/oauth/cursor.ts:13-15`). After login, catalog uses stored tokens via `getValidAccessToken`. `GetUsableModels` is empty-body unary (`live-models.ts:12-14, 28`). Decode keeps **ids only** (`live-models.ts:115-131`). Static seed in `discovery.ts` is filtered by live ids; `stripCursorWirePrefix` at the comparison boundary (`discovery.ts:67-84`, issue #117). Effort is a static suffix table (`effort-map.ts`). `RequestedModel.maxMode` is hardcoded `false` (`protobuf-request.ts:963-966`). + +## Exec + +Known proto cases end at `writeShellStdinArgs` (`gen/agent_pb.ts:6886+`). Dispatcher: `native-exec.ts:550-609`. Default `nativeLocalExec` is **off**; only `"on"` authorizes local fs/shell/fetch (`exec-policy.ts:17-44`). Unknown exec returns `[]` to keep the stream alive (`native-exec.ts:605-609`). Responses `mcpArgs` are **not** executed locally (`live-transport.ts:226-246, 1236-1246`). Native exec emits `local_side_effect` before running so `invalid_argument` remint cannot replay (`live-transport.ts:1248-1252`). + +## Usage / overflow + +Checkpoint `usedTokens` is absolute context, not an output delta (`protobuf-events.ts:1233-1238`). Conversation-keyed cache: 200 entries / 60 minutes (`protobuf-events.ts:21-22`). Generated `TurnEndedUpdate` is empty (`gen/agent_pb.ts:3083-3085`), so billed cacheRead is not ingested. Generic `resource_exhausted` classifies as 429 unless an explicit size phrase wins (`cursor-errors.ts:131-163`). Transport retry never retries RE (`transport-retry.ts:25`). Conversation remint exists only for external-model `invalid_argument` (`src/adapters/cursor.ts:231-247`). Compaction uses an isolated conversation and does not store its checkpoints (`request-builder.ts:397, 443-444`; `src/server/responses/core.ts:2247-2249`). + +## OpenCodex-only keepers + +HTTP/1 RunSSE; interactionQuery matrix; fail-closed nativeLocalExec; Responses-tool suspend; JWT-sub multiauth; classified discovery errors; bounded blob KV / checkpoint store; `createTerminalSettler`. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md b/devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md new file mode 100644 index 0000000000..90589612c0 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/002_senpi_cursor_inventory.md @@ -0,0 +1,43 @@ +# 002 — senpi Cursor inventory + +Research only. senpi `main` SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. Explorers Pasteur / Archimedes / Plato / Ohm. + +## Layout + +Cursor is a first-class builtin provider, not an OpenCodex-style proxy adapter. + +- Provider: [packages/ai/src/providers/cursor.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/providers/cursor.ts) — OAuth, empty static catalog, `fetchModels` = live `GetUsableModels` +- Run client: [packages/ai/src/api/cursor-agent.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts) (~4439 lines, Node http2) +- Lazy load: `cursor-agent.lazy.ts`; Bun static register: `cursor-agent-provider.ts` +- Catalog grouping: `packages/ai/src/cursor/catalog-grouping.ts`, `model-capabilities.ts`, `selection-descriptor.ts`, `store-migration.ts` +- OAuth: [packages/ai/src/auth/oauth/cursor.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/auth/oauth/cursor.ts) +- Rotation: `packages/ai/src/api/cursor-conversation-rotation.ts` +- Overflow: `packages/ai/src/utils/overflow.ts` +- Host exec-bridge: `packages/coding-agent/src/core/cursor-exec-bridge.ts` +- CLI fallback: `packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/` +- PRs: [#905](https://github.com/code-yeongyu/senpi/pull/905) OAuth, [#910](https://github.com/code-yeongyu/senpi/pull/910) protocol, [#921](https://github.com/code-yeongyu/senpi/pull/921) CLI, [#948](https://github.com/code-yeongyu/senpi/pull/948) reasoning levels, [#1013](https://github.com/code-yeongyu/senpi/pull/1013) ANTML skip, [#1015](https://github.com/code-yeongyu/senpi/pull/1015) compact-before-rotate, [#1062](https://github.com/code-yeongyu/senpi/pull/1062) turnEnded completion + +## Protocol + +Same `AgentService/Run` Connect path, 5s client heartbeat, client version `cli-2026.07.23-e383d2b`. HTTP/2 only; ALPN-stripping proxy is fatal (no h1 fallback). `turnEnded` is the application completion signal: drain exec ≤5s, then close the client HTTP/2 stream ([cursor-agent.ts L249-254, L698-704](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L249-L254)). HTTP close without `turnEnded` is an error. Stream-health: 30s no inbound frames, 90s heartbeat/checkpoint-only. + +Unknown exec is `ExecClientThrow` + `streamClose` so the server is never left blocked. Per-exec 3s heartbeat while a handler runs (`exec-lifecycle.ts`). + +`handleServerMessage` has **no `interactionQuery` case** (open [#1026](https://github.com/code-yeongyu/senpi/issues/1026)). + +## Auth / catalog + +Same PKCE poll. Fail-fast on poll 400/401/403/410; 429 does not burn the transient budget. Catalog is fully dynamic: `models: []`, live GetUsableModels, then `normalizeCursorCatalog` grouping with `thinkingLevelMap` / `cursorReasoning` / `cursorMaxMode`. Live `maxMode` is copied onto `RequestedModel` ([reasoning-params.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/reasoning-params.ts#L8-L20)). + +## Exec + +Host-injected `CursorExecHandlers` map frames onto senpi tools (`read`/`bash`/`edit`/`write`/`grep`/`find`/`ls` + MCP). Exec-synthesized tool calls are stamped `kCursorExecResolved` so the agent loop does not re-run them. Pi exec family (proto 45–51) is dispatched. Computer-use / canvas / subagents / conversation-search are typed refusals (PR 910). CLI lane is a **separate** spawn of official `cursor-agent -p --output-format stream-json`; tools are display-only; `--force` needs `noApprovalAcknowledgedAt`; kill switch is verbatim `enabled: false`. + +## Overflow + +0-token `resource_exhausted` is payload overflow for compact-before-rotate (`overflow.ts` `isCursorPayloadResourceExhausted`). First 0-token RE is **surfaced** so session compaction can run; later ones rotate the wire id up to 3 times (`cursor-conversation-rotation.ts`). Billed `turnEnded` cacheRead that dwarfs checkpoint `usedTokens` (>3×) is ignored ([cursor-agent.ts L3544](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3544)). ANTML text-tool recovery is skipped for `api === "cursor-agent"` (PR #1013). Compact while a Cursor Run is live is skipped (#984). Open: [#1043](https://github.com/code-yeongyu/senpi/issues/1043) compact-reload restores full toolResult bodies. + +## Deliberately not ported (senpi) + +Computer use, subagents, Cursor-managed background shells (typed refuse; OpenCodex actually implements bg shell when native exec is on), canvas, smart-mode classifier, conversation search, Kimi-K3 thinking replay, proxy tunneling (PR 910). + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md new file mode 100644 index 0000000000..c6883b3513 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/003_protocol_compare.md @@ -0,0 +1,35 @@ +# 003 — Protocol / transport compare + +Helmholtz + Pasteur. senpi SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. + +## Same + +Both speak `agent.v1.AgentService/Run` over HTTP/2 Connect (`application/connect+proto`, `connect-protocol-version: 1`, Bearer, `x-ghost-mode: true`, `x-cursor-client-type: cli`). Both write a 5s `clientHeartbeat`. Both rebuild `rootPromptMessagesJson` as the model prompt and treat `turns[]` as display metadata. Both implement blob KV `getBlobArgs`/`setBlobArgs`. + +OpenCodex: + +```90:92:src/adapters/cursor/live-transport.ts +const CURSOR_RUN_PATH = "/agent.v1.AgentService/Run"; +const CURSOR_CLIENT_VERSION = "cli-2026.07.08-0c04a8a"; +const HEARTBEAT_MS = 5_000; +``` + +senpi: [cursor-agent.ts L522-547, L746-747](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L522-L547). + +## Different + +| Topic | OpenCodex | senpi | +|---|---|---| +| Client version | `cli-2026.07.08-0c04a8a` | `cli-2026.07.23-e383d2b` | +| HTTP/1 | `RunSSE` + `BidiAppend` (`http1-bidi.ts:10-11`) | HTTP/2-only; ALPN strip is fatal | +| Session header | sends `x-session-id` | does not | +| Completion | `turnEnded` finalizes mapper; transport waits for EOF; may synthesize `done` | `turnEnded` closes client HTTP/2 after ≤5s exec drain | +| Mid-turn health | 30s first-frame only; then 300s bridge stall | 30s silence / 90s heartbeat-only inside the adapter | +| Abort owner | `failAndClear` + `createTerminalSettler` | `settleH2` | +| Exec heartbeat | none (types exist) | 3s per-exec heartbeat | +| Blob store | TTL / 4096 / 64MiB | unbounded per-conversation Map | + +## Transfer suspicion + +High: close HTTP/2 on `turnEnded` (frozen turns until bridge 300s). Medium: heartbeat-only stall fail. Low: bump client version without a live probe. Do not copy senpi's unbounded blob Map. Keep OpenCodex HTTP/1 fallback. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md new file mode 100644 index 0000000000..00e50f16ad --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/004_auth_catalog_compare.md @@ -0,0 +1,29 @@ +# 004 — Auth / catalog / effort / max-mode + +Planck + Archimedes. + +## Auth — ALREADY-HAVE + +Same three URLs and PKCE params (`challenge`, `uuid`, `mode=login`, `redirectTarget=cli`). + +OpenCodex `src/oauth/cursor.ts:13-15, 78-85`. senpi [oauth/cursor.ts L17-19, L123-130](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/auth/oauth/cursor.ts#L17-L19). + +Delta worth a small adapt: senpi fail-fasts poll 400/401/403/410 and does not spend the transient budget on 429. OpenCodex retries any non-ok as consecutive errors up to 3 (`src/oauth/cursor.ts:121-148`). OpenCodex-only keepers: JWT `sub`/`email` multiauth, 15s refresh timeout, 429/5xx refresh retry. + +Login catalog refresh: senpi auto `fetchModels` after `/login cursor`. OpenCodex clears model cache and tells the operator to `ocx sync` (`src/oauth/index.ts:1234`, `src/oauth/login-cli.ts:95`). + +## Catalog — different-shape + +OpenCodex: static seed + live id filter + `stripCursorWirePrefix` (`discovery.ts:67-84`). Decode keeps ids only (`live-models.ts:4-6`). Empty 0-byte GetUsableModels body is a Bun HTTP/2 requirement (`live-models.ts:12-14`). + +senpi: no static baseline; live GetUsableModels is the catalog; grouping produces `thinkingLevelMap` / `cursorReasoning` / `legacyAliases` ([providers/cursor.ts L10-17](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/providers/cursor.ts#L10-L17), [catalog-grouping.ts L19-31](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/cursor/catalog-grouping.ts#L19-L31)). Decode keeps `maxMode`, display name, `thinkingDetails` ([cursor-agent.ts L4354-4369](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L4354-L4369)). + +## Effort / max-mode + +OpenCodex flattens Codex effort onto a static suffix table (`effort-map.ts:96-108`). Grok Fast is parameterized (`request-builder.ts:182-204`). `RequestedModel.maxMode` is always `false` (`protobuf-request.ts:963-966`; the 934-937 window is debug logging, not maxMode). + +senpi copies live `cursorMaxMode` onto the wire ([reasoning-params.ts L8-20](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/reasoning-params.ts#L8-L20)). Family-specific parameters (Claude thinking/context/effort, GPT extra-high, etc.) come from a captured AvailableModels capability table, not from GetUsableModels fields. + +## Transfer suspicion + +Medium-high: honor live `maxMode` instead of hardcoding false (proto field already exists at `gen/agent_pb.ts:2617`). Medium: fail-fast OAuth poll. Low/product: replace static seed with fully dynamic catalog (OpenCodex still needs logged-out fallback and `auto-{cost,balance,intelligence}` router ids). Do not copy senpi's 204-id alias JSON wholesale. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md new file mode 100644 index 0000000000..f196a95bfb --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/005_exec_compare.md @@ -0,0 +1,29 @@ +# 005 — Exec / interactionQuery / tool pairing + +Hypatia + Plato + Pasteur. + +## Architecture mismatch (do not ignore) + +senpi is a **host**. Exec frames map onto senpi tools via `CursorExecHandlers`, then the agent loop skips `kCursorExecResolved` blocks ([cursor-exec-bridge.ts L1-16](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/cursor-exec-bridge.ts#L1-L16), [block-symbols.ts L40-49](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/block-symbols.ts#L40-L49)). + +OpenCodex is a **Responses proxy**. Exec either runs locally inside the adapter (only if `nativeLocalExec: "on"`) or is rejected. Codex-owned tools travel as `opencodex-responses` MCP and are **not** executed on the exec channel (`live-transport.ts:226-246`). Copying senpi's host-tool bridge would invert OpenCodex's trust model. + +## Frames + +OpenCodex known cases end at `writeShellStdinArgs` (`gen/agent_pb.ts:6886+`). Dispatcher `native-exec.ts:550-609`. Default policy off (`exec-policy.ts:17-44`). + +senpi additionally dispatches Pi family 45–51 and answers newer oneofs with typed refusals (mcpState, hooks, subagents, canvas, conversation search). Unknown/unset: `ExecClientThrow` + `streamClose` ([cursor-agent.ts L1288-1316](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L1288-L1316)). OpenCodex unknown: empty `[]` (`native-exec.ts:605-609`, #116). That is the stall class senpi refused. + +OpenCodex-only: real background shell / fetch / optional computer-use when native exec is on. senpi refuses those. + +## interactionQuery + +OpenCodex answers immediately (`live-transport.ts:287-382, 1256-1269`): createPlan success; ask/switch reject; web/exa approve; setupVm + unknown empty. senpi has **no** interactionQuery branch ([cursor-agent.ts L922-946](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L922-L946), issue #1026). Do not copy senpi here. + +## Pairing / double-exec + +OpenCodex: `local_side_effect` before native exec (`live-transport.ts:1248-1252`); `completedToolCalls` for Responses mapper idempotency (`protobuf-events.ts:1052-1056`). senpi: `kCursorExecResolved` so the **agent loop** does not re-run host tools. Different layer. Only needed if OpenCodex starts synthesizing native exec as Codex-visible tool calls. + +## Transfer suspicion + +High: unknown-exec typed reply + stream-close (without enabling local fs). Medium: proto refresh to name Pi/mcpState/hook frames **as typed refusals**, not as implementations. Reject: host-tool bridge, enabling nativeLocalExec by default, copying senpi's missing interactionQuery. diff --git a/devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md b/devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md new file mode 100644 index 0000000000..91887fd0f3 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/006_stream_overflow_compare.md @@ -0,0 +1,40 @@ +# 006 — Stream completion / usage / overflow / rotation + +Leibniz + Ohm. senpi SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. + +## usedTokens — ALREADY-HAVE + +Both treat checkpoint `usedTokens` as absolute conversation window, not additive output. + +OpenCodex `protobuf-events.ts:1233-1238`. senpi [cursor-agent.ts L3566-3582](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3566-L3582). OpenCodex tests lock 10000→10300 not 20300 (`tests/cursor-protobuf-events.test.ts`). + +OpenCodex cache: 200 entries / 60 minutes (`protobuf-events.ts:21-22`). Older memory said 30m/256; current code wins. + +## cacheRead — senpi-only billed split + +senpi reads billed `turnEnded` fields and drops cacheRead when `cacheRead > liveUsed * 3` ([cursor-agent.ts L3516-3547](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3516-L3547); [cursor-usage.test.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/test/cursor-usage.test.ts)). Compact threshold uses local estimate if billed > 8× and estimate ≥ 50k. + +OpenCodex generated `TurnEndedUpdate` is `{}` (`gen/agent_pb.ts:3083-3085`), so billed cacheRead cannot spike totals. Do not add billed fields without the 3×/8× guards. Live wire still emitting those int64s is **unverified** this cycle (client versions differ). + +## 0-token resource_exhausted — inverted + +OpenCodex: generic RE is 429 unless an explicit size phrase wins (`cursor-errors.ts:131-163`; `tests/cursor-errors.test.ts:15-17` expects bare `Connect error resource_exhausted: Error` → rate limit). Retry layer never retries RE (`transport-retry.ts:25`). + +senpi: 0-token RE is payload overflow for compact-before-rotate (`overflow.ts` `isCursorPayloadResourceExhausted`, [L211-222](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/overflow.ts#L211-L222)). First failure is **surfaced** so session compact can run; later ones rotate wire id ≤3 ([cursor-conversation-rotation.ts L34-46](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-conversation-rotation.ts#L34-L46), [cursor-agent.ts L789-812](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L789-L812)). Stale senpi comment still says 0-token RE is rate-limit; code does the opposite. + +OpenCodex remint is only external-model `invalid_argument` (`src/adapters/cursor.ts:231-247`). Compaction is client-driven and isolated (`request-builder.ts:397, 443-444`). Architectural bound: OpenCodex cannot copy senpi `AgentSession._runPrePromptCompaction`. Transfer is **HTTP mapping** so Codex compact can fire, plus optional remint after that, not an in-adapter compact loop. + +## turnEnded hang — senpi newer + +#1062: Cursor can leave HTTP/2 open after content is done. senpi closes the client stream on `turnEnded`. OpenCodex waits for EOF / 300s bridge stall. First-frame 30s is not a mid-turn health watchdog. + +OpenCodex-only: synthesize `done` on clean EOF after assistant text without `turnEnded` (`live-transport.ts:1147-1150`). senpi fails that case. Comment/test tension: `tests/cursor-eof-terminal.test.ts` vs hardening tests vs transport `settleFinish`. + +## ANTML / interactionQuery + +ANTML skip is senpi-only because senpi has Claude-name text-tool recovery. OpenCodex has zero ANTML hits — already-have by absence. interactionQuery is OpenCodex-only (senpi gap #1026). + +## #1043 toolResult reload + +senpi compact reloads full jsonl bodies (still open). OpenCodex truncates toolResult blobs for **external-model replay budget** 512KiB / 192 roots (`protobuf-request.ts:64-72, 140-143`), not as a post-compact native admission pass. Medium residual if native-model full replay after Codex compact still ships verbatim tool results. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md b/devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md new file mode 100644 index 0000000000..fcc97cf194 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/007_cli_fallback.md @@ -0,0 +1,20 @@ +# 007 — CLI fallback lane + +Plato. senpi SHA `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. PR [#921](https://github.com/code-yeongyu/senpi/pull/921). + +## What senpi added + +`cursor-cli-oauth` is a **documented fallback**, never a replacement for native `cursor` ([AGENTS.md L1-5](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/AGENTS.md#L1-L5)). + +It spawns official `cursor-agent -p --output-format stream-json --stream-partial-output --trust` ([spawn-args.ts L18-34](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/spawn-args.ts#L18-L34)). CLI tools are display-only. `--force` requires `noApprovalAcknowledgedAt` ([guardrails.ts L136-154](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/guardrails.ts#L136-L154)). Kill switch: verbatim `enabled: false` outranks stored accounts. Implicit fallback is refused while force-ack is pending (`index.ts:77-85`). File-store HOMEs, `AGENT_CLI_CREDENTIAL_STORE=file`, 130 KB prompt cap, process-group kill. senpi remains context owner for usage numbers. + +## What OpenCodex has + +Native protobuf only. OAuth comment: no dependency on a local Cursor IDE/CLI (`src/oauth/cursor.ts:1-4`). Repo `rg` has no `cursor-agent` spawn, `stream-json`, or `cursor-cli-oauth`. Native-exec kill is `nativeLocalExec` default off (`exec-policy.ts:17-45`) — different layer. + +## Transfer class + +**REJECT for OpenCodex core.** OpenCodex is a Codex/Claude proxy. Spawning Cursor's own agent CLI would fork tool execution out of Codex sandbox/approvals, add a binary dependency, and spend Cursor quota through a second harness. If a fallback is ever wanted, it is a separate opt-in product surface (NEEDS_HUMAN), not an adapter default. + +Do not confuse this with native protobuf hardening. Native-first is the senpi recommendation too. + diff --git a/devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md b/devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md new file mode 100644 index 0000000000..1b4c080938 --- /dev/null +++ b/devlog/_plan/260822_senpi_cursor_transfer/090_transfer_verdict.md @@ -0,0 +1,52 @@ +# 090 — Transfer verdict + +Locked from explorer reports + local reads. senpi `a5eed44536f3024c5740dc3dfff4ffe0bb08b717`. No production code in this cycle. + +Class keys: ADOPT (port the mechanism), ADAPT (same idea, OpenCodex-shaped), REJECT (wrong product/trust model), ALREADY-HAVE, NEEDS_HUMAN (policy), UNSAFE (do not recommend). + +## Table + +| ID | Mechanism | Class | OpenCodex owner | senpi source | Residual risk | +|---|---|---|---|---|---| +| T01 | Bare 0-token `resource_exhausted` mapped as 429 | **ADAPT** | `src/adapters/cursor/cursor-errors.ts:131-163`, `src/lib/errors.ts`, `tests/cursor-errors.test.ts:15-17` | [overflow.ts L211-222](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/overflow.ts#L211-L222), issues #1009/#1036 | Must not reclassify quota RE as overflow. Codex compact must actually fire; if not, remint is a second step. | +| T02 | Surface-first then rotate conversationId | **ADAPT** | `src/adapters/cursor.ts:231-247` (today only external invalid_argument) | [cursor-conversation-rotation.ts L34-46](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-conversation-rotation.ts#L34-L46) | Do not persist unbounded maps. Cap + migrate usage cache via existing `rekey`. | +| T03 | Close HTTP/2 on `turnEnded` after exec drain | **ADOPT** | `src/adapters/cursor/live-transport.ts:1132-1154`, `protobuf-events.ts:1327-1328` | [cursor-agent.ts L249-254, L698-704](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L249-L254) PR #1062 | Must preserve Responses client-tool path that **intentionally** ends without turnEnded (`live-transport.ts:203-206`). | +| T04 | Adapter heartbeat-only stall fail (30s/90s) | **ADAPT** | `live-transport.ts:92-93` first-frame only; `src/stall-timeout.ts:8` 300s | [cursor-agent.ts L592-610](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L592-L610) | Do not fight synthetic progress heartbeats that keep the bridge alive. Scope to inbound-frame silence, not "no assistant text". | +| T05 | Unknown exec empty `[]` vs throw+close | **ADAPT** | `native-exec.ts:605-609` | [cursor-agent.ts L1288-1316](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L1288-L1316) | Empty reply was the #116 stream-kill fix. Prefer typed `ExecClientThrow` + stream-close **without** re-throwing into `failAndClear`. Live stall vs empty is unverified. | +| T06 | Live `GetUsableModels.maxMode` on the wire | **ADAPT** | `live-models.ts:115-131` decode keeps ids only; `gen/agent_pb.ts:2617` is catalog `ModelDetails.maxMode`; wire field is `RequestedModel.maxMode` at `gen/agent_pb.ts:2665-2667`; hardcode `protobuf-request.ts:963-966` | [reasoning-params.ts L8-20](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/reasoning-params.ts#L8-L20) | Product: 1M windows / quota. Needs a live probe before claiming user-visible gain. Keep static seed + auto router ids. | +| T07 | OAuth poll fail-fast 400/401/403/410 | **ADAPT** | `src/oauth/cursor.ts:121-148` | [oauth/cursor.ts L165-178](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/auth/oauth/cursor.ts#L165-L178) PR #905 | Small. Keep OpenCodex refresh retry / JWT accountId. | +| T08 | Per-exec 3s heartbeat | **ADAPT** | `ExecClientHeartbeat` exists in `gen/agent_pb.ts`; stream-close bytes at `native-exec-common.ts:41-49`; no heartbeat writer in `native-exec.ts` | [exec-lifecycle.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent/exec-lifecycle.ts) | Only if long native-exec stays enabled. Default native exec is off. | +| T09 | Billed turnEnded cacheRead 3× clamp | **ADAPT** (only with proto decode) | `TurnEndedUpdate` is `{}` `gen/agent_pb.ts:3083-3085` | [cursor-agent.ts L3516-3547](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3516-L3547) PR #985 | Do not add billed fields without the clamp. Live wire unverified vs OCX client version. | +| T10 | Newer exec oneofs as typed refusals | **ADAPT** | `gen/agent_pb.ts:6886+` oneof ends at `writeShellStdinArgs`; dispatcher `native-exec.ts:550-609` | [cursor-agent.ts L1655-2010](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L1655-L2010) PR #910 | Proto regen is its own unit. Until then, T05 covers unknown frames. Do not implement Pi tools in the proxy. | +| T11 | Host-tool exec-bridge onto Codex tools | **REJECT** | `native-exec.ts` + `exec-policy.ts:17-44` fail-closed | [cursor-exec-bridge.ts L1-16](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/cursor-exec-bridge.ts#L1-L16) | Wrong architecture. OpenCodex already surfaces Responses tools; native fs default-off is the trust gate. | +| T12 | `cursor-agent` CLI fallback lane | **REJECT** (core) / **NEEDS_HUMAN** (optional product) | none; `src/oauth/cursor.ts:1-4` | [cursor-cli-oauth/AGENTS.md](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/extensions/builtin/cursor-cli-oauth/AGENTS.md) PR #921 | Binary dep, `--force` spends Cursor tools outside Codex sandbox. | +| T13 | Fully dynamic catalog, drop static seed | **REJECT** | `discovery.ts:76-88` seed filter; `discovery.ts:90-104` router ids; `src/codex/catalog/provider-fetch.ts:1197` live gather | [providers/cursor.ts L10-17](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/providers/cursor.ts#L10-L17) | OpenCodex needs logged-out catalog and `auto-*` router models. T06 is the live-field adapt. | +| T14 | thinkingLevelMap / 204-id grouping | **REJECT** for now | `effort-map.ts:96-108` static tiers; `request-builder.ts:187-204` suffix flatten | [catalog-grouping.ts](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/cursor/catalog-grouping.ts) PR #948 | Codex picker already maps effort. Revisit only if live ids stop matching suffixes. | +| T15 | ANTML skip on cursor-agent | **ALREADY-HAVE** (by absence) | no ANTML in `src/` | [tool-call-middleware/index.ts L48-54](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/tool-call-middleware/index.ts#L48-L54) PR #1013 | Only if OCX later adds Claude-name text-tool recovery on Cursor models. | +| T16 | interactionQuery replies | **ALREADY-HAVE** (OpenCodex ahead) | `live-transport.ts:287-382` | missing; [#1026](https://github.com/code-yeongyu/senpi/issues/1026) | Do not copy senpi. | +| T17 | Absolute `usedTokens` cache | **ALREADY-HAVE** | `protobuf-events.ts:1233-1238` | [cursor-agent.ts L3566](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L3566) | Keep. | +| T18 | Compact isolation / skip mid-run compact | **ALREADY-HAVE** (different-shape) | `request-builder.ts:397, 443-444`; `responses/core.ts:2247-2249` | [agent-session.ts L1293-1296](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/coding-agent/src/core/agent-session.ts#L1293-L1296) #984 | Keep OCX isolated-conversation approach. | +| T19 | HTTP/1 RunSSE fallback | **ALREADY-HAVE** (OpenCodex-only) | `http1-bidi.ts:10-11` | [cursor-agent.ts L378-381](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L378-L381) | Keep. | +| T20 | Native-app / Safe Storage patching | **UNSAFE** | n/a | n/a | Out of scope. Prior ocx-cursor probe already forbade this. | +| T21 | Unofficial Cursor protocol ToS | **NEEDS_HUMAN** | whole adapter | whole provider | Both projects already ship it. No new disclosure in this unit. | +| T22 | Copy senpi protobuf / unbounded blob maps | **REJECT** | bounded KV/checkpoint (`native-exec.ts:81-92`, `checkpoint-store.ts:30`) | [cursor-agent.ts L314](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L314); [#1024](https://github.com/code-yeongyu/senpi/issues/1024) | Keep OCX bounds. | +| T23 | Overflow compact `keepRecentTokens: 0` | **REJECT** for adapter | Codex owns compact | [overflow.ts L244-251](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/utils/overflow.ts#L244-L251) | Only relevant if Codex compact keeps a large tail; that is a Codex-side setting, not ocx Cursor. | +| T24 | Fail EOF without `turnEnded` | **ADAPT** (careful) | `live-transport.ts:1147-1150` synthesizes done | [cursor-agent.ts L477-478](https://github.com/code-yeongyu/senpi/blob/a5eed44536f3024c5740dc3dfff4ffe0bb08b717/packages/ai/src/api/cursor-agent.ts#L477-L478) | Conflicts with OCX client-tool suspend and some hardening tests. Fold into T03, do not land as a blanket fail. | + +## Recommended later implementation order + +Matches `000_plan.md` wp1–wp4: + +1. T01 error mapping (highest user-visible: overflow vs 429). +2. T03 + T04 turn-end / stream health (protocol hang). +3. T05 unknown-exec typed reply; T10 only with a dedicated proto unit. +4. T06 maxMode + T07 poll fail-fast (catalog/auth polish). + +Do not start T12. Do not start T11. + +## Residual unknowns (not blockers for this research lock) + +- Whether live `api2.cursor.sh` still emits billed `turnEnded` int64s against OCX client `cli-2026.07.08-0c04a8a`. +- Whether mapping 0-token RE to overflow/400 makes Codex auto-compact, or still needs remint (T02). +- Whether empty unknown-exec replies currently stall modern Pi frames on OCX's proto. +- Native-model toolResult size after Codex compact (#1043 analogue). diff --git a/src/adapters/cursor/cursor-errors.ts b/src/adapters/cursor/cursor-errors.ts index f2e13c578c..39f0fc5a8a 100644 --- a/src/adapters/cursor/cursor-errors.ts +++ b/src/adapters/cursor/cursor-errors.ts @@ -112,6 +112,34 @@ export function isCursorInvalidArgumentError(value: unknown): boolean { } const QUOTA_RATE_CUES = ["too many requests", "quota", "rate limit", "rate-limit", "throttl"]; +/** + * A bare `resource_exhausted` end-stream with no detail beyond a generic error wrapper + * ("Error" or empty tail) and zero tokens billed is the shape Cursor's backend emits when + * the request payload exceeded its context window — not when quota ran out (senpi #1009, + * #1036: same wording, two causes). Quota rejections always carry an explicit rate cue + * ("too many requests", "quota exhausted"), so the ABSENCE of those cues plus the + * absence of a size phrase means payload overflow. Classifying it as 429 makes Codex + * back off instead of compacting, which burns retries on an unfixable-by-retry failure. + */ +const BARE_RE_TAILS = new Set(["error", "", "resource_exhausted", "resource exhausted"]); + +export function isCursorZeroTokenResourceExhausted(lowerMessage: string): boolean { + if (!lowerMessage.includes("resource_exhausted") && !lowerMessage.includes("resource exhausted")) return false; + // Any explicit quota/rate cue wins: this is a real 429. + if (QUOTA_RATE_CUES.some(cue => lowerMessage.includes(cue))) return false; + // An explicit size phrase also wins (already handled by the existing classifier). + if (isCursorRequestTooLargeDetail(lowerMessage)) return false; + // Extract the tail after the resource_exhausted marker. If it names a specific + // non-quota, non-size cause, this is NOT bare overflow. + const idx = Math.max( + lowerMessage.indexOf("resource_exhausted"), + lowerMessage.indexOf("resource exhausted"), + ); + const tail = lowerMessage.slice(idx + "resource_exhausted".length).trim().replace(/^[:\s]+/, "").trim(); + if (!BARE_RE_TAILS.has(tail)) return false; + return true; +} + const REQUEST_TOO_LARGE_PATTERNS: (string | RegExp)[] = [ "tool catalog too large", "tool registration too large", @@ -158,9 +186,12 @@ export function classifyCursorError(message: string): string { // client-fixable 400; everything else surfaces as a 429 so Codex backs off // instead of hammering retries (live evidence: 6x 400 retry storm, devlog // 260723_cursor_context_continuity/000_plan.md). - return isCursorRequestTooLargeDetail(lower) - ? "Cursor resource limit exceeded" - : "Cursor rate limit exceeded"; + if (isCursorRequestTooLargeDetail(lower)) return "Cursor resource limit exceeded"; + // A bare resource_exhausted with no quota cue and no size phrase is payload + // overflow, not rate limiting. Classifying it as 429 makes Codex back off on a + // failure that only compaction can fix (senpi #1009 / #1036; research unit T01). + if (isCursorZeroTokenResourceExhausted(lower)) return "Cursor context limit exceeded"; + return "Cursor rate limit exceeded"; } if ( diff --git a/src/lib/errors.ts b/src/lib/errors.ts index c5523712dc..a7bbdb71e9 100644 --- a/src/lib/errors.ts +++ b/src/lib/errors.ts @@ -162,11 +162,16 @@ export function classifyError(status: number, type: string, message: string): Oc return { message, type: "invalid_request_error", code: "context_length_exceeded" }; } // "Cursor resource limit exceeded" is emitted only for explicit request-size overflow - // details (isCursorRequestTooLargeDetail in cursor-errors.ts); quota-style resource - // exhaustion arrives as "Cursor rate limit exceeded" and falls through to 429 below. + // details (isCursorRequestTooLargeDetail in cursor-errors.ts); "Cursor context limit + // exceeded" is the bare payload-overflow shape (isCursorZeroTokenResourceExhausted); + // quota-style resource exhaustion arrives as "Cursor rate limit exceeded" and falls + // through to 429 below. if (text.includes("cursor resource limit exceeded")) { return { message, type: "invalid_request_error", code: "tool_catalog_too_large" }; } + if (text.includes("cursor context limit exceeded")) { + return { message, type: "invalid_request_error", code: "context_length_exceeded" }; + } // The Cursor adapter's classified rate-limit prefix is authoritative: its DETAIL may echo // quota wording ("... quota exhausted") that would otherwise hit the insufficient_quota // branch below and break the planned retry-with-backoff contract (WP3 review blocker 1). @@ -306,6 +311,7 @@ export function inferHttpStatusFromAdapterMessage(message: string): number { // See classifyError: this prefix now only means explicit request-size overflow (400); // quota-style Cursor resource exhaustion carries the rate-limit prefix and maps to 429. if (lower.includes("cursor resource limit exceeded")) return 400; + if (lower.includes("cursor context limit exceeded")) return 400; if ( lower.includes("resource_exhausted") || lower.includes("resource exhausted") || diff --git a/tests/cursor-errors.test.ts b/tests/cursor-errors.test.ts index 80e5310f81..3ae5ade2cd 100644 --- a/tests/cursor-errors.test.ts +++ b/tests/cursor-errors.test.ts @@ -12,9 +12,7 @@ describe("classifyCursorError", () => { expect(classifyCursorError("rate limit exceeded for model")).toBe("Cursor rate limit exceeded"); }); - test("generic resource_exhausted is quota-style rate limiting, not a too-large request", () => { - // The live retry-storm shape: no detail beyond "Error" — must map to 429 so Codex backs off. - expect(classifyCursorError("Cursor Connect error resource_exhausted: Error")).toBe("Cursor rate limit exceeded"); + test("explicit quota-cue resource_exhausted is rate limiting; bare overflow is context limit (T01)", () => { expect(classifyCursorError("resource_exhausted: too many requests")).toBe("Cursor rate limit exceeded"); expect(classifyCursorError("resource_exhausted while loading tool catalog: quota exhausted")).toBe("Cursor rate limit exceeded"); // Concurrency limits are quota shapes, not request-size overflow (a bare "limit" @@ -32,6 +30,20 @@ describe("classifyCursorError", () => { expect(classifyCursorError("resource_exhausted: request size exceeds maximum allowed limit")).toBe("Cursor resource limit exceeded"); }); + test("bare resource_exhausted with no quota cue and no size phrase is payload overflow (T01)", () => { + // senpi #1009 / #1036: a huge session hits the context window and Cursor returns a bare + // gRPC resource_exhausted end-stream with no detail. Classifying it as 429 makes Codex + // back off instead of compacting, which burns retries on an unfixable-by-retry failure. + expect(classifyCursorError("Cursor Connect error resource_exhausted: Error")).toBe("Cursor context limit exceeded"); + expect(classifyCursorError("resource_exhausted")).toBe("Cursor context limit exceeded"); + expect(classifyCursorError("resource exhausted")).toBe("Cursor context limit exceeded"); + }); + + test("explicit quota wording still maps to rate limit even without a size phrase", () => { + expect(classifyCursorError("resource_exhausted: too many requests for this model")).toBe("Cursor rate limit exceeded"); + expect(classifyCursorError("resource_exhausted while loading tool catalog: quota exhausted")).toBe("Cursor rate limit exceeded"); + }); + test("authentication / permission denied", () => { expect(classifyCursorError("unauthenticated: invalid bearer token")).toBe("Cursor authentication failed"); expect(classifyCursorError("permission_denied: account suspended")).toBe("Cursor authentication failed"); @@ -102,9 +114,10 @@ describe("safeCursorErrorMessage", () => { expect(msg).not.toContain("rate limit"); }); - test("end-to-end: quota-style resource exhaustion carries the rate-limit prefix", () => { + test("end-to-end: bare resource_exhausted carries the overflow prefix; explicit quota carries the rate-limit prefix", () => { + // Bare resource_exhausted is payload overflow (T01): the 400-class prefix lets Codex compact. expect(safeCursorErrorMessage("Cursor Connect error resource_exhausted: Error")) - .toContain("Cursor rate limit exceeded"); + .toContain("Cursor context limit exceeded"); expect(safeCursorErrorMessage("resource_exhausted: too many requests")) .toContain("Cursor rate limit exceeded"); expect(safeCursorErrorMessage("resource_exhausted while loading tool catalog: quota exhausted"))