agents: stream cursor InteractionUpdate deltas (text, thinking, tool, shell, token, summary) #951

Closed
opened 2026-05-08 12:04:33 +00:00 by claude-desktop · 0 comments
Collaborator

User story

As an operator watching a provider=cursor task, I want streamed deltas (assistant text typing, thinking deltas, tool start/partial/complete, live shell output, token counter ticking) instead of waiting for whole-message events, so the dashboard feels live and matches the Cursor app's responsiveness.

Context

@cursor/sdk exposes a finer-grained delta stream alongside Run.stream(). From dist/cjs/types/delta-types.d.ts:

TextDeltaUpdate, ThinkingDeltaUpdate, ThinkingCompletedUpdate
ToolCallStartedUpdate, PartialToolCallUpdate, ToolCallCompletedUpdate
ShellOutputDeltaUpdate
SummaryStartedUpdate, SummaryCompletedUpdate, SummaryUpdate
TokenDeltaUpdate
TurnEndedUpdate, UserMessageAppendedUpdate
InteractionListener

Today cursor-sdk-adapter.ts only consumes Run.stream() (whole SDKMessage events). Issue #950 closes the visibility gap at message granularity; this issue takes the next step — sub-message streaming.

The Claude Code SDK does not expose deltas of equivalent fidelity, so the port should make these events optional (provider may or may not emit them). The runner + UI must handle both cases gracefully (claude-code shows whole-turn events; cursor shows live deltas).

Acceptance criteria

Port (claude-port.ts) — new optional TaskEvent variants

  • TextDeltaEvent { type: "text_delta", sessionId, text }
  • ThinkingDeltaEvent { type: "thinking_delta", sessionId, text }
  • ToolCallStartedEvent { type: "tool_call_started", sessionId, callId, toolName, args }
  • ToolCallProgressEvent { type: "tool_call_progress", sessionId, callId, partial: unknown } — for PartialToolCallUpdate
  • ToolCallCompletedEvent { type: "tool_call_completed", sessionId, callId, toolName, result, ok }
  • ShellOutputDeltaEvent { type: "shell_output_delta", sessionId, callId, stream: "stdout"|"stderr", text }
  • UsageDeltaEvent { type: "usage_delta", sessionId, deltaInput, deltaOutput } — for TokenDeltaUpdate
  • SummaryEvent { type: "summary", sessionId, phase: "started"|"completed", text? } — for SummaryStartedUpdate / SummaryCompletedUpdate
  • TurnEndedEvent { type: "turn_ended", sessionId } — to demarcate logical turns in a long-running session
  • Existing AssistantTurn / ToolSummaryEvent etc. stay; cursor stops emitting them when delta path is active and emits the new ones instead. claude-code keeps emitting the whole-turn events.

Cursor adapter (cursor-sdk-adapter.ts)

  • Replace the Run.stream() consumer with agent.onInteraction(...) (or whatever the cursor SDK names the interaction-listener API — verify against the published types). Keep Run.stream() as a fallback if interaction listener is unavailable on the active SDK version.
  • Wire each InteractionUpdate subtype to the matching new TaskEvent. Use the callId from ToolCallStartedUpdate so partial / completed updates can be correlated to the started event.
  • Handle interleaving: a ShellOutputDeltaUpdate must include the callId of the in-flight Shell tool call so the UI can append to the right output pane.

Event log (event-log.ts)

  • New cases for each new event type. Persist them as event-log rows so the timeline replays correctly on reload. Keep memory caps in mind — MAX_EVENTS_PER_TASK = 500 may be too low once deltas stream; consider per-event-type sub-caps (text deltas coalesced into the most recent assistant turn, shell deltas truncated to last N KB per tool call) rather than blindly raising the global cap.

Coalescing

  • Frontend-friendly coalescing: emit a per-callId "rolling state" over SSE (e.g. accumulate ShellOutputDeltaEvent + ToolCallProgressEvent into a single mutable record on the server, broadcast deltas as patches). Otherwise SSE bandwidth + client render cost spikes on long shell outputs.
  • Document the wire format for the new events in docs/api.md SSE section.

Tests

  • Unit tests for the cursor adapter's InteractionUpdateTaskEvent mapping, fixtures per delta type.
  • Integration test: feed a synthetic interaction stream and assert the event log ends up with the expected rows + final state.
  • Backpressure test: 10k shell output deltas must not OOM the worker or blow past memory caps.

Out of scope

  • Frontend rendering of the new events — covered by a separate dashboard issue (typed tool-call widgets / live shell pane / token meter).
  • Claude Code synthesizing equivalent deltas — separate issue (shell_output_delta for claude path via docker logs -f).
  • Cost computation from the new usage_delta — separate cost issue.

References

  • Cursor SDK delta types: node_modules/.bun/@cursor+sdk@1.0.12/node_modules/@cursor/sdk/dist/cjs/types/delta-types.d.ts
  • Parent issue: #950
  • Adapter: apps/server/src/infrastructure/agent/cursor-sdk-adapter.ts
  • Port: apps/server/src/infrastructure/agent/claude-port.ts
  • Event log: apps/server/src/infrastructure/event-log.ts
## User story As an operator watching a `provider=cursor` task, I want streamed deltas (assistant text typing, thinking deltas, tool start/partial/complete, live shell output, token counter ticking) instead of waiting for whole-message events, so the dashboard feels live and matches the Cursor app's responsiveness. ## Context `@cursor/sdk` exposes a finer-grained delta stream alongside `Run.stream()`. From `dist/cjs/types/delta-types.d.ts`: ``` TextDeltaUpdate, ThinkingDeltaUpdate, ThinkingCompletedUpdate ToolCallStartedUpdate, PartialToolCallUpdate, ToolCallCompletedUpdate ShellOutputDeltaUpdate SummaryStartedUpdate, SummaryCompletedUpdate, SummaryUpdate TokenDeltaUpdate TurnEndedUpdate, UserMessageAppendedUpdate InteractionListener ``` Today `cursor-sdk-adapter.ts` only consumes `Run.stream()` (whole `SDKMessage` events). Issue #950 closes the visibility gap at message granularity; this issue takes the next step — sub-message streaming. The Claude Code SDK does not expose deltas of equivalent fidelity, so the port should make these events **optional** (provider may or may not emit them). The runner + UI must handle both cases gracefully (claude-code shows whole-turn events; cursor shows live deltas). ## Acceptance criteria ### Port (`claude-port.ts`) — new optional `TaskEvent` variants - [ ] `TextDeltaEvent { type: "text_delta", sessionId, text }` - [ ] `ThinkingDeltaEvent { type: "thinking_delta", sessionId, text }` - [ ] `ToolCallStartedEvent { type: "tool_call_started", sessionId, callId, toolName, args }` - [ ] `ToolCallProgressEvent { type: "tool_call_progress", sessionId, callId, partial: unknown }` — for `PartialToolCallUpdate` - [ ] `ToolCallCompletedEvent { type: "tool_call_completed", sessionId, callId, toolName, result, ok }` - [ ] `ShellOutputDeltaEvent { type: "shell_output_delta", sessionId, callId, stream: "stdout"|"stderr", text }` - [ ] `UsageDeltaEvent { type: "usage_delta", sessionId, deltaInput, deltaOutput }` — for `TokenDeltaUpdate` - [ ] `SummaryEvent { type: "summary", sessionId, phase: "started"|"completed", text? }` — for `SummaryStartedUpdate` / `SummaryCompletedUpdate` - [ ] `TurnEndedEvent { type: "turn_ended", sessionId }` — to demarcate logical turns in a long-running session - [ ] Existing `AssistantTurn` / `ToolSummaryEvent` etc. stay; cursor stops emitting them when delta path is active and emits the new ones instead. claude-code keeps emitting the whole-turn events. ### Cursor adapter (`cursor-sdk-adapter.ts`) - [ ] Replace the `Run.stream()` consumer with `agent.onInteraction(...)` (or whatever the cursor SDK names the interaction-listener API — verify against the published types). Keep `Run.stream()` as a fallback if interaction listener is unavailable on the active SDK version. - [ ] Wire each `InteractionUpdate` subtype to the matching new `TaskEvent`. Use the `callId` from `ToolCallStartedUpdate` so partial / completed updates can be correlated to the started event. - [ ] Handle interleaving: a `ShellOutputDeltaUpdate` must include the `callId` of the in-flight `Shell` tool call so the UI can append to the right output pane. ### Event log (`event-log.ts`) - [ ] New cases for each new event type. Persist them as event-log rows so the timeline replays correctly on reload. Keep memory caps in mind — `MAX_EVENTS_PER_TASK = 500` may be too low once deltas stream; consider per-event-type sub-caps (text deltas coalesced into the most recent assistant turn, shell deltas truncated to last N KB per tool call) rather than blindly raising the global cap. ### Coalescing - [ ] Frontend-friendly coalescing: emit a per-`callId` "rolling state" over SSE (e.g. accumulate `ShellOutputDeltaEvent` + `ToolCallProgressEvent` into a single mutable record on the server, broadcast deltas as patches). Otherwise SSE bandwidth + client render cost spikes on long shell outputs. - [ ] Document the wire format for the new events in `docs/api.md` SSE section. ### Tests - [ ] Unit tests for the cursor adapter's `InteractionUpdate` → `TaskEvent` mapping, fixtures per delta type. - [ ] Integration test: feed a synthetic interaction stream and assert the event log ends up with the expected rows + final state. - [ ] Backpressure test: 10k shell output deltas must not OOM the worker or blow past memory caps. ## Out of scope - Frontend rendering of the new events — covered by a separate dashboard issue (typed tool-call widgets / live shell pane / token meter). - Claude Code synthesizing equivalent deltas — separate issue (`shell_output_delta` for claude path via `docker logs -f`). - Cost computation from the new `usage_delta` — separate cost issue. ## References - Cursor SDK delta types: `node_modules/.bun/@cursor+sdk@1.0.12/node_modules/@cursor/sdk/dist/cjs/types/delta-types.d.ts` - Parent issue: #950 - Adapter: `apps/server/src/infrastructure/agent/cursor-sdk-adapter.ts` - Port: `apps/server/src/infrastructure/agent/claude-port.ts` - Event log: `apps/server/src/infrastructure/event-log.ts`
Sign in to join this conversation.
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
charles/claude-hooks#951
No description provided.