Repository navigation
refactor(cursor): decode hook payloads into typed structs - #133
Merged
Merged
Conversation
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>
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour 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>
Abhijeet Prasad (AbhiPrasad)
approved these changes
Oct 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The Cursor translator read every hook field with ad hoc
serde_json::Valuelookups: about 95.get("…")calls and noDeserializetypes. Each hook is now decoded once, at the start ofhandle, into types that match what Cursor actually sends.Valuenow appears only at the capture/journal boundary, plus the two exceptions noted below.cursor.rsmoves tocursor/mod.rs. The newtranslate/cursor/hooks.rsholds the payload types:Hook: the decoded hook, made of aCursorHookenum keyed by event name,HookCommon, and the daemon's_bt_transcript_mirrorreference.ShellHook,McpHook,FileEditHook,ReadFileHook).TranscriptRecord, which must be a user or assistant message or aturn_endedmarker.CursorHookinstead of event strings. The largereduce_hookarms 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 isunexpected cursor sessionEnd hook format: missing field final_status.handleanddrain_pendingdecode before changing any translator state and return the error.downcast_refthe error and treat it as a format change that may need a newerbt.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.
2026.10.01-e373342, and checked them against all six real captures.cwd,model_id,model_paramsstopandafterAgentResponseparent_tool_call_idsubagentStart.tool_call_idandsubagent_modelsubagentStop.summarysessionEnd.error_messagetool_use_idon specialized hooks, which the CLI doesn't sendsubagentStoprequiressubagent_id. Cursor's docs leave it out, but the CLI'sSubagentStopRequestQuerydefines 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/stopevents, but a saved transcript has no generation id, model or workspace roots. It now emits_bt_importStart,_bt_importCheckpointand_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.Valueis deliberately kept in two places:tool_input(a JSON object) andmodel_params.Behaviour
mainfor every real CLI capture.subagentStopis recorded as a terminal-only subagent span. It no longer reports amodel, becausesubagentStopdoesn't carry one.Testing
SpanOpoutput compared withmain.generation_id,model,workspace_roots). Each test's event-specific payload now carries the fields the CLI sends. Tool outputs are strings,sessionEndhasfinal_status, and tool hooks havetool_inputandduration.postToolUseFailure, which genuinely has no output.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.cargo clippy --all-features --all-targets -D warningsis clean.cargo test --all-features --no-fail-fastpasses everything excepttrace_runtime::tests::doctor_reports_a_daemon_that_{cannot_authenticate_the_route,resolves_other_credentials}, which fail the same way onmainin my local environment.This PR is independent of the reorganization stack (#134 → #140). #139 adds three lines to
translate/cursor.rs, and #140 touchestranscript_import/cursor.rs. Whichever lands second needs those small hunks carried across.🤖 Generated with Claude Code