diff --git a/devlog/_plan/260826_cursor_responses_gap/000_plan.md b/devlog/_plan/260826_cursor_responses_gap/000_plan.md new file mode 100644 index 0000000000..748f3be548 --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/000_plan.md @@ -0,0 +1,124 @@ +# 260826 cursor responses gap — unit plan (P artifact, docs-only cycle) + +Goal: document what the cursor adapter still lacks to serve as a real Codex +Responses backend, seeded by thread 01a03beb-a886-74f2-83be-5bf998f9fa4a +("Test apply_patch 적용", cursor-routed Codex app session) and measured by a +live probe campaign against `cursor/grok-4.6` through the local proxy +(port 10100, opencodex 2.32.1-preview.20260825). + +## Loop-spec header + +- Archetype: spec-satisfaction (documentation unit; verifier = docs exist + + probe evidence recorded + src/ untouched). +- Trigger: user request to analyze cursor incompleteness with repeated + grok-4.6 probing and record it in devlog. +- Goal: severity-ranked gap catalog with file:line citations + >=10 probe + evidence rows. +- Non-goals: fixing the adapter, provider config changes, issues/PRs, pushes. +- Verifier: `ls devlog/_plan/260826_cursor_responses_gap` (docs), + `git status --short src/` (must be empty), probe-count grep in 010 doc. + All three run and read the change target (this unit's files / src tree). +- Stop: all three docs written and consistent; bounds = ~40min probing, + <=30 live probes, <=3 retries per probe class. +- Memory artifact: this unit. +- Terminal outcomes: DONE / BLOCKED (cursor pool refuses all probes) / + BUDGET_EXHAUSTED (probe shortfall stated) / NEEDS_HUMAN. +- Escalation: cursor account/token actions (rotation, login) are user-owned. + +## Seed-thread symptom list (evidence base for 001) + +From codex://threads/01a03beb-a886-74f2-83be-5bf998f9fa4a (full turn read): + +1. Every reasoning block restarts with "Continuing from previous + conversation / 이전 대화에서 이어서" — the model re-orients every tool + round-trip as if the conversation was replayed, repeats working-directory + checks 3+ times, and re-announces the same commentary. +2. All commandExecution items report `durationMs: 0`. +3. Model self-reports "previous tool results were garbled" and "the exec + tool only returned a working directory check" — tool output loss or + truncation on the wire. +4. apply_patch first attempt failed: "first line lacked the required + `*** Begin Patch` header" (decorated/mangled envelope emitted by the + cursor-trained model). +5. Repeated shell quoting failures (python3 -c with nested quotes) — model + behavior, but amplified by lost tool feedback. +6. Task that should be ~3 tool calls took 179s and ~15 tool rounds. + +## File-change map (docs only) + +- ADD 000_plan.md (this doc). +- ADD 001_seed_thread_failure_catalog.md — symptom -> code-path map with + file:line into src/adapters/cursor/* and src/server/responses/core.ts; + UNKNOWN rows carry the stated evidence gap. +- ADD 010_probe_campaign.md — decade doc for wp2: probe matrix (8 classes), + per-probe evidence rows appended during wp2. +- ADD 020_gap_summary.md — decade doc for wp3: severity-ranked gaps. +- OUT of scope: any file outside this unit directory. + +## Probe matrix (consumed by wp2) + +| # | Class | Method | +|---|-------|--------| +| P1 | plain completion | curl /v1/responses stream=false | +| P2 | streaming | curl stream=true, inspect SSE event sequence | +| P3 | single tool call | curl with one function tool, check call fidelity | +| P4 | multi tool call | two tools, parallel-call handling | +| P5 | tool-result round trip | 2-turn: function_call_output back in input | +| P6 | apply_patch freeform | custom tool shaped like Codex apply_patch | +| P7 | multi-turn continuity | previous_response_id + history replay | +| P8 | reasoning handling | reasoning items in replayed input | +| P9 | parallel/concurrent | 3 simultaneous requests | +| P10 | native subagent | spawn_agent model=cursor/grok-4.6 real task | +| P11 | checkpoint reuse | >=3 turns, tool results interleaved, same thread — exercises trailing_tool_result invalidation (checkpoint-store.ts) | +| P12 | tool-output stress | large (>=64KB), multi-line, ANSI/unicode tool result payloads round-tripped | +| C1 | cross-provider control | replay P5/P7/P8 through xai/grok-4.6 (same base model, different adapter) | +| C2 | cross-model control | cursor/claude-opus-5 and cursor/gemini-3.7-flash through same adapter | +| S1 | subagent fleet (parallel) | parallel spawn_agent dispatches; sol medium reviewers + cursor/grok-4.6 workers side by side | +| S2 | subagent plugin surface | cursor-model subagents driving plugin/tool surfaces (computer-use screenshot-read, browser read, exec/apply_patch) — full tool-catalog exposure test | + +Each probe records: request shape, HTTP/status, response/stream behavior, +usage block, pass/fail, raw snippet. Repeats within bounds count as extra +probes toward the >=10 minimum. + +### Audit fold-back (round 1, GO-WITH-FIXES blockers=4) + +1. (High) Control probes added: C1 cross-provider (xai/grok-4.6 live in + catalog, verified) and C2 cross-model — separates adapter gaps from + grok-4.6 training behavior. +2. (High) Subagent fence: P10/S1/S2 subagent tasks are pinned read-only or + to `mktemp -d` scratch workspaces; no writes outside scratch + this + devlog unit. Plugin-surface tests (S2) use observation-only actions + (screenshot/read); no destructive or account-mutating plugin calls. +3. (Medium) Code-path anchors corrected: primary surfaces are + protobuf-request.ts:225-260 (root replay flattening + reasoning drop for + external wire models), protobuf-events.ts:702/:896 (apply_patch grammar + repair + structured-edit conversion, #1017), checkpoint-store.ts:11-27 + (TTL/invalidation), tool-result-normalize.ts, native-exec-shell.ts:136. + message-mapper.ts / thread-continuity.ts are thin and demoted to + secondary references. durationMs:0 is plausibly Codex-client-side — + 001 carries it as UNKNOWN with the evidence gap stated. +4. (Medium) Probe-count verifier is now concrete: + `rg -c '^\| (P|C|S)[0-9]' devlog/_plan/260826_cursor_responses_gap/010_probe_campaign.md` + must report >= 10 evidence rows (pass condition), and each row carries a + pass/fail column. + +### User scope addition (mid-A, 2026-08-26) + +The user extended wp2: dispatch sol medium subagents in parallel, run cursor +models themselves as subagents, and exercise plugin surfaces (Computer use +et al.) across the board — captured as S1/S2 above. Rate-limit spend on the +user's Cursor account is bounded by the existing <=30-probe budget; probe +failures from quota/rotation are recorded as evidence, not retried past 3. + +## Accept criteria (testable) + +- c1: every symptom row in 001 names file:line or UNKNOWN + gap. +- c2: >=10 probe rows in 010 spanning all classes P1-P10. +- c3: 020 ranks gaps by severity with evidence pointers. +- c4: numbered filenames only; `git status --short src/` empty at close. + +## Conditional paths + +Docs-only cycle: no production conditional paths are added, so +C-ACTIVATION-GROUNDING-01 rows are N/A; probe classes themselves are the +activation scenarios for wp2 evidence. diff --git a/devlog/_plan/260826_cursor_responses_gap/001_seed_thread_failure_catalog.md b/devlog/_plan/260826_cursor_responses_gap/001_seed_thread_failure_catalog.md new file mode 100644 index 0000000000..63465463a3 --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/001_seed_thread_failure_catalog.md @@ -0,0 +1,122 @@ +# 001 — Seed-thread failure catalog (thread 01a03beb-a886-74f2-83be-5bf998f9fa4a) + +Seed: Codex app thread "Test apply_patch 적용", cwd +`/Users/jun/Downloads/hellobot-detail-studio`, single turn, 179.4s, +~15 tool rounds for a task that natively takes ~3. Model route: +`cursor/grok-4.6` through the local proxy. Full turn read on 2026-08-26. + +Verdict legend: ADAPTER = opencodex cursor adapter mechanism; MODEL = +grok-4.6/cursor-training behavior; CLIENT = Codex app/client-side; +UNKNOWN = evidence gap stated. + +## S1 — Per-round context re-orientation ("Continuing from previous conversation") + +Evidence: every reasoning block in the seed thread restarts with +"Continuing from the previous conversation / 이전 대화에서 이어서"; the +model re-checked `pwd`/workspace state at least 4 separate times and +re-announced near-identical commentary each round. + +Mechanism (ADAPTER, primary): + +- src/adapters/cursor/protobuf-request.ts:225-260 — history replay for + external wire models flattens prior turns into root text blobs: + user/developer messages as text parts, assistant text via + `assistantRootText(message, !externalModel)` — i.e. for external models + (grok) hidden reasoning is intentionally NOT replayed — and tool results + as `[Tool Result]\n...` assistant-visible text only when + `cursorNeedsExternalToolContinuation` says so. Tool CALLS are never + replayed. The model therefore sees a text transcript, not its own + structured turn state, and treats each round as a resumed/foreign + conversation. +- src/adapters/cursor/checkpoint-store.ts:11-27 — checkpoint continuity + exists (TTL 15min, 64 entries, 16MB) but `trailing_tool_result` is an + invalidation reason: src/adapters/cursor/request-builder.ts:430-443 + (lineageMismatch) returns `trailing_tool_result` when the last message + is a toolResult and the checkpoint covers the whole message list — + exactly the state after every tool round-trip. When invalidated, + request-builder.ts:459-461 falls back to `continuationMode: + "full-replay"` — the flattened-text path above. Whether every + tool round actually falls back is the P11 probe question. +- src/server/responses/core.ts:529-537 — conversation identity scoping; + core.ts:2436-2438 forces `_cursorIsolateConversation` for compaction + requests. Not the per-round mechanism, but governs when a conversation + id rotates (S1 amplifier when the client omits thread identity). + +Model contribution (MODEL, secondary): grok-4.6 verbalizes the re-oriented +state ("Continuing...") instead of silently continuing; C1/C2 control +probes decide how much is model vs adapter. + +## S2 — durationMs: 0 on every commandExecution + +Evidence: all commandExecution items in the seed thread report +`durationMs: 0` even for commands that clearly took time. + +Verdict: CLIENT-leaning UNKNOWN. `durationMs` appears in adapter code only +in native-exec-desktop.ts / native-exec-tools.ts error paths; the seed +thread's value is recorded by the Codex app from turn events. Gap: we did +not capture the SSE event stream the app received, so whether opencodex +emits started/completed timestamps correctly on the cursor route is +unverified. Probe P2 (SSE inspection) + P10 (native subagent) carry this. +Related surface: src/adapters/cursor/native-exec-shell.ts:136 +(`executionTime`) for cursor-native exec, not the Codex client path. + +## S3 — "Garbled / lost tool outputs" + +Evidence: model self-reports "previous tool results were garbled", "the +exec tool only returned a working directory check"; repeated identical +probes suggest earlier results never reached the model. + +Mechanism (ADAPTER, primary suspects): + +- protobuf-request.ts:248-258 — tool results replayed as flattened + `[Tool Result]` text with normalization + (src/adapters/cursor/tool-result-normalize.ts, #1920 empty-result + error reclassification). Any truncation/normalization loss here is + invisible to the client but visible to the model. +- The checkpoint-vs-full-replay fork (S1) means the model's view of tool + history differs by round; a round served from checkpoint and the next + from flattened replay do not present tool output identically. +- P12 (large/multi-line/ANSI payload stress) is the discriminator. + +## S4 — apply_patch envelope failure on first attempt + +Evidence: seed thread reasoning: "The previous patch failed because its +first line lacked the required `*** Begin Patch` header." + +Mechanism (ADAPTER by design, known limitation): + +- src/adapters/cursor/tool-definitions.ts:269-312 — Cursor-trained models + cannot reliably emit Codex freeform patch grammar (#1017), so the + adapter advertises `edit_file`/`multi_edit` and converts them + (protobuf-events.ts:896 `translateStructuredEditCall`); freeform + apply_patch passes through a grammar-only repair + (protobuf-events.ts:702 `sanitizeCodexApplyPatch`) that strips fences, + converts git-style diffs, rewrites hunk headers. +- The seed failure shows the repair did not catch a decorated + `*** Begin Patch ***` variant on first emission — or the model emitted + the patch as plain tool-argument text on a path that bypasses + sanitize. Which envelope variant leaked is UNKNOWN (the raw failing + payload was not captured in thread items); P6 probes decorated + envelopes deliberately. + +## S5 — Shell quoting failure loops (python3 -c nesting) + +Verdict: MODEL primarily (grok-4.6 quoting habits), amplified by S3 (lost +feedback prevented learning from the first failure). Adapter surface: +tool-definitions.ts:654-676 already injects guidance about host-shell-safe +commands; the seed thread shows the model ignoring or never seeing it +mid-conversation (S1 replay drops? — the system prompt is re-sent per +request-builder.ts:447, so this leans MODEL). + +## S6 — Task cost blowup (179s / ~15 rounds for a 3-round task) + +Composite of S1+S3: every lost or re-oriented round doubles the work. Not +a separate mechanism; recorded for severity weighting in 020. + +## UNKNOWN register + +| Item | Gap | Carried by | +|---|---|---| +| S2 timing emission | SSE event timestamps not captured | P2, P10 | +| S4 raw failing payload | thread items store outcome, not raw args | P6 | +| S1 checkpoint hit-rate | no live counter read | P11 | diff --git a/devlog/_plan/260826_cursor_responses_gap/010_probe_campaign.md b/devlog/_plan/260826_cursor_responses_gap/010_probe_campaign.md new file mode 100644 index 0000000000..791e8a9345 --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/010_probe_campaign.md @@ -0,0 +1,121 @@ +# 010 — Probe campaign (wp2 decade doc) + +Instrument: local proxy `localhost:10100` /v1/responses, opencodex +2.32.1-preview.20260825. Subject: `cursor/grok-4.6`. Controls: +`xai/grok-4.6` (C1), `cursor/claude-opus-5` + `cursor/gemini-3.7-flash` +(C2). Subagent fleet: native spawn_agent — sol medium default + explicit +cursor-model workers (S1), plugin-surface exposure (S2), all fenced to +read-only tasks or mktemp scratch. + +Verifier (from 000 plan): +`rg -c '^\| (P|C|S)[0-9]' devlog/_plan/260826_cursor_responses_gap/010_probe_campaign.md` +pass condition >= 10 evidence rows. + +Bounds: <=30 live probes, <=3 retries/class, ~40min wall clock. + +## Evidence rows + +| ID | Class | Model | Result | Evidence | +|---|---|---|---|---| +| P1a | plain | cursor/grok-4.6 | PASS | resp_8c23ca0d, "PONG", usage in=11913/out=14, reasoning_tokens=0 | +| P2a | streaming | cursor/grok-4.6 | PASS* | SSE seq: created, output_item.added, content_part.added, 9x output_text.delta, content_part.done, output_text.done, output_item.done, completed. Missing: response.in_progress event (Codex clients tolerate; spec emits it) | +| P3a | single tool | cursor/grok-4.6 | PASS* | function_call get_weather {"city":"Seoul"} emitted; call_id contains embedded NEWLINE: "call-9aee...-0\nfc_5a1a...__0" — two ids glued with \n | +| P4a | multi tool parallel | cursor/grok-4.6 | PASS* | 2 function_calls (Seoul, Tokyo) in one response; same newline call_id shape on both | +| P5a | tool-result round trip | cursor/grok-4.6 | PASS | echoed newline call_id accepted back; final "21°C sunny"; in=10346 | +| P6a | apply_patch freeform | cursor/grok-4.6 | PASS | custom_tool_call with valid "*** Begin Patch\n*** Add File: hello.txt\n+alpha\n+beta\n*** End Patch" envelope; in=1078 | +| P7a/b | previous_response_id | cursor/grok-4.6 | PASS | store:true chain; turn2 recalled "737"; in=12225 | +| P8a | reasoning item replay | cursor/grok-4.6 | PASS* | reasoning item in input accepted (no 4xx); answer "apple" correct; reasoning_tokens=0 in ALL cursor responses (reasoning never surfaced) | +| P9a-c | concurrent x3 | cursor/grok-4.6 | PASS | 3 parallel curls all HTTP 200, 3.4-4.0s, correct N1/N2/N3, no cross-talk | +| P11a | 5-turn tool chain | cursor/grok-4.6 | PASS | t1 call slot A -> t2 result 41 -> t3 call slot B -> t4 result 59 -> t5 sum "100" correct. in_tokens: 268 -> 10383 -> 10405 -> 10640 -> 10744 | +| P12a | 77KB ANSI/unicode tool result | cursor/grok-4.6 | PASS | ESC bytes + 한글 survived round trip; model read TOTAL_CHARS=77400 correctly; in=33341 | +| C1a | control: tool round trip | xai/grok-4.6 | PASS | same P5 shape; in=529 (vs cursor 10346 — 20x smaller) | +| C1b | control: reasoning replay | xai/grok-4.6 | PASS | native reasoning item RETURNED in output, reasoning_tokens=143 (cursor route: always 0) | +| C2a | control: cross-model | cursor/claude-opus-5 | FAIL | status:"failed", error "Cursor upstream error: Cursor Connect error not_found" (both P5-shape and plain retry); model advertised in catalog but unusable | +| C2b | control: cross-model | cursor/gemini-3.7-flash | PASS* | P5 shape ok; in=14846 — highest preamble floor of all routes | +| P13a | stream + function tool | cursor/grok-4.6 | PASS | full arg delta/done sequence, args complete, response.completed emitted | +| P13b | stream + custom (exec) tool | cursor/grok-4.6 | PASS* | custom_tool_call_input delta/done ok; note response.heartbeat event emitted (non-standard); tool input "await tools.exec_command({ command: \"date\" })" — wrong arg name (cmd vs command), model-side | +| S1a | subagent real task | cursor/grok-4.6 (Noether) | PASS* | 4-line file via apply_patch completed BUT: apply_patch rejections mid-task, duplicate round2-ok line collapsed, one ls "came back empty", agent wrote receipts OUTSIDE scratch fence (.codexclaw/evidence in repo), needed 3 hook-forced verification attempts | +| S1b | subagent real task | gpt-5.6-sol medium (Nash) | PASS | notes_sol-medium.md 4 lines correct; no incidents; finished but stayed unreaped until close (interrupted at close) | +| S1c | subagent real task | cursor/gemini-3.7-flash (Cicero) | PASS | notes + receipt written, self-reported PASS; also stayed unreaped | +| S2a | subagent plugin surface | cursor/grok-4.6 (Ramanujan) | FAIL | completed the probe (shell ok, Chrome screenshot ok, Codex-app screenshot policy-blocked as designed) THEN degenerated: ~180 consecutive identical exec calls ("BYTES:1646 EXISTS:true"), never terminated, closed manually | + +## Per-probe notes + +### P1a (smoke, pre-campaign) + +Request: `{"model":"cursor/grok-4.6","input":"Reply with exactly: PONG","stream":false,"store":false}`. +Observation: correct text, but input_tokens=11913 for an 6-word prompt — +the adapter injects its full system/tool preamble even for a bare request. +Flag for 020: token-cost floor. + +### Cross-cutting observations (curl phase) + +1. **Token floor**: every cursor-route request pays a ~10-15K input-token + preamble (P1a 11913, P5a 10346, P8a 10174, C2b 14846) even with zero + caller tools; xai route pays 229-529 for identical shapes. The adapter + injects its native tool catalog + system scaffolding unconditionally. + P3a is the exception (in=272): when the caller supplies function tools, + the preamble collapses — the floor comes from the DEFAULT native + toolset advertisement. +2. **call_id newline gluing** (P3a/P4a): the Responses-visible call_id is + `call--\nfc__` — two identifiers joined by a literal + newline. Round-trip works (P5a) because the adapter parses its own + format, but any client that logs, splits, or validates call_ids on + line boundaries breaks. Responses API ids are opaque but + single-line by convention everywhere else. +3. **reasoning_tokens always 0** on cursor route (all probes) while + xai/grok-4.6 emits a reasoning item + reasoning_tokens=143 for the + same prompt: grok-4.6's thinking is either not requested or dropped + by the cursor adapter — consistent with 001 S1 (reasoning never + replayed for external wire models, protobuf-request.ts:237). +4. **cursor/claude-opus-5 dead in catalog** (C2a): advertised by + /v1/models but every request fails upstream not_found. Catalog-serving + honesty gap. +5. **No response.in_progress SSE event** (P2a) — minor spec parity gap. +6. Multi-turn continuity via previous_response_id (P7, P11) WORKS at the + Responses surface — the seed thread's S1 re-orientation is therefore + NOT a hard continuity break; it is the replay REPRESENTATION + (flattened text + dropped reasoning) that causes re-orientation, plus + in_tokens jumping 268 -> 10383 after the first tool round (P11a: + checkpoint not reused across the tool boundary; full-replay fallback, + 001 S1 mechanism confirmed by token accounting). + +### Subagent-phase observations (S1/S2 + user session reports) + +7. **Degenerate tool-call loop (S2a, live)**: after finishing its task, the + cursor/grok-4.6 subagent repeated the byte-identical exec call ~180 + times ("BYTES:1646 EXISTS:true" x180 in its transcript) without ever + emitting a final answer. The flattened-text history replay (001 S1) + makes each round look like "someone verified bytes, verify again" — + no structured turn state to signal completion. Highest-severity live + confirmation of the replay-representation gap. +8. **Turn stall right after tool-call emission (user screenshot, + 2026-08-26 ~11:57)**: a cursor/grok-4.6 High session in the Codex app + ended its turn immediately after printing "[Tool call: + mcp_opencodex-responses_exec] / input" — tool call emitted, turn died + before the round trip. Matches P13b-adjacent risk: custom_tool_call + completes, stream closes with response.completed, and the app renders + a dead turn. UNKNOWN split: adapter closed the turn early vs app-side + loop drop — needs a captured SSE trace of a stalling session. +9. **Injected token corruption in replayed history (S2a transcript)**: + a stray token (" mar") spliced into tool results and structural + markers: "is_error mar: false", "jun mar staff", "[ martool_result]", + "[ martool_ marresult]". Corruption sits INSIDE replayed [tool_result] + text envelopes — produced on the replay/blob path + (protobuf-request.ts root blob candidates / blob hydration), not by + the local shell. Live confirmation of 001 S3 with a concrete + signature: token-level splicing at high replay volume. +10. **Hook/verification storm interaction (S1a)**: the codexclaw + SubagentStop evidence hook re-prompted the cursor worker 3 times; + each re-prompt re-entered the degraded replay loop, multiplying + cost. Not an adapter bug per se, but cursor-route sessions amplify + any re-prompting supervisor. +11. **Scratch-fence violation (S1a)**: the cursor worker wrote receipts + into repo .codexclaw/evidence/ despite scratch-only instruction + (the evidence hook demanded that path — instruction conflict + resolved toward the hook). Receipts left in place, noted here. +12. **Fleet reaping**: all three S1 workers reached their final message + but wait_agent kept timing out; each reported previous_status + "interrupted" at close. Completion signaling did not translate into + a reapable final status (affects sol too — not cursor-specific; + recorded for the campaign, attribution MIXED/host-side). diff --git a/devlog/_plan/260826_cursor_responses_gap/020_gap_summary.md b/devlog/_plan/260826_cursor_responses_gap/020_gap_summary.md new file mode 100644 index 0000000000..da4ab1915c --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/020_gap_summary.md @@ -0,0 +1,109 @@ +# 020 — Gap summary (wp3 decade doc) + +Severity-ranked incompleteness list for "cursor adapter as a real Codex +Responses backend". Populated at wp3 from 001 (code evidence) + 010 +(probe evidence). Skeleton written at wp1 (docs-first roadmap lock). + +## Ranking axes + +- Severity: how badly it degrades a Codex session (context loss > tool + fidelity > cost > polish). +- Attribution: ADAPTER / MODEL / CLIENT / MIXED, from control probes. +- Fixability: adapter-side fix surface exists in-tree or not. + +## Entries (to fill at wp3) + +### G1 — History replay representation destroys agentic turn state (CRITICAL, ADAPTER) + +The single root cause behind most of the seed thread's pathology. For +external wire models (grok), prior turns are replayed as flattened root +text: assistant reasoning dropped (protobuf-request.ts:237-240), tool +calls never replayed (:247), tool results as "[Tool Result]" prose +(:248-260). The model reads a foreign transcript instead of resuming its +own state. + +Evidence: 001 S1/S6; 010 obs 6 (in_tokens 268 -> 10383 across one tool +boundary = checkpoint fell back to full-replay); 010 obs 7 (S2a ~180x +identical tool-call loop — the model cannot tell "I already verified +this" from prose); seed thread's per-round "Continuing from previous +conversation". Control: xai/grok-4.6 (C1b) keeps native reasoning +(reasoning_tokens=143) — same model, no re-orientation pathology. + +Fix surface: checkpoint reuse across trailing_tool_result +(request-builder.ts:430-443 + checkpoint-store.ts invalidation policy), +and/or structured tool-call/result replay for external models. + +### G2 — Turn stall / degenerate loop at tool boundaries (CRITICAL, MIXED adapter-leaning) + +Two live faces: (a) turn dies immediately after emitting a tool call +(user screenshot 11:57 — "[Tool call: mcp_opencodex-responses_exec] / +input" then turn end); (b) turn never ends (S2a 180x loop). Both are +the same missing invariant: one tool round-trip = one clean +turn-continuation. P13a/b show the SSE sequence itself completes +correctly on curl, so the failure needs conversation depth/state — the +UNKNOWN is whether the adapter emits status:completed prematurely on +some paths or the replay confusion (G1) makes the model emit nothing. +Needs an SSE capture of a stalling app session. + +### G3 — Reasoning permanently dropped on cursor route (HIGH, ADAPTER by design) + +reasoning_tokens=0 on every cursor probe; xai control returns a real +reasoning item for the identical prompt (C1b). Cursor route neither +requests nor surfaces grok thinking, and never replays it (G1). For a +Responses backend this breaks reasoning-summary UX and weakens +multi-turn quality. Evidence: 010 obs 3, 001 S1. + +### G4 — Tool-output corruption on the replay/blob path (HIGH, ADAPTER) + +" mar" token spliced inside replayed [tool_result] envelopes and even +structural markers ("is_error mar: false", "[ martool_result]") in the +S2a transcript (010 obs 9). Seed thread's "garbled tool outputs" (001 +S3) now has a live signature: token-level splicing at high replay +volume. P12a shows 77KB ANSI/unicode survives a SINGLE round trip — +corruption appears under accumulated replay, pointing at root blob +assembly/hydration (protobuf-request.ts) not one-shot encoding. + +### G5 — call_id format breaks Responses id conventions (MEDIUM, ADAPTER) + +Every function/custom call exposes call_id as two ids glued with a +literal newline ("call--\nfc__"; P3a/P4a/P6a/P13b). +Round-trips through the adapter itself (P5a), but any client that +splits/validates on line boundaries breaks. Fix surface: single-line +encoding of the composite id. + +### G6 — ~10-15K input-token preamble floor (MEDIUM, ADAPTER) + +Bare "PONG" request costs 11,913 input tokens; xai identical shape 229. +Default native-toolset advertisement is injected even when the caller +brought no tools (P1a vs P3a in=272 shows the floor collapses when +caller tools are present). Cost + context-budget tax on every Codex +session. Evidence: 010 obs 1. + +### G7 — Catalog serves a dead model (MEDIUM, ADAPTER/catalog) + +cursor/claude-opus-5 advertised by /v1/models but 100% upstream +"Connect error not_found" (C2a, plain retry too). Catalog honesty gap — +a Codex picker offering a model that cannot answer. + +### G8 — SSE spec parity gaps (LOW, ADAPTER) + +No response.in_progress event (P2a); non-standard response.heartbeat +emitted (P13b). Codex tolerates both today; strict Responses clients +may not. + +### G9 — Model-side residuals (LOW, MODEL — for completeness) + +grok-4.6 quoting/argument habits: nested-quote shell failures (001 S5), +wrong arg name for the exec helper ({command} vs {cmd}, P13b). These are +not adapter defects but G1 amplifies them by hiding corrective feedback. + +## Verdict + +"cursor가 진짜 Responses 백엔드가 되기 위해" 부족한 것 순서: +G1 replay representation (root cause) > G2 turn-boundary stall/loop > +G3 reasoning drop > G4 replay corruption > G5 call_id > G6 token floor > +G7 dead catalog entry > G8 SSE parity. The request surface itself +(P1-P13: plain, stream, tools, apply_patch, prev_response_id chains, +concurrency, 77KB payloads) already works; the incompleteness is +concentrated in multi-turn state representation and turn-boundary +lifecycle, exactly where the seed thread suffered. diff --git a/devlog/_plan/260826_cursor_responses_gap/025_ultra_k3_research.md b/devlog/_plan/260826_cursor_responses_gap/025_ultra_k3_research.md new file mode 100644 index 0000000000..cd25d3e6e1 --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/025_ultra_k3_research.md @@ -0,0 +1,26 @@ +# 025 — Ultra / k3 / 1M research (luna swarm, 2026-08-26) + +Three luna lanes (ultra-plan, k3, protocol) + prior in-repo maxmode work. +Claim ledger — status per cxc-search discipline: + +| Claim | Status | Source | +|---|---|---| +| Cursor k3 = Moonshot Kimi K3 (2.8T MoE) | verified | kimi.ai blog 2026-07-20 (primary) | +| Kimi K3 native context up to 1M (k3-256k variant exists) | verified | kimi.com/code docs (primary) | +| Cursor default session context ~200k; Max Mode extends to model max | verified | cursor.com/docs/models-and-pricing (primary) | +| Some models show 1M "Max Context" in Cursor table (Fable/Opus/Sonnet 5, Gemini) | verified | same (primary) | +| Ultra = 20x usage ($400 API-agent allowance), NOT an exclusive catalog | verified | cursor.com/pricing + forum staff (primary) | +| Max Mode currently documented for legacy request-based plans | verified | prod.cursor.com/help/ai-features/max-mode (primary) | +| K3 1M specifically unlocked on Ultra | UNVERIFIED — user observation; no primary source; Reddit says the 1M option appeared then disappeared | reddit 2026-08 (lead) | +| Wire: max mode = RequestedModel.max_mode (field 2) AND ModelDetails.max_mode (field 7); missing either can invalid_argument | lead (2 impl sources) | oh-my-pi #4969, cursor-opencode-provider | +| 1M exposure pattern: synthetic -1m picker variant w/ limit.context=1M, wire sends original id + maxMode | lead | cursor-opencode-provider README | +| In-repo: GetUsableModels ModelDetails.maxMode=true observed on 28 -fast ids (260822); no contextTokenLimit field | verified (own probe) | devlog/_plan/260822_senpi_cursor_transfer/210_maxmode.md | + +Design consequence for 070 (ultra toggle): treat "ultra" as a per-model +toggle that (a) sets the wire maxMode flag in both RequestedModel and +ModelDetails, (b) advertises a synthetic catalog variant with +context_window=1M ONLY where evidence supports it (kimi-k3, and any +ModelDetails.maxMode=true id), auto-detected from GetUsableModels, and +(c) never renames the wire model id. User-facing shape mirrors the +effort-suffix system (cursor/kimi-k3-max already exists; ultra adds the +big-context dimension). diff --git a/devlog/_plan/260826_cursor_responses_gap/030_callid_sse_parity.md b/devlog/_plan/260826_cursor_responses_gap/030_callid_sse_parity.md new file mode 100644 index 0000000000..226ce96386 --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/030_callid_sse_parity.md @@ -0,0 +1,34 @@ +# 030 — Fix A: call_id single-line codec + response.in_progress (branch codex/cursor-gap-1) + +Research: sol lane Curie (REPORT_DONE, 2026-08-26). Key facts: +- The newline join is UPSTREAM (Cursor sends composite ids inside protobuf + call_id); local code forwards unchanged. protobuf-events.ts:1192/:1253, + message-mapper.ts:20, bridge.ts:1079/:658. +- Round-trip consumers: parser.ts:569/:604/:692/:706/:288, + protobuf-request.ts:678/:735/:576/:530, request-builder.ts:226/:434. +- response.in_progress never emitted: bridge.ts:1395 emits only + response.created (status in_progress). responses-json-events.ts:12 same. +- response.heartbeat: typed event from bridge.ts:425/:1434, all bridged + providers (not cursor-specific); grok client surface switches to SSE + comments (core.ts:4692/:5648). No change needed for heartbeat. + +## Diff plan + +1. ADD src/adapters/cursor/call-id.ts — reversible codec: encode ids + containing CR/LF to `ocx_cursor_v1_`; decode both that + form and legacy raw newline ids to the original composite. +2. EDIT src/adapters/cursor/message-mapper.ts:20 — encode + tool_call_start/end ids at the Cursor->AdapterEvent boundary. +3. EDIT src/adapters/cursor/request-builder.ts — decode toolCall.id / + toolResult.toolCallId before building messages + rawMessages so the + wire sees the original composite id. +4. EDIT src/bridge.ts:1395 — emit response.in_progress right after + response.created (same empty snapshot). + +## Accept criteria + +- No CR/LF in any Responses-visible call_id (unit + bridge test). +- Legacy newline call_ids still pair on replay (backward compat test). +- Stream begins created -> in_progress; exactly one in_progress. +- Tests: cursor-message-mapper, cursor-blob (pairing), bridge-lifecycle, + responses-stream-tool-events. Focused bun test + typecheck green. diff --git a/devlog/_plan/260826_cursor_responses_gap/040_preamble_floor.md b/devlog/_plan/260826_cursor_responses_gap/040_preamble_floor.md new file mode 100644 index 0000000000..bdb78f5569 --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/040_preamble_floor.md @@ -0,0 +1,30 @@ +# 040 — Fix B: token preamble floor (branch codex/cursor-gap-2) + +Research: sol lane Hume (REPORT_DONE). Root cause: bare requests omit the +top-level protobuf mcp_tools field (protobuf-request.ts:979 +`mcpToolDefs.length > 0 ? { mcpTools } : {}`), so Cursor upstream +injects its default native tool catalog (~11.6K tokens). With caller +tools present, mcp_tools replaces the default (in=272). Chain: +parser.ts:726/:749 -> tool-definitions.ts:493 -> request-builder.ts:77/:454 +-> protobuf-request.ts:979. Locked by tests/cursor-blob.test.ts:1065. + +## Diff plan + +1. EDIT src/adapters/cursor/types.ts — add + `suppressDefaultCursorToolCatalog?: boolean` to CursorRunRequest. +2. EDIT request-builder.ts createCursorRequest — set it when + `budget.tools.length === 0 && !cursorClientThreadOwner(parsed)` + (bare API caller, no Codex thread identity). +3. EDIT protobuf-request.ts:979 — advertise when + `mcpToolDefs.length > 0 || request.suppressDefaultCursorToolCatalog` + (empty McpTools wrapper suppresses upstream default catalog). + +Codex-native sessions (thread owner present) keep today's absent-field +behavior — they rely on the native catalog. + +## Accept criteria + +- Bare no-tool request emits explicit empty mcp_tools {} (blob test). +- Thread-identified no-tool request leaves field absent (unchanged). +- Caller-tool requests unchanged. Focused tests + typecheck green. +- Tests: cursor-request-builder (flag matrix), cursor-blob (wire shape). diff --git a/devlog/_plan/260826_cursor_responses_gap/050_checkpoint_continuity.md b/devlog/_plan/260826_cursor_responses_gap/050_checkpoint_continuity.md new file mode 100644 index 0000000000..0af3090577 --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/050_checkpoint_continuity.md @@ -0,0 +1,38 @@ +# 050 — Fix C: checkpoint continuity across tool round-trips (codex/cursor-gap-3) + +Research: sol lane Banach (REPORT_DONE; focused tests 245 pass baseline). +CORRECTION to 001/020 G1 attribution: the covered-prefix + trailing +toolResult path ALREADY works (request-builder.ts:429/:464, test +cursor-request-builder.test.ts:972). The live 268->10383 jump is +missing_ref: a turn that emits a client tool call sets +emittedClientTool=true (cursor.ts:207) and commitCapturedCheckpoint +REFUSES to commit (cursor.ts:150) — so the first tool round has no +checkpoint at all, and every tool-call-heavy session replays fully. + +## Diff plan (external-model-only, evidence-gated) + +1. EDIT live-transport.ts checkpoint seam — return capture-ordering + metadata (checkpoint arrived after client tool-call completion). +2. EDIT cursor.ts commitCapturedCheckpoint — allow committing a + tool-suspended checkpoint ONLY for external wire models with that + ordering proof; store with checkpointUsable:false + covered count. +3. Next trailing-toolResult request rides the EXISTING + checkpointSuffixStart path (no change to lineage guards). +4. Fail closed on upstream invalid_argument (cursor.ts:250 behavior kept). +5. Reasoning (G3): keep dropped for external roots (live evidence: + external workers invalid_argument on native thinking structures, + protobuf-request.ts:696). reasoning_tokens=0 does NOT prove current + reasoning dropped — grok worker may emit no thinkingDelta; current-turn + thinking IS mapped when present (protobuf-events.ts:1251, + message-mapper.ts:18, bridge.ts:947). G3 reclassified: adapter replay + drop is by design + upstream constraint; no code change this program. + +## Accept criteria + +- New test: external model, tool-suspended checkpoint with ordering proof + commits; next covered+[toolResult] request uses checkpoint mode + (continuationMode=checkpoint, not full-replay). +- Native models unchanged (commit still refused). +- Pre-tool-captured checkpoints still refused (ordering guard test). +- Tests: cursor-live-transport, cursor-adapter, cursor-request-builder, + cursor-blob, responses-state. Focused green + typecheck. diff --git a/devlog/_plan/260826_cursor_responses_gap/060_catalog_honesty.md b/devlog/_plan/260826_cursor_responses_gap/060_catalog_honesty.md new file mode 100644 index 0000000000..39a342319a --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/060_catalog_honesty.md @@ -0,0 +1,25 @@ +# 060 — Fix D: catalog honesty for dead upstream models (codex/cursor-gap-4) + +Research: sol lane Schrödinger. cursor/claude-opus-5 exposure chain: +static seed discovery.ts:248 -> effort-map.ts:33 suffixes -> +registry.ts:1037 -> live filter only checks GetUsableModels ID presence +(discovery.ts:78) -> provider-fetch.ts:1281 serves it. Upstream returns +not_found on every Run (probe C2a, 100%). + +## Diff plan + +1. EDIT discovery.ts — remove base claude-opus-5 from + CURSOR_STATIC_MODELS (keep -fast and -thinking families: separate + wire families with success evidence). +2. ADD narrow quarantine set CURSOR_KNOWN_UNCALLABLE_MODEL_IDS applied in + the cursor branch of provider-fetch.ts before auth/fallback so stale + caches and discovery-failure fallbacks cannot resurrect the row. + Custom user provider overrides are NOT touched. + +## Accept criteria + +- Gathered canonical cursor catalog excludes cursor/claude-opus-5 even + when GetUsableModels lists claude-opus-5-* ids (test). +- claude-opus-5-fast / -thinking siblings survive (test). +- No-auth, discovery-failure, stale-cache paths stay quarantined (test). +- Tests: cursor-hardening, cursor-static-catalog, cursor-discovery. diff --git a/devlog/_plan/260826_cursor_responses_gap/070_ultra_mode.md b/devlog/_plan/260826_cursor_responses_gap/070_ultra_mode.md new file mode 100644 index 0000000000..fbd9a5d49c --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/070_ultra_mode.md @@ -0,0 +1,39 @@ +# 070 — Fix E: ultra mode toggle + 1M context autodetect (codex/cursor-gap-5) + +Research: 025 ledger (luna: k3=Moonshot Kimi K3, native 1M; Cursor default +~200k; max mode = wire flag RequestedModel.max_mode AND +ModelDetails.max_mode — two impl leads; 1M exposed as synthetic -1m +picker variant, wire keeps original id) + sol Schrödinger (current code: +maxMode discarded at discovery, wire always false — cursor-blob.test:582; +ultra effort collapses to max, effort-map.ts:132; context SoT chain +discovery.ts:27/:221 -> registry.ts:1029 -> provider-fetch.ts:1216 -> +effort.ts:114) + in-repo 210_maxmode (server ACCEPTS maxMode flag; +entitlement varies per account/tier; 28 -fast ids show maxMode=true). + +## Diff plan + +1. EDIT live-models.ts:45 — extend result to + { models, maxModeModels } preserving ModelDetails.maxMode=true ids. +2. EDIT discovery.ts — synthesize cursor/-1m rows for + maxMode-capable bases (auto-detect), contextWindow=1_000_000; + kimi-k3 included when capability observed. Codex-style toggle = the + picker variant (like effort toggles), exactly the "ultra 모드 토글". +3. EDIT types.ts + request-builder.ts normalizeCursorModelId — strip the + synthetic -1m marker BEFORE effort resolution; set + CursorRunRequest.maxMode=true; wire id stays original (kimi-k3-max). + Guard against collision with real wire ids ending in -1m (restrict + synthesis to live-derived rows). +4. EDIT protobuf-request.ts:950 — ModelDetails.maxMode = request.maxMode; + RequestedModel.maxMode = request.maxMode (create RequestedModel even + without parameters when maxMode). + +## Accept criteria + +- decode preserves maxModeModels (cursor-hardening test). +- 1M synthetic row only for capable models, context_window=1M + (cursor-discovery + cursor-static-catalog tests). +- kimi-k3-1m + max/ultra -> wire kimi-k3-max + maxMode=true both protobuf + fields; no synthetic suffix leaks to wire (cursor-effort-suffix + + cursor-blob tests; update cursor-blob.test.ts:582 expectation). +- Entitlement rejection upstream stays a runtime error (plan-gated + accounts) — documented, not faked. diff --git a/devlog/_plan/260826_cursor_responses_gap/080_stall_corruption_diag.md b/devlog/_plan/260826_cursor_responses_gap/080_stall_corruption_diag.md new file mode 100644 index 0000000000..c6b63bceb7 --- /dev/null +++ b/devlog/_plan/260826_cursor_responses_gap/080_stall_corruption_diag.md @@ -0,0 +1,29 @@ +# 080 — Fix F: G2/G4 instrumentation (codex/cursor-gap-6) + +No deterministic local fix is provable yet: G2 (turn stall/degenerate +loop) needs an SSE trace of a stalling app session; G4 (" mar" token +splice) reproduced only under accumulated replay volume in a live +subagent. Honest scope: diagnostics capture, not a behavior fix. + +## Diff plan + +1. Provider diagnostics already expose continuationMode + + checkpointInvalidationReason (protobuf-request.ts:933, cursor.ts:283) + — surface them in the debug provider-diagnostic log line for every + cursor turn (cheap, existing debug channel), so a stalling session's + next report carries replay-state evidence. +2. ADD a bounded root-blob integrity check at assembly time: after + building root blob candidates for external replay, verify the + serialized text round-trips byte-identically (detect splice-class + corruption at the source); on mismatch emit a debug diagnostic with + offsets (no payload contents — privacy scan safe). +3. Document the capture procedure for the next stall occurrence + (curl -N session mirror) in this doc. + +## Accept criteria + +- Diagnostic line appears for cursor turns under debug flag (test with + debug seam). +- Integrity check triggers on an injected mutated blob (unit test with + fault injection); silent on clean paths. +- privacy:scan stays green (no payload logging). diff --git a/src/adapters/cursor/call-id.ts b/src/adapters/cursor/call-id.ts new file mode 100644 index 0000000000..a76707d00a --- /dev/null +++ b/src/adapters/cursor/call-id.ts @@ -0,0 +1,44 @@ +/** + * Reversible single-line codec for Cursor composite tool-call ids. + * + * Cursor's wire delivers tool-call ids that can be two identifiers glued with a + * literal newline ("call--\nfc__"). OpenCodex forwards ids + * verbatim, so that newline leaked into Responses-visible `call_id` values, + * where line-oriented clients (logging, splitting, validation) break. The codec + * encodes only ids containing CR/LF into a versioned single-line form and + * decodes both that form and legacy raw multi-line ids back to the exact + * upstream bytes before anything is serialized toward Cursor. + */ + +const CALL_ID_PREFIX = "ocxc1_"; + +/** True when the id needs encoding to survive line-oriented consumers. */ +function needsEncoding(id: string): boolean { + return id.includes("\n") || id.includes("\r"); +} + +/** Encode a Cursor wire call id into a single-line Responses-safe id. */ +export function encodeCursorCallId(id: string): string { + if (!needsEncoding(id)) return id; + return CALL_ID_PREFIX + Buffer.from(id, "utf8").toString("base64url"); +} + +/** + * Decode a Responses-visible call id back to the exact Cursor wire id. + * Non-encoded ids (including legacy raw multi-line ids replayed by older + * clients) pass through unchanged; a malformed encoded payload also passes + * through rather than corrupting pairing. + */ +export function decodeCursorCallId(id: string): string { + if (!id.startsWith(CALL_ID_PREFIX)) return id; + const payload = id.slice(CALL_ID_PREFIX.length); + if (payload.length === 0) return id; + try { + const decoded = Buffer.from(payload, "base64url").toString("utf8"); + // Round-trip guard: only trust payloads our encoder could have produced. + if (Buffer.from(decoded, "utf8").toString("base64url") !== payload) return id; + return decoded; + } catch { + return id; + } +} diff --git a/src/adapters/cursor/message-mapper.ts b/src/adapters/cursor/message-mapper.ts index 459cd9af30..c9133db4e8 100644 --- a/src/adapters/cursor/message-mapper.ts +++ b/src/adapters/cursor/message-mapper.ts @@ -1,4 +1,5 @@ import type { AdapterEvent } from "../../types"; +import { encodeCursorCallId } from "./call-id"; import { cursorExecResult } from "./exec-policy"; import type { CursorClientMessage, CursorServerMessage } from "./types"; import type { CursorKvStore } from "./kv-store"; @@ -18,7 +19,9 @@ export function mapCursorServerMessage( case "thinking": return [{ type: "thinking_delta", thinking: message.thinking }]; case "tool_call_start": - return [{ type: "tool_call_start", id: message.id, name: message.name }]; + // Cursor composite ids can contain a literal newline; Responses call_ids + // must stay single-line (see call-id.ts). + return [{ type: "tool_call_start", id: encodeCursorCallId(message.id), name: message.name }]; case "tool_call_delta": return [{ type: "tool_call_delta", arguments: message.arguments }]; case "tool_call_end": diff --git a/src/adapters/cursor/protobuf-request.ts b/src/adapters/cursor/protobuf-request.ts index 74e64bd5b5..76e1cc12da 100644 --- a/src/adapters/cursor/protobuf-request.ts +++ b/src/adapters/cursor/protobuf-request.ts @@ -4,6 +4,7 @@ import { ValueSchema } from "@bufbuild/protobuf/wkt"; import type { OcxAssistantContentPart, OcxMessage, OcxToolResultMessage } from "../../types"; import { namespacedToolName } from "../../types"; import type { CursorRunRequest } from "./types"; +import { decodeCursorCallId } from "./call-id"; import { cursorNeedsExternalToolContinuation, isCursorExternalWireModel } from "./discovery"; import { normalizeCursorToolResultText } from "./tool-result-normalize"; import { debugProviderDiagnostic } from "../../lib/debug"; @@ -531,7 +532,7 @@ function toolResultToText(message: OcxToolResultMessage): string { const normalized = normalizedToolResult(message, contentToText(message.content)); return [ "[tool_result]", - `call_id: ${message.toolCallId}`, + `call_id: ${decodeCursorCallId(message.toolCallId)}`, `name: ${namespacedToolName(message.toolNamespace, message.toolName)}`, `is_error: ${normalized.isError}`, "output:", @@ -592,7 +593,7 @@ function toolCallStep( args: create(McpArgsSchema, { name: toolName, toolName, - toolCallId: part.id, + toolCallId: decodeCursorCallId(part.id), providerIdentifier: OCX_RESPONSES_TOOL_PROVIDER, args, }), diff --git a/src/adapters/cursor/request-builder.ts b/src/adapters/cursor/request-builder.ts index 858db97b27..d9785f14c1 100644 --- a/src/adapters/cursor/request-builder.ts +++ b/src/adapters/cursor/request-builder.ts @@ -10,6 +10,7 @@ import type { import { isAllowedToolChoice, namespacedToolName, toolChoiceAliases, type OcxTool, type OcxToolChoice } from "../../types"; import type { CursorRequestMessage, CursorRequestedModelParameter, CursorRunRequest } from "./types"; import { cursorCheckpointModelAffinityId, cursorWireModelSelection, type CursorRoutingLevel } from "./discovery"; +import { decodeCursorCallId } from "./call-id"; import { cursorEffortSuffix, cursorRequestWireModelIdWithEffort } from "./effort-map"; import { cursorMcpToolEncodedSize, @@ -226,7 +227,7 @@ function contentPartToText(part: OcxContentPart | OcxAssistantContentPart): stri function toolResultToText(message: OcxToolResultMessage): string { return [ "[tool_result]", - `call_id: ${message.toolCallId}`, + `call_id: ${decodeCursorCallId(message.toolCallId)}`, `name: ${namespacedToolName(message.toolNamespace, message.toolName)}`, `is_error: ${message.isError}`, "output:", diff --git a/src/bridge.ts b/src/bridge.ts index 1276b1406b..1ba5a3db14 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -1394,6 +1394,8 @@ export function bridgeToResponsesSSE( const startStream = () => { emit("response.created", { response: responseSnapshot("in_progress", []) }); + // Responses spec parity: clients expect an explicit in_progress frame after created. + emit("response.in_progress", { response: responseSnapshot("in_progress", []) }); // The default ReadableStream strategy has HWM=1. Once one event's frames fill that // queue, pull stepping pauses; no custom FIFO or queuing strategy is layered on top. gated = true; diff --git a/src/lab/conformance/fixtures/protocol-v1-cases.json b/src/lab/conformance/fixtures/protocol-v1-cases.json index 729ed6911b..79850853b4 100644 --- a/src/lab/conformance/fixtures/protocol-v1-cases.json +++ b/src/lab/conformance/fixtures/protocol-v1-cases.json @@ -274,7 +274,7 @@ "assertions": [ { "id": "text", "operator": "normalized_text_equals", "selector": "/client/response/normalizedText", "expected": "OK", "required": true }, { "id": "terminal", "operator": "terminal_signal_equals", "selector": "/client/response/terminal", "expected": "completed", "required": true }, - { "id": "phase", "operator": "json_path_equals", "selector": "/client/response/events/6/data/item/phase", "expected": "final_answer", "required": true } + { "id": "phase", "operator": "json_path_equals", "selector": "/client/response/events/7/data/item/phase", "expected": "final_answer", "required": true } ] }, { diff --git a/tests/cursor-call-id.test.ts b/tests/cursor-call-id.test.ts new file mode 100644 index 0000000000..def2ece14c --- /dev/null +++ b/tests/cursor-call-id.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, test } from "bun:test"; +import { decodeCursorCallId, encodeCursorCallId } from "../src/adapters/cursor/call-id"; +import { mapCursorServerMessage } from "../src/adapters/cursor/message-mapper"; +import type { CursorMessageMapperState } from "../src/adapters/cursor/message-mapper"; +import type { CursorKvStore } from "../src/adapters/cursor/kv-store"; + +const COMPOSITE = "call-9aee6d07-edc0-442f-8466-9d3924e16e03-0\nfc_5a1a53c6-32ce-9602-bc56-459030165589_0"; + +function mapperState(): CursorMessageMapperState { + const kv: CursorKvStore = { get: () => undefined, set: () => {} }; + return { kv, writeClient: () => {} }; +} + +describe("cursor call-id codec", () => { + test("plain ids pass through unchanged", () => { + expect(encodeCursorCallId("call_abc123")).toBe("call_abc123"); + expect(decodeCursorCallId("call_abc123")).toBe("call_abc123"); + }); + + test("newline composite id round-trips through a single-line form", () => { + const encoded = encodeCursorCallId(COMPOSITE); + expect(encoded).not.toContain("\n"); + expect(encoded).not.toContain("\r"); + expect(encoded.startsWith("ocxc1_")).toBe(true); + expect(decodeCursorCallId(encoded)).toBe(COMPOSITE); + }); + + test("legacy raw newline id decodes to itself (backward compat)", () => { + expect(decodeCursorCallId(COMPOSITE)).toBe(COMPOSITE); + }); + + test("malformed encoded payloads are not corrupted", () => { + expect(decodeCursorCallId("ocxc1_")).toBe("ocxc1_"); + expect(decodeCursorCallId("ocxc1_!!not-base64url!!")).toBe("ocxc1_!!not-base64url!!"); + }); + + test("tool_call_start ids are single-line at the adapter boundary", () => { + const events = mapCursorServerMessage( + { type: "tool_call_start", id: COMPOSITE, name: "get_weather" }, + mapperState(), + ); + expect(events).toHaveLength(1); + const event = events[0]!; + if (event.type !== "tool_call_start") throw new Error("expected tool_call_start"); + expect(event.id).not.toContain("\n"); + expect(decodeCursorCallId(event.id)).toBe(COMPOSITE); + }); +});