Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
e03a7a2
docs(devlog): decode the Cursor tool-call and tool-result wire path
lidge-jun Aug 17, 2026
46296fe
docs(devlog): add phase 3 — xai/grok-4.6 apply_patch affordance gap
lidge-jun Aug 17, 2026
fe2e789
docs(devlog): use underscore-numbered doc names (LEXICO-SPLIT-01)
lidge-jun Aug 17, 2026
c297822
docs(devlog): correct the roadmap unit after an adversarial audit ret…
lidge-jun Aug 17, 2026
27204d5
docs(devlog): absorb round-2 audit FAIL (7 findings)
lidge-jun Aug 17, 2026
53fe3a6
docs(devlog): absorb round-3 audit FAIL (5 findings)
lidge-jun Aug 17, 2026
85722a2
docs(devlog): record a live grok-4.6 apply_patch probe that fails to …
lidge-jun Aug 17, 2026
f15481e
docs(devlog): absorb round-4 audit — fix the 010 double-terminal blocker
lidge-jun Aug 17, 2026
e91eba7
docs(devlog): round 5 reframes 010 around one-terminal-per-turn
lidge-jun Aug 17, 2026
bc6bb42
docs(devlog): round 6 reverses round 5 — 010 returns to narrow F1 scope
lidge-jun Aug 17, 2026
7735824
fix(cursor): keep emittedTerminal on dev's fail-closed EOF shape
lidge-jun Aug 18, 2026
ff20311
fix(cursor): send real image content in tool results instead of a pla…
lidge-jun Aug 17, 2026
916815a
fix(cursor): bound tool-result images by real serialized step size, n…
lidge-jun Aug 17, 2026
d3153d4
fix(cursor): keep image degradation within the legacy placeholder budget
lidge-jun Aug 17, 2026
92bb472
fix(cursor): group text parts into one item so no-image results keep …
lidge-jun Aug 17, 2026
75d965b
refactor(cursor): stop claiming the adapter cannot send images
lidge-jun Aug 17, 2026
e2a203d
docs(devlog): record what shipped for 010 and 020, and why 030 did not
lidge-jun Aug 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
265 changes: 265 additions & 0 deletions devlog/_plan/260817_cursor_toolcall_decode/000_index.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
# 001 — Tool-call lifecycle decode

Source: `src/adapters/cursor/protobuf-events.ts`. Verified by direct read plus
an independent `gpt-5.6-sol` audit, then corrected after an adversarial audit.

## The lifecycle

Cursor delivers a client tool call across several interaction updates, and the
adapter deliberately does **not** mirror them one-to-one downstream:

| Cursor update | Adapter action | Emitted downstream |
|---------------|----------------|--------------------|
| `toolCallStarted` (`:1249`) | `recordToolCall` opens the call | **nothing** (deferred) |
| `partialToolCall` (`:1254`) | `recordToolCall` if new, then `bufferToolArgs` on `argsTextDelta` | nothing |
| `toolCallDelta` (`:1265`) | **nothing** — returns `[]` | nothing |
| `toolCallCompleted` (`:1269`) | `resolveCompletedArgs` + `commitToolCall` | `tool_call_start` -> `tool_call_delta` -> `tool_call_end` |

**Correction (adversarial audit).** An earlier draft put argument buffering on
`toolCallDelta`. That branch returns `[]` by design: Cursor's typed deltas cover
native exec internals (shell/task/edit), while client Responses tools return as
`McpToolCall` plus partial args text. Buffering happens on `partialToolCall`,
which the original table omitted entirely.

The deferral is intentional and correct: Cursor can open several calls in
parallel or interleave their arg streams, while the Codex bridge tracks a single
current call. Emitting each completed call as one atomic unit serializes them
safely. The cost is that an **incomplete** call has emitted nothing at all, which
is what makes F1 (see `003`) invisible rather than merely wrong.

## Argument resolution

`resolveCompletedArgs` (`:354`) picks in order:

1. the structured protobuf map when it has bytes (canonical, schema-normalized);
2. otherwise buffered streamed text, normalized when it is complete JSON;
3. otherwise the buffered text **verbatim**.

Case 3 is deliberate: passing malformed text through lets the bridge reject it
(`bridge.ts:1070`) instead of silently converting truncated args into `{}` and
executing a tool with the wrong arguments. `argsTextDelta` is cumulative, so the
buffer keeps the longest value seen rather than concatenating.

## Error surfaces

`recordToolCall` returns an error for an un-advertised tool name (`:1103`) and
for exceeding `maxClientToolCalls` (`:1107`). `commitToolCall` returns an error
only for invalid freeform args (`:1160`) and invalid shell-bridge args (`:1163`).
A failed structured-edit conversion deliberately returns **text**, not an error
(`:1175`), keeping the turn alive.

Every `error` `CursorServerMessage` is turn-fatal downstream: it maps to
`AdapterEvent.error` (`message-mapper.ts:28`) and then `response.failed`
(`bridge.ts:1219`).

## Verdict

The lifecycle itself is sound. Two prior hypotheses were disproved here (see
`000`), and no change to this file is proposed for its own sake — `010` does not
modify it at all in the revised plan.

Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# 002 — Tool-result encoding decode

Source: `src/adapters/cursor/protobuf-request.ts`, `gen/agent_pb.ts`,
`native-exec-mcp.ts`. Verified by direct read plus an independent
`gpt-5.6-sol` audit.

## F2 — images are destroyed, and the wire did not ask for that (High)

`contentToText` (`protobuf-request.ts:328`) maps every non-text part of a tool
result to a literal placeholder:

```ts
.map(part => part.type === "text" ? part.text : `[image input unsupported by Cursor adapter phase 3: ${part.detail ?? "auto"}]`)
```

`toolResultPart` (`:384`) then always builds exactly one `text` content item. So
a screenshot returned by Computer Use, a browser QA tool, or any image-returning
MCP tool reaches Cursor as the string
`[image input unsupported by Cursor adapter phase 3: auto]` and nothing else.

**The Cursor protobuf supports images.** `McpToolResultContentItem.content` is a
oneof with exactly two defined cases (`gen/agent_pb.ts:8476`):

```ts
content:
| { value: McpTextContent; case: "text" } // field 1
| { value: McpImageContent; case: "image" } // field 2
| { case: undefined; value?: undefined };
```

`McpImageContent` (`:8449`) carries `data: Uint8Array` (bytes, base64 in JSON)
and `mimeType: string`.

**The adapter already knows how to send one.** `native-exec-mcp.ts:115` decodes
base64 from an MCP block and emits a real `McpImageContent`. That path covers
tools invoked through `CursorMcpManager`; it does not cover Codex
`OcxToolResultMessage` values flowing through `protobuf-request.ts`. The
placeholder is therefore not a wire limitation but an unfinished migration — the
"phase 3" in its own text.

A model driving Computer Use therefore receives a blind result and cannot see
what its own action did. That is a real capability loss, proven by source
reading.

**It is not proven to be the cause of the reported retries and session resets.**
No live trace ties those symptoms to this branch, and the external evidence in
`004` points at least partly elsewhere (the `node_repl` runtime). An adversarial
audit flagged the original causal claim as unsupported; it is withdrawn. The
defect stands on its own merits and does not need to explain every symptom to be
worth fixing.

## The three result paths in `conversationTurns`

| Path | Behavior | Lines |
|------|----------|-------|
| External model | `AssistantMessage` with `[Tool Result]`/`[Tool Error]` + placeholder | 481-490 |
| Native model, matching pending call | `toolResultPart(result)` attached to the MCP call — still text-only | 493-496 |
| Native model, no matching call | `toolResultToText` as an `AssistantMessage` | 498-503 |

All three lose the image. A fix must cover the native path (real
`McpImageContent`) and degrade honestly on the external path.

## Other image surfaces (context, not in scope)

User-message images are also placeholdered, at `request-builder.ts:201` and
`protobuf-request.ts:314`. The schema would support them: `UserMessage` has
`selectedContext` -> `selectedImages` (`agent_pb.ts:1823`, `:11178`), and
`SelectedImage` accepts `blobId`, inline `data`, or `blobIdWithData`
(`:10389`). No adapter code populates these. Out of scope for this unit; noted
so a later unit does not have to rediscover it.

## Size limits (hypothesis partly disproved)

There is no single whole-request cap and no cap inside `contentToText`. What
exists:

- tool catalog: 330 tools / 120,000 protobuf bytes (`request-builder.ts:29`) —
definitions only, not results;
- external root replay: 192 roots / 512 KiB with UTF-8 truncation marked
`…[truncated for Cursor external replay budget]` (`protobuf-request.ts:60`, `:122`);
- blob store: 16 MiB per blob, 4,096 entries, 64 MiB total, 15-minute TTL
(`native-exec.ts:81`);
- `requestScope` pins blobs for the in-flight request so eviction cannot
invalidate advertised ids; over-capacity raises `CursorBlobAdmissionError`
rather than truncating (`native-exec.ts:363`, `:374`).

Implication for `020`: adding real image bytes to results makes the blob and
replay budgets load-bearing, so the fix must bound image payloads deliberately
instead of trusting these limits to absorb them.

Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# 003 — Transport terminal decode

Source: `src/adapters/cursor/live-transport.ts`, `transport-retry.ts`,
`src/bridge.ts`. Verified by direct read plus an independent `gpt-5.6-sol`
audit that returned FAIL on this surface.

## Terminal settlement paths

`createTerminalSettler()` (`live-transport.ts:102`) is single-shot: the first
settle wins, later ones are ignored.

| Event | Classification |
|-------|----------------|
| Connect end-stream frame with error or malformed payload | fatal (`:891`, `:900`) |
| Successful `{}` trailer | **no settlement**; waits for HTTP/2 `end` (`:175`) |
| Nonzero `grpc-status` trailer | fatal (`:970`) |
| HTTP/2 `end` with leftover frame bytes | fatal `ConnectFrameError` **unless `expectedClose`** (`:1016`) |
| HTTP/2 `end` with zero frames | fatal unexpected EOF **unless `expectedClose`** (`:1024`) |
| HTTP/2 `end` with >=1 complete frame | **unconditional graceful finish** (`:1029`) |
| Socket/session error | fatal via `failAndClear` (`:824`, `:975`) |
| Socket error after intentional client-tool suspension | graceful, `expectedClose` (`:806`) |
| Abort | fatal unless `expectedClose` or already settled (`:1036`) |
| First-frame timeout (30s default, `:88`) | fatal (`:835`) |

A fatal settlement makes `run()` throw (`:619`); a graceful settlement only
marks the iterator done (`:597`).

## F1 — the clean-EOF gap (High)

The last row of that table is the defect. The `end` handler drains queued frame
work, then classifies: leftover bytes -> fail, zero frames -> fail, otherwise
`settler.settleFinish()` — **without consulting `state.terminated`, and without
asking whether the application-level `turnEnded` frame ever arrived**.

Consequences when Cursor's stream ends cleanly mid-turn:

1. `finalizeTurnEvents` never runs, so the fail-closed open-tool-call check at
`protobuf-events.ts:1361` — the code written for exactly this hazard — is
bypassed.
2. The adapter emits neither `done` nor `error`. Only pre-EOF text/reasoning
reached the bridge; the tool call was buffered and deferred, so it is simply
gone (`protobuf-events.ts:1249`).
3. Streaming Responses partly repairs this: a terminal-less adapter EOF becomes
`response.incomplete` with reason `adapter_eof` (`bridge.ts:1283`).
4. **Non-streaming does not.** With no error and no incomplete event, status
defaults to `"completed"` (`bridge.ts:1829`), so a truncated turn is reported
as a success.

This matches the external report of a `cursor-grok` stream ending with
`hasToolCalls: true` and partial content before `turnEnded` (see `004`).

## Retry behavior

The retry guard (`transport-retry.ts:99`) is correct and must not be loosened:

```ts
const canRetry =
!emittedAny &&
attempt < CURSOR_RETRY_ATTEMPTS - 1 &&
!signal?.aborted &&
requestUncommitted(transport) &&
isRetryableCursorError(err);
```

`emittedAny` is set before `onEvent()` (`:93`), so any emitted event blocks
retry — replay after partial emission would duplicate output. Note the second-
order consequence of F1: a clean EOF **throws nothing**, so the retry wrapper
returns success and never evaluates the guard at all (`:97`). Fixing F1 to
throw a typed error is what makes this path reachable in the first place.

## Idle and keep-alive

- First-frame deadline 30s (`:88`), cleared by the first raw `data` chunk (`:940`).
- No post-first-frame idle timeout in the transport.
- `clientHeartbeat` written upstream every 5s (`:1042`).
- The Responses bridge has a separate ~300s silence watchdog (`bridge.ts:1321`).

For a Responses-owned Computer Use call the transport normally emits `done` and
cancels Cursor before the client runs the minutes-long tool (`:737`), so tool
duration should not idle that stream. An inline native execution, or a call left
open awaiting completion, can still hit the 300s watchdog while 5s heartbeats
keep the socket alive — the socket is healthy and the turn is dead, which is the
worst shape for diagnosis.

Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
# 004 — External wire and format evidence

Gathered by a five-lane `gpt-5.6-luna` discovery swarm, then filtered here.
Public reverse-engineering of Cursor's protocol is **lead-grade, not primary**:
the repository's own generated `gen/agent_pb.ts` outranks all of it and is what
`002` relies on. These sources matter for behavior we cannot read from our tree.

## Load-bearing

**A cursor-grok stream is reported to end before `turnEnded` with tool calls in
flight.** A 2026-07-27 report for `cursor-grok-4.5-high` records
`hasToolCalls: true`, partial content, and "Cursor stream ended before
turnEnded", requiring manual continuation — attributed to the HTTP/2 stream
ending without the application-level frame.
<https://github.com/can1357/oh-my-pi/issues/6772> (lead)

This is independent corroboration that F1 is a real upstream behavior and not a
theoretical branch. It is the reason `010` treats clean EOF as a first-class
terminal state rather than an edge case.

**Cursor documents that MCP tool responses can return base64 images.**
<https://docs.cursor.com/context/model-context-protocol> (primary for product
behavior). Combined with `McpImageContent` in our generated schema, image
results are supported end to end, which is what makes `020` a passthrough fix
rather than a feature request.

**MCP specifies images in tool results.** `CallToolResult.content` is a
`ContentBlock[]` whose union includes `ImageContent` with base64 `data` and
`mimeType`. <https://modelcontextprotocol.io/specification/2025-11-25/schema> (primary)

**Naive gateway translation of image tool results fails loudly.** LiteLLM passed
Chat-Completions-style `image_url` content into a Responses `function_call_output`
and OpenAI rejected it, because Responses expects `input_image`.
<https://github.com/BerriAI/litellm/issues/17507> (lead). Design consequence for
`020`: emit the upstream's own image representation, never a foreign one.

**Bun reuses stale pooled keep-alive sockets without liveness checks**, so a
reaped connection hangs until Bun's ceiling instead of reconnecting.
<https://github.com/oven-sh/bun/issues/31894> (primary, closed "not planned").
Relevant to long-lived Cursor streams; not itself proven to be our defect.

## Context only

- **Protocol principle, adjacent transport.** In gRPC over HTTP/2, status
trailers are required for normal completion, and Connect's gRPC-web transport
fails loudly when the encoded trailer is missing.
<https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md> (primary),
<https://github.com/connectrpc/connect-es/issues/1115> (primary).
**Terminology caution:** this adapter speaks Connect framing with an encoded
end-stream envelope (`live-transport.ts:787-900`), not gRPC-web trailers, so
these sources supply the general principle — a body without its terminal is not
authoritative success — and not a statement about our exact wire. F1 rests on
source reading (`003`), not on these citations.
- xAI documents Grok 4.6 tool calling over streaming and synchronous modes.
<https://docs.x.ai/developers/tools/streaming-and-sync> (primary). No official
changelog naming grok-4.6 as dropping tool calls was found.
- A Grok-compatible path was reported returning empty `arguments` with the real
JSON in `partialJson`. <https://github.com/earendil-works/pi/issues/3131> (lead).
- Codex Computer Use is bridged through the `node_repl` runtime; several 2026
reports describe it being detected but unattached, with kernel resets.
<https://github.com/openai/codex/issues/21530> (lead). Some of the user's
observed instability may originate here rather than in our adapter — recorded
so we do not over-attribute every symptom to the Cursor path.

## Negative result

No public authoritative `.proto` confirming `McpSuccess` or
`McpToolResultContentItem` was found; available dumps use older
`ClientSideToolV2Call` terminology or are explicitly speculative. Our generated
schema remains the only trustworthy source, which is why `002` quotes it directly.

Loading
Loading