Cursor hooks intermittently emit empty `conversation_id` / `session_id` on tool-execution events

Where does the bug appear (feature/product)?

Cursor IDE

Describe the Bug

Summary

We consume Cursor’s agent hooks to attribute agent activity to the conversation that produced it. We’re seeing Cursor intermittently deliver tool-execution hook events in which the entire conversation-identity block is blank — conversation_id: "", session_id: "", generation_id: "", model: "", transcript_path: null — even though the machine/user envelope (cursor_version, workspace_roots, user_email) and the tool payload (tool_name, tool_input, tool_use_id) on the same event are fully populated.

Because the events carry no conversation id, any downstream consumer cannot attribute them to the conversation they belong to. We’ve mitigated this on our side, but the empty ids originate in the Cursor client, so we’re reporting it upstream. We were not able to reproduce it on demand; the strongest evidence is a static analysis of the shipped bundle plus a consistent fingerprint across a large volume of real events.

Environment

  • Observed on Cursor desktop, notably 3.13.25, 3.13.10, and 3.9.16.
  • Other versions might be affected as well.

What we observe

  1. The whole identity block blanks atomically. Whenever conversation_id is empty, session_id, generation_id, and model are always empty too, and transcript_path is null — with zero exceptions in our data. It’s all-or-nothing, so the identity clearly comes from a single source that is either fully populated or fully empty.

  2. Only tool-execution hooks are affected. The blanks appear exclusively on:
    preToolUse, postToolUse, postToolUseFailure, beforeReadFile, afterFileEdit, beforeShellExecution, afterShellExecution, beforeMCPExecution, afterMCPExecution.
    They never appear on conversation-lifecycle hooks:
    beforeSubmitPrompt, afterAgentResponse, afterAgentThought, preCompact, sessionStart, sessionEnd, subagentStart.
    In practice this means a conversation’s prompts/responses/thoughts are correctly attributed while the tool calls inside that same conversation can be orphaned.

  3. The whole invocation is context-less, not a mid-flight loss. For a single tool invocation, the before… and after… hooks blank together (e.g. beforeShellExecution and afterShellExecution blank in equal counts). So the invocation is initiated without an id — it isn’t losing the id partway through.

  4. It’s tool-agnostic. The blank rate is roughly uniform across tool types (file reads, shell, search, writes all blank at a similar low baseline). Whatever tool happens to fire during a blank window is affected; it isn’t specific to one subsystem.

  5. It’s intermittent and per-user-variable. Within a single user and a single event type, some events carry a real id and some don’t.

Strong correlation we found (reported as a lead, not a confirmed cause)

We found a very strong correlation with compaction happening mid-turn: in the affected cases, the empty ids begin immediately after a preCompact that fires during an agent turn, and then every subsequent tool call stays blank until the next user prompt, at which point ids are restored. Lifecycle events (thoughts/responses) emitted during that same window keep the correct id — only the tool events blank.

We want to be explicit that we are not claiming preCompact/compaction is the root cause:

  • We could not reproduce the blanking on demand, including by deliberately triggering compaction many times (in normal usage and in a dedicated harness). Compaction is harmless for the vast majority of users/sessions in our data.
  • So compaction may be correlated with, rather than causing, the underlying context loss — e.g. it may just be the most common event that coincides with the gated path being active, or it may perturb some shared state that is the real culprit. There may also be additional, unrelated triggers.

We’re sharing it because the correlation is high and may help you localize the state that gets dropped.

What we ruled out

  • Our own ingestion / plugin. Our hook dispatcher is a verbatim pass-through — it reads the hook body from stdin and forwards it byte-for-byte without parsing or rewriting any field. The empty strings are present in exactly what Cursor writes to the hook’s stdin.
  • Privacy/Ghost mode, subagents, model provider / enterprise (Bedrock) config, cross-tool Claude Code hook loading, and restart/resume — each independently checked and excluded (e.g. subagent lifecycle events never blank, and users with zero subagent activity still blank tool calls; blanks span multiple providers; prompts never blank, which rules out session-wide resume/privacy explanations).

Code-level findings (decompiled 3.13.25 bundle)

This is likely the most useful part for you. Static inspection of the shipped renderer bundle (out/vs/workbench/workbench.desktop.main.js) and the agent-exec extension bundle (extensions/cursor-agent-exec/dist/main.js) shows two different identity sources for the two hook families, and a hard-coded empty fallback on the tool-execution side:

  • Lifecycle hooks read the id straight off the live composer, e.g.:

    executeHookForStep(dh.afterAgentThought, { conversation_id: this.composerDataHandle.data.composerId, ... })
    

    These are the hooks that never blank.

  • Tool-execution hooks build their payload from a shared exec/resource context that defaults to empty string when the slot isn’t populated, e.g.:

    executeHookForStep(dh.beforeReadFile, { conversation_id: m ?? "", generation_id: v ?? "", model: b ?? "", ... })
    // agent-exec base hook extractor:  conversation_id: <ctx>.conversationId ?? ""
    
  • There is a hard-coded empty-identity fallback used when the stream-metadata context slot is unset (in the exec-instance factory):

    { conversationId: "", composerId: "", requestId: "", modelName: "" }
    

    This exactly matches the atomic all-empty signature we see in production.

  • Symbol drift on the context key. The bundle contains both a registry symbol Symbol.for("cursor.agentStreamMetadata") (×1) and plain, module-local Symbol("agentStreamMetadata") (×2). A writer keying the context off one symbol and a reader looking it up under a different same-named symbol would miss and hit the empty fallback above — a plausible mechanism for an intermittent, context-dependent blank.

  • A bare-context summarization path (summarize(Kg())) is present, which is consistent with (though not proof of) the compaction correlation above: a turn that continues after a mid-turn summarization may run on a context that was never re-seeded with the stream metadata.

Our working hypothesis, stated as a hypothesis: the tool-execution hook path reads conversation identity from a context/metadata slot that is occasionally unset for a gated cohort, and when unset it silently falls back to empty strings instead of the live conversation id. Compaction mid-turn appears to be a strong correlate of entering that state, but we haven’t proven causation.

Steps to Reproduce

We built a local harness that captures every hook’s raw payload and drove Cursor 3.13.25 through: foreground agent with sandbox on and off, Cmd-K inline edits, compaction/summarize (fired repeatedly), interrupt→resume, parallel tool bursts, background shells, and long-running MCP calls. Across ~400 tool events we captured 0 blanks, which suggests the buggy path is enabled for a cohort our machine isn’t in — reinforcing the feature-gate/rollout reading.

Operating System

MacOS

Version Information

  • Observed on Cursor desktop, notably 3.13.25, 3.13.10, and 3.9.16.
  • Other versions might be affected as well.

Does this stop you from using Cursor

No - Cursor works, but with this issue

Hey!

Confirming: empty conversation_id on tool-execution hooks isn’t intended behavior, and it isn’t anything in your config. I’ve been able to reproduce a degraded variant on my end (identity fields partially empty on some execution paths) but not the full blank you’re seeing, so I want to pin down which one you’re hitting before this goes further.

One thing I can rule out: the repeated symbol names you spotted are intentionally distinct keys living in separate layers, so that’s not the source.

Three things from your logs would localize this quickly:

  1. Partial blanks? Do you ever see conversation_id populated but model empty (or transcript_path null), or is it always all-or-nothing? This is the single most useful signal - it separates two very different causes.
  2. Blanks without compaction? Do blanks ever occur in a session where no preCompact fired at all? You noted a strong correlation, but even a handful of blank windows with zero compaction would rule it out as the trigger.
  3. How concentrated is it per user? A few users blanking heavily vs. everyone blanking a little points at very different things.

No timeline to share yet, but I’ll post here as soon as I have something.

This reads like a client-side context-propagation bug rather than anything you can fix downstream: the identity block is clearly pulled from a single conversation-context object at hook-dispatch time, and on the tool-execution path that object is sometimes unset, so you get the atomic all-empty fingerprint you described. The fact that only tool hooks are affected and that before/after blank in equal pairs supports that the tool invocation is dispatched from an execution path that never had the context attached, not that the context is dropped mid-flight.

One thing worth checking against your data before the team looks at it: whether the blank events correlate with subagent runs, background/composer-spawned agents, or tool calls that resume after a compaction or window reload. Those are the paths most likely to run tool execution on a worker that doesn’t carry the parent conversation handle, and if the blank rate is concentrated there it narrows the repro a lot. You mentioned subagentStart is never blank, but the tool calls made *inside* a subagent may still be the ones losing identity, which would fit.

For mitigation on your side, if you aren’t already doing it: tool_use_id plus the pid/workspace_root and a short time window is usually enough to stitch an orphaned tool event back to the last conversation-lifecycle event seen for that workspace, since tool calls can’t outlive the conversation that issued them by much. It’s heuristic and it will mis-attribute if a user runs two agents concurrently in the same workspace, so keep the attribution flagged as inferred rather than authoritative.

Worth adding your Cursor request IDs and the affected version numbers to the report if you have them - that’s usually what gets these picked up rather than the static analysis alone.

Thanks for your response!

  1. Partial vs all-or-nothing — all-or-nothing on our side. Every blank event we have has conversation_id, session_id, generation_id and model empty together, with transcript_path null — zero cases where the ids were blank but generation_id/model survived. So we are NOT seeing the degraded/partial variant you reproduced; ours is the full atomic blank every time. That divergence seems important: you may be hitting a different (or earlier) point on the same failure than we are.

  2. Blanks without compaction — we do see blank tool events in time slices where no preCompact appears, but we can’t treat that as a clean rule-out. A single user might has multiple sessions active at once, so when we slice by time, a blank that looks “compaction-free” may actually belong to a different session that did compact around then. Concretely: one long session where the issue is happening, plus a fresh second chat — send a message in the fresh chat while the first is hard at work and a “compaction-free” blank shows up in the data, but it may really belong to the first session. So these exist, but they’re untrusted for attribution — they might be relevant, or they might belong to another session entirely.

  3. Concentration — we see it across several users, but it’s clearly not a per-user or per-install condition. For the very same user and the very same event type, some events come through with full ids and some blank — it flips back and forth within a user’s activity. So this isn’t the kind of thing a reinstall or a corrupted local state would explain; it’s intermittent for an otherwise “normal” user, not a broken subset of users. It does cluster on some users more than others, but that likely just reflects how they use Cursor (e.g. rarely starting new conversations and reusing one long session, or working on very large-context tasks) rather than anything wrong with their setup — we can’t infer their actual usage from what we have.

Happy to help however we can to get this pinned down - tell me if you need anything else.

Appreciate the analysis — it matches our read: a single conversation-context object read at hook-dispatch time, unset on the tool-exec path, giving the atomic all-empty fingerprint with before/after blanking in equal pairs.

On the paths you flagged:

- Subagents aren’t required. We have affected users with zero subagentStart events who still blank tool calls, so subagent-inner execution can’t be the whole story. We can’t cleanly isolate background/composer-spawned agents from our data, so I can’t rule those out — but the dominant correlate we can see is mid-turn compaction (strong, though not necessary).

- Compaction-resume fits what we see: blanks begin right after a mid-turn preCompact and persist until the next user prompt re-seeds — i.e. tool execution continuing on something that never re-attached the parent handle.

On mitigation: we’ve had to put a stopgap in place, but it’s only damage control, not a fix — once the identity block leaves Cursor empty, nothing downstream can reliably recover it. We can’t rely on a time-based window to re-attach orphaned events, because a single user can have more than one active session at a time (a time window would mis-attribute across concurrent sessions). tool_use_id can’t recover the session either — it only appears on the pre/postToolUse events, which are exactly the ones arriving with no conversation/session id when the bug hits. So this needs a client-side fix — that’s the only place the correct id still exists, and until then real agent activity is being lost.

On request IDs: hook payloads don’t carry a Cursor request id, and the closest field (generation_id) is empty precisely on the blank events; several affected users also run privacy mode. So we can’t attach request IDs for the blank events themselves. Regarding versioning, we saw that issue on 3.13.25, 3.13.10, and 3.9.16but other versions may be affected as well.

Thanks for your help!

Hey @rogue-yuval

I tried to force a mid-turn blank locally (long Agent turn + hooks logger) and also couldn’t get an atomic empty identity window, so I’m still hunting the production trigger. A few things from the fleet logs would help a lot:

  1. Same-conversation strip (best ask). For one blank window, can you paste a short redacted sequence (5–15 events) filtered to a single conversation: the last populated tool hook → any preCompact (if present) → the first blank tool hook → one lifecycle event (afterAgentThought / afterAgentResponse) in that same window? Timestamps + hook_event_name + the identity fields are enough; strip prompts/paths/emails.

  2. Does the blank start mid-turn? In those windows, do tool hooks start the turn with real IDs and only go empty later, or is the first tool call of the turn already blank?

  3. Surface / topology (if you can tell from installs). When blanks show up, is it mostly classic Editor Agent, Agents Window, Remote SSH/WSL, or mixed? Even a rough split helps.

  4. cursor_version on the blank events themselves. From the hook payload’s cursor_version field, which versions show up on blank events vs populated ones (counts or top few is fine)?

  5. Optional: Cursor Settings → Hooks → Execution Log around one blank window, if anything looks off on Cursor’s side for those calls.

No rush - whenever you have a strip for (1), that’s the highest signal.

Thanks — this is really helpful. Answers to all five below.

1) Same-conversation strip

One blank window from a single conversation, redacted to identity fields. First, the identity block in detail around the transition — note the whole block goes empty atomically, and that at the compaction the generation_id/model switch to the summarizer (gemini-2.5-flash):

time            hook                tool   conversation_id  session_id   generation_id      model              transcript_path
21:17:34.683    postToolUse         Shell  5bb4f256…        5bb4f256…    8f833ac7…          composer-2.5       present
21:17:43.392    afterAgentResponse         5bb4f256…        5bb4f256…    8f833ac7…          composer-2.5-fast  present
21:18:25.426    preCompact                 5bb4f256…        5bb4f256…    587d642b…          gemini-2.5-flash   present
21:19:01.827    preToolUse          Write  (empty)          (empty)      (empty)            (empty)            null
21:19:02.944    postToolUse         Write  (empty)          (empty)      (empty)            (empty)            null
21:19:13.270    afterAgentThought          5bb4f256…        5bb4f256…    587d642b…-0-reqx   composer-2.5-fast  present

And the wider timeline (conversation/session/tool_use_id only), showing the blank window opening right after the mid-turn preCompact and closing exactly at the next user prompt:

time            hook                tool   conversation_id  session_id   tool_use_id
21:17:32.280    postToolUse         Write  5bb4f256…        5bb4f256…    tool_7f222679…
21:17:34.683    postToolUse         Shell  5bb4f256…        5bb4f256…    c432f46d…
21:17:43.392    afterAgentResponse         5bb4f256…        5bb4f256…
21:18:25.426    preCompact                 5bb4f256…        5bb4f256…
21:19:01.827    preToolUse          Write  (empty)          (empty)      ""  (empty)
21:19:02.327    afterFileEdit              (empty)          (empty)
21:19:02.944    postToolUse         Write  (empty)          (empty)      ""  (empty)
21:19:13.270    afterAgentThought          5bb4f256…        5bb4f256…
21:19:13.338    preToolUse          Grep   (empty)          (empty)      tool_146c7efd…
21:19:14.215    postToolUse         Grep   (empty)          (empty)      tool_146c7efd…
21:19:17.036    afterAgentThought          5bb4f256…        5bb4f256…
21:19:17.084    preToolUse          Read   (empty)          (empty)      tool_a23dd79a…
     … tool hooks stay blank while afterAgentThought stays populated, for ~3 more minutes …
21:22:14.491    postToolUse         Write  (empty)          (empty)      tool_ccf9f182…
21:22:17.315    afterAgentThought          5bb4f256…        5bb4f256…
21:23:48.987    beforeSubmitPrompt         5bb4f256…        5bb4f256…
21:23:58.542    preToolUse          Read   5bb4f256…        5bb4f256…    tool_51a1078d…
21:24:00.798    postToolUse         Read   5bb4f256…        5bb4f256…    tool_51a1078d…
21:24:03.170    postToolUse         Write  5bb4f256…        5bb4f256…    tool_51a1078d…

Two things to call out: lifecycle hooks (afterAgentThought) keep the real id all the way through the blank window; and tool_use_id on the blank tool calls is sometimes a real id and sometimes empty ("") too.

2) Does the blank start mid-turn?

Mid-turn — not at turn start. The turn ran fully populated through the afterAgentResponse at 21:17:43; preCompact fired at 21:18:25; and from the very next tool call (21:19:01) every tool hook was blank, continuously, until the next beforeSubmitPrompt at 21:23:48 restored them — a ~4.5-minute window bounded exactly by [mid-turn compaction → next prompt]. The first tool call of a turn is essentially never the blank one; blanking begins after the compaction and clears on the next prompt.

3) Surface / topology

This is macOS, so Remote SSH/WSL is ruled out. Beyond that we can’t give you a split — the hook payload doesn’t carry a reliable surface/topology indicator, so we can’t distinguish classic Editor Agent vs Agents Window from our side.

4) cursor_version on the blank events

Reading cursor_version off the blank events themselves, we see them across several versions (listed by blank volume, most first):

3.14.7
3.13.25
3.13.10
3.9.16
3.11.19
3.11.13

The newer, more widely-used versions have the most blanks — probably just because most people keep Cursor updated, so the counts accumulate there. So we read this as adoption-weighted rather than a clean version regression, and other versions may well be affected too.

5) Execution log

We can’t get this one — these are events from a customer’s machine, so we don’t have access to their Cursor settings or local execution log.