Skip to content

refactor(cursor): decode hook payloads into typed structs - #133

Merged
Stephen Belanger (Qard) merged 4 commits into
mainfrom
refactor/cursor-typed-hooks
Oct 6, 2026
Merged

Stephen Belanger (Qard) merged 4 commits into
mainfrom
refactor/cursor-typed-hooks

Conversation

@Qard

@Qard Stephen Belanger (Qard) commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

The Cursor translator read every hook field with ad hoc serde_json::Value lookups: about 95 .get("…") calls and no Deserialize types. Each hook is now decoded once, at the start of handle, into types that match what Cursor actually sends. Value now appears only at the capture/journal boundary, plus the two exceptions noted below.

  • Typed payloads. cursor.rs moves to cursor/mod.rs. The new translate/cursor/hooks.rs holds the payload types:
    • Hook: the decoded hook, made of a CursorHook enum keyed by event name, HookCommon, and the daemon's _bt_transcript_mirror reference.
    • One struct per event, plus one per specialized hook (ShellHook, McpHook, FileEditHook, ReadFileHook).
    • TranscriptRecord, which must be a user or assistant message or a turn_ended marker.
  • Dispatch on the enum. The translator matches on CursorHook instead of event strings. The large reduce_hook arms become methods.

Strict decoding

A hook that doesn't match its expected shape fails with a translate::decode::DecodeError { source, what, error }. That covers a missing required field, a field of the wrong type, and an unexpected transcript record. An example message is unexpected cursor sessionEnd hook format: missing field final_status.

  • No partial state. handle and drain_pending decode before changing any translator state and return the error.
  • Typed error. Error handling (SDK-471) can downcast_ref the error and treat it as a format change that may need a newer bt.
  • Plain serde. There are no custom deserializers. Unknown fields are ignored. Unknown event names reach no handler, as before.

Required vs optional. The rule is: document every field the translator reads; make it required when Cursor always sends it, and optional only when Cursor sometimes omits it.

  • Source of truth. The shapes come from the Cursor CLI's own hook code, not the docs. I inspected the hook request protobuf schemas and payload builders bundled in CLI 2026.10.01-e373342, and checked them against all six real captures.
  • Optional fields:
    • cwd, model_id, model_params
    • the token counts on stop and afterAgentResponse
    • parent_tool_call_id
    • subagentStart.tool_call_id and subagent_model
    • subagentStop.summary
    • sessionEnd.error_message
    • tool_use_id on specialized hooks, which the CLI doesn't send
  • Docs vs CLI. subagentStop requires subagent_id. Cursor's docs leave it out, but the CLI's SubagentStopRequestQuery defines it as a non-optional field and the payload builder always sets it.

Import events are typed separately. Transcript import used to synthesize live sessionStart/stop events, but a saved transcript has no generation id, model or workspace roots. It now emits _bt_importStart, _bt_importCheckpoint and _bt_importStop, each typed with only the fields a transcript provides. The _bt_ prefix is the daemon's reserved namespace, so these names can't collide with a future native Cursor hook.

Value is deliberately kept in two places:

  • Opaque native data forwarded verbatim: tool_input (a JSON object) and model_params.
  • Hashed payloads: the raw payload and transcript lines wherever they are hashed for deduplication fingerprints and late-merge keys. Because of this, span IDs and merge keys are unchanged.

Behaviour

  • Unchanged output: byte-identical to main for every real CLI capture.
  • Malformed hooks fail: a hook missing a field the CLI always sends, or with a wrongly typed field, now makes the translator return an error. Previously it produced spans with that data missing.
  • Subagent stops: an unmatched subagentStop is recorded as a terminal-only subagent span. It no longer reports a model, because subagentStop doesn't carry one.

Testing

  • Real captures: all seven real-capture tests produce byte-identical SpanOp output compared with main.
  • Synthetic tests: the helpers fill in the fields the CLI sends with every hook (generation_id, model, workspace_roots). Each test's event-specific payload now carries the fields the CLI sends. Tool outputs are strings, sessionEnd has final_status, and tool hooks have tool_input and duration.
    • The missing-tool-result test now uses postToolUseFailure, which genuinely has no output.
    • The import tests use the import events.
  • New tests: wrongly typed fields, missing required fields (prompt, tool_use_id, final_status, subagent_id), a malformed transcript record, and a specialized hook missing its result each fail with the expected error.
  • Lint: cargo clippy --all-features --all-targets -D warnings is clean.
  • Full suite: cargo test --all-features --no-fail-fast passes everything except trace_runtime::tests::doctor_reports_a_daemon_that_{cannot_authenticate_the_route,resolves_other_credentials}, which fail the same way on main in my local environment.

This PR is independent of the reorganization stack (#134 → #140). #139 adds three lines to translate/cursor.rs, and #140 touches transcript_import/cursor.rs. Whichever lands second needs those small hunks carried across.

🤖 Generated with Claude Code

The Cursor translator read every hook field with ad hoc `Value` lookups.
Hooks are now parsed once per event into a `CursorHook` enum with a
payload struct per event family, plus `HookCommon` for fields shared by
every hook. Saved transcript lines decode into `TranscriptRecord`.

`Value` remains only where the data is opaque and forwarded verbatim
(tool inputs and outputs, model params, specialized results) and where
the raw payload is hashed for deduplication and late-merge keys, so
span IDs are unchanged.

The new `translate::lenient` deserializers treat a scalar field with an
unexpected JSON type as absent rather than rejecting the event, so the
other translators can adopt the same approach.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ⚠️ Failed 2026-10-06T12:10:01.483169Z 57c3305 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Treating a wrongly typed field as absent hid format changes and produced
incomplete spans. Hook payloads and transcript records now decode with
plain serde types, so an unexpected type fails the event. handle() and
drain_pending() return the error before changing any translator state.

Failures are a typed `translate::decode::DecodeError` naming the agent
and the event or record, so error handling can tell a format change from
other translation failures. Unknown fields and events are still ignored,
and a JSON null still reads as absent.

The lenient field deserializers are removed. The partial-usage test no
longer relies on negative token counts being dropped, and new tests
cover wrongly typed hook fields and transcript records.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Optional fields let a hook missing expected data decode anyway and
produce incomplete spans. The payload types now follow what the Cursor
CLI actually builds for each hook, taken from its bundled hook request
schemas and payload builders (2026.10.01) and checked against the real
captures. A field the CLI always sends is required. Only fields it
omits in some cases (cwd, model_id, model_params, token counts,
parent_tool_call_id, subagent tool_call_id, summary) stay optional.
`subagentStop` requires `subagent_id`: the CLI always sends it, even
though Cursor's documentation leaves it out.

Each specialized hook has its own struct, tool results are typed
strings, file edits are typed, and saved transcript lines must be a
user or assistant message or a `turn_ended` marker.

Transcript import no longer reuses the live `sessionStart` and `stop`
names for events it synthesizes without generation ids or models. It
emits `importStart`, `importCheckpoint` and `importStop`, each typed
with the fields a saved transcript provides.

Output for the real captures is unchanged. Synthetic test payloads now
carry the fields the CLI sends with every hook.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Unprefixed names like `importStart` could collide with a future native
Cursor hook. The synthesized transcript import events are now
`_bt_importStart`, `_bt_importCheckpoint` and `_bt_importStop`, in the
daemon's reserved `_bt_` namespace.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@Qard
Stephen Belanger (Qard) merged commit 121b214 into main Oct 6, 2026
20 checks passed
@Qard
Stephen Belanger (Qard) deleted the refactor/cursor-typed-hooks branch October 6, 2026 14:19
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants