Skip to content

[Bug]: Cursor shell commands never stream live output #30228

Closed

Description

@Zburgers

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Use the Cursor provider (@cursor/sdk 1.2.0) and start a turn.
  2. Ask the agent to run a command that prints over several seconds, e.g. for i in 1 2 3 4 5; do echo "tick $i"; sleep 2; done.
  3. Expand the command row while the command is still running.

Expected behavior

Output appears incrementally while the command runs, the same way it does for Codex and Claude Agent. The expanded row should show tick 1, tick 2, … as they are printed.

Actual behavior

The expanded command row stays empty for the whole run. The complete output appears at once when the tool call finishes, so a long command looks hung.

This affects every Cursor shell command, including a real ~3 minute remote deploy where the whole log landed only at completion.

Impact

Major degradation or frequent failure

Version or commit

main @ cefb544 (also reproduced on 0.1.7-nightly.20261103.4119)

Environment

Linux, web client, Cursor provider via @cursor/sdk 1.2.0, default Cursor model, local mode.

Logs or stack traces

Redacted reproduction log. The command emits 5 lines over ~10s; T3 shows nothing until the end.

[12:00:00] INFO  running command
[12:00:10] INFO  command finished (5 lines of stdout)

# T3 UI while running: command row expanded, no output
# T3 UI after completion: all 5 lines appear at once

Root cause is in the adapter, not the transport. Cursor sends the delta as a protobuf oneof:

{ "case": "stdout", "value": { "data": "tick 1\n" } }
{ "case": "stderr", "value": { "data": "" } }
{ "case": "exit",   "value": { "code": 0, "aborted": false } }

packages/provider-cursor/src/server/adapter.ts (shellOutputText, ~L1975) only looks at
event.text, event.output, event.data, event.stdout, event.stderr, event.chunk, so it
returns "". shell-output-delta (~L2048) then returns early at:

const chunk = shellOutputText(update.event);
if (chunk.length === 0) return;

So command_output.delta is never emitted for Cursor, while the final output still arrives through
the completion fallback in emitToolArtifacts (cursorToolOutputText(toolCall) || streamedOutput).

The SDK does stream live: @cursor/sdk dist/esm/336.js runs handleShellStream concurrently with
the tool call and sends one shellOutputDelta update per stdout/stderr chunk.

Workaround

Run the command outside T3 (terminal/ssh) and paste the result, or keep the row collapsed and wait
for the completion output.

Activity

  1. Zburgers commented on Nov 3, 2026

    @Zburgers
    Author

    Proposed implementation plan

    Posted as the working plan for this issue. No code is implemented yet.

    Cursor live shell output — Implementation Plan

    Execute task by task. Each task is one TDD cycle and one commit.

    Goal: Cursor shell tool calls emit command_output.delta while the command runs, so an
    expanded command row streams output live instead of dumping everything on completion.

    Architecture: @cursor/sdk already emits one shell-output-delta interaction update per
    stdout/stderr chunk, shaped { case: "stdout" | "stderr", value: { data } }. T3 receives every
    update (adapter.ts → onDelta → handleInteractionUpdate) but shellOutputText reads flat keys,
    returns "", and adapter.ts:2055 drops the chunk. Fix the extractor; the hub, RPC, capability,
    and UI already work.

    Tech Stack: TypeScript, Effect, @effect/vitest, Cursor replay harness, vp.


    Constraints and non-goals

    • No changes to @cursor/sdk or node_modules. It is closed source and already correct.
    • No packages/contracts, apps/server, or apps/web changes. CommandOutputHub,
      subscribeCommandOutput, and CommandOutputPanel already stream for Codex/Claude.
    • Nested subagent shells stay unsupported: the SDK typings say shell deltas are not exposed for
      nested task updates (NestedTaskUpdateSchema comment).
    • Collapsed rows stay silent; that subscription behavior is unchanged.
    • Live output must not be persisted into the event store. The fixture asserts it.
    • exit payloads ({ code, aborted }) carry no text and must be ignored.

    Invariants the fix must preserve

    1. chunk is still appended to shell.streamedOutput (bounded tail, used as completion fallback).
    2. One command_output.delta event per non-empty chunk, with the same itemId derivation.
    3. Final persisted output still comes from cursorToolOutputText(toolCall) || streamedOutput.
    4. No behavior change for non-shell tools or for Codex/Claude adapters.

    Baseline (must fail before the fix)

    vp test run apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts \
      -t "command_output_streaming/cursor"

    Expected on main: FAIL — the running command must stream its output.


    Task 1 — Add the failing Cursor replay fixture

    Files:

    • Create: apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/cursor_transcript.ndjson
    • Modify: apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/output.ts:17-64
    • Modify: apps/server/src/orchestration-v2/testkit/fixtures/index.ts:1883-1905

    Step 1: Author the transcript

    The replay harness accepts hand-authored frames; model it on
    fixtures/turn_interrupt_mid_tool/cursor_transcript.ndjson. Required order:

    {"type":"transcript_start","provider":"cursor","protocol":"cursor-agent-sdk.local","version":"1","scenario":"command_output_streaming","metadata":{"generatedBy":"hand-authored","nativeAgentId":"agent-command-output-streaming"}}
    {"type":"expect_outbound","label":"agent.open","frame":{"type":"agent.open","operation":"create","options":{"model":{"id":"composer-2.5"},"hasName":true,"mode":"agent","local":{"hasCwd":true,"autoReview":false,"settingSources":["project","user","team","mdm","plugins"],"sandboxEnabled":true,"enableAgentRetries":true}}}}
    {"type":"emit_inbound","label":"agent.opened","frame":{"type":"agent.opened","agentId":"agent-command-output-streaming"}}
    {"type":"expect_outbound","label":"run.start:1","frame":{"type":"run.start","message":"<the COMMAND_OUTPUT_STREAMING_PROMPT text>","options":{"model":{"id":"composer-2.5"},"mode":"agent"}}}
    {"type":"emit_inbound","label":"run.started:1","frame":{"type":"run.started","runId":"run-command-output-streaming","agentId":"agent-command-output-streaming"}}
    {"type":"emit_inbound","label":"tool-call-started","frame":{"type":"interaction.update","runId":"run-command-output-streaming","update":{"type":"tool-call-started","callId":"tool_command_output_streaming","toolCall":{"type":"shell","args":{"command":"printf 'cursor line 1\\n'; sleep 1; printf 'cursor line 2\\n'; sleep 1; printf '\\033[0;32mcursor line 3\\033[0m\\n'; sleep 1; printf 'cursor line 4\\nprogress 100%\\n'","timeout":30000}},"modelCallId":"model-call-command-output-streaming"}}}
    {"type":"emit_inbound","label":"shell-stdout-1","frame":{"type":"interaction.update","runId":"run-command-output-streaming","update":{"type":"shell-output-delta","event":{"case":"stdout","value":{"data":"cursor line 1\n"}}}}}
    {"type":"emit_inbound","label":"shell-stdout-2","frame":{"type":"interaction.update","runId":"run-command-output-streaming","update":{"type":"shell-output-delta","event":{"case":"stdout","value":{"data":"cursor line 2\n"}}}}}
    {"type":"emit_inbound","label":"shell-stdout-3","frame":{"type":"interaction.update","runId":"run-command-output-streaming","update":{"type":"shell-output-delta","event":{"case":"stdout","value":{"data":"\u001b[0;32mcursor line 3\u001b[0m\n"}}}}}
    {"type":"emit_inbound","label":"shell-stdout-4","frame":{"type":"interaction.update","runId":"run-command-output-streaming","update":{"type":"shell-output-delta","event":{"case":"stdout","value":{"data":"cursor line 4\nprogress 100%\n"}}}}}
    {"type":"emit_inbound","label":"shell-exit","frame":{"type":"interaction.update","runId":"run-command-output-streaming","update":{"type":"shell-output-delta","event":{"case":"exit","value":{"code":0,"aborted":false}}}}}
    {"type":"emit_inbound","label":"tool-call-completed","frame":{"type":"interaction.update","runId":"run-command-output-streaming","update":{"type":"tool-call-completed","callId":"tool_command_output_streaming","toolCall":{"type":"shell","args":{"command":"..."},"result":{"status":"success","value":{"exitCode":0,"signal":"","stdout":"cursor line 1\ncursor line 2\ncursor line 3\ncursor line 4\nprogress 100%\n","stderr":"","executionTime":4000}}},"modelCallId":"model-call-command-output-streaming"}}}
    {"type":"emit_inbound","label":"turn-ended","frame":{"type":"interaction.update","runId":"run-command-output-streaming","update":{"type":"turn-ended"}}}
    {"type":"emit_inbound","label":"run.completed:1","frame":{"type":"run.completed","result":{"id":"run-command-output-streaming","requestId":"model-call-command-output-streaming","status":"finished","model":{"id":"composer-2.5"},"durationMs":4500}}}
    {"type":"expect_outbound","label":"agent.close","frame":{"type":"agent.close","agentId":"agent-command-output-streaming"}}

    Use the exact text of COMMAND_OUTPUT_STREAMING_PROMPT from
    apps/server/src/orchestration-v2/testkit/fixtures/shared.ts in run.start.message, otherwise the
    user-message assertion fails.

    Step 2: Parameterize the assertion

    In output.ts, extract the provider-neutral body into assertCommandOutputStreamingLines(input)
    with fields { result, transcript, liveLines, liveAnsi?, excludedLiveLines? }. Keep two exports:

    export function assertCommandOutputStreamingOutput(result, transcript) {
      assertCommandOutputStreamingLines({
        result,
        transcript,
        liveLines: ["stream line 2", "stream line 3", "stream line 4", "progress 100%"],
        liveAnsi: "\u001b[0;32mstream line 2\u001b[0m",
        excludedLiveLines: ["progress 50%"],
      });
    }
    
    export function assertCursorCommandOutputStreamingOutput(result, transcript) {
      assertCommandOutputStreamingLines({
        result,
        transcript,
        liveLines: ["cursor line 1", "cursor line 2", "cursor line 3", "cursor line 4", "progress 100%"],
        liveAnsi: "\u001b[0;32mcursor line 3\u001b[0m",
      });
    }

    Move the "Codex omits pre-yield command output" comment into the Codex wrapper.

    Step 3: Register the provider

    In fixtures/index.ts, add to command_output_streaming.providers:

    {
      driver: ProviderDriverKind.make("cursor"),
      transcriptFile: new URL(
        "./command_output_streaming/cursor_transcript.ndjson",
        import.meta.url,
      ),
      modelSelection: CURSOR_MODEL_SELECTION,
      assertOutput: assertCursorCommandOutputStreamingOutput,
    },

    Also import assertCursorCommandOutputStreamingOutput from ./command_output_streaming/output.ts.

    Step 4: Verify it fails for the right reason

    vp test run apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts \
      -t "command_output_streaming/cursor"

    Expected: FAIL at the running command must stream its output (chunks === 0).

    Step 5: Commit

    git add apps/server/src/orchestration-v2/testkit/fixtures/command_output_streaming/ \
            apps/server/src/orchestration-v2/testkit/fixtures/index.ts
    git commit -m "test(server): cover Cursor live shell output streaming"

    Task 2 — Read the Cursor { case, value } payload

    Files:

    • Modify: packages/provider-cursor/src/server/adapter.ts:1975-1984

    Step 1: Replace the extractor

    const shellOutputText = (event: Record<string, unknown>): string => {
      // The SDK flattens a protobuf oneof to { case: "stdout" | "stderr" | "exit", value }.
      const kind = event.case;
      if (kind === "stdout" || kind === "stderr") {
        const value = event.value;
        const data =
          typeof value === "object" && value !== null && "data" in value
            ? (value as { data?: unknown }).data
            : undefined;
        return typeof data === "string" ? data : "";
      }
      if (typeof kind === "string") {
        return "";
      }
      const candidates = [event.text, event.output, event.data, event.stdout, event.stderr, event.chunk];
      return candidates.filter((value): value is string => typeof value === "string").join("");
    };

    The trailing flat-key path stays as a fallback for SDK builds that emit unwrapped events.

    Step 2: Run the fixture

    vp test run apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts \
      -t "command_output_streaming"

    Expected: PASS for command_output_streaming/cursor, and the existing
    command_output_streaming/codex + command_output_streaming/claudeAgent still pass.

    Step 3: Commit

    git add packages/provider-cursor/src/server/adapter.ts
    git commit -m "fix(provider-cursor): stream live shell output"

    Task 3 — Verification

    Run these, in order:

    vp test run apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts
    vp test run packages/provider-cursor/src/server/adapter.test.ts
    vp run check:changed

    Expected: all pass; check:changed reports no format, lint, knip, or typecheck errors.

    Manual check (one real Cursor turn, cheapest model):

    1. Start a dev server and open a Cursor thread.
    2. Ask it to run: for i in 1 2 3 4 5; do echo "tick $i"; sleep 2; done
    3. Expand the command row while it runs.
    4. Expect tick 1…tick 5 to appear incrementally, then the completed output to remain.

    If only the final output appears, the transcript test passed for the wrong reason: confirm the
    event.case/event.value.data shape in the provider event log before changing anything else.


    Risks

    Risk Mitigation
    SDK version drift changes the oneof shape Flat-key fallback retained; fixture pins the current shape
    exit chunk appended as text Explicit kind === "string" early return; fixture includes an exit frame
    Duplicate output (live tail + final artifact) Existing hub/final split is unchanged; fixture asserts running items carry no output
    Subagent shells appear broken after the fix Out of scope by SDK design; document in the PR as not covered

    Rollback

    Revert the two commits. No migrations, contracts, or persisted state change.

  2. Zburgers commented on Nov 3, 2026

    @Zburgers
    Author

    Implemented

    PR: #30229 — fix(provider-cursor): stream live shell output

    Two details from the plan above changed while writing it against the replay harness:

    1. shellOutputText decodes the oneof directly and returns "" for any case other than stdout/stderr. The flat-key fallback is gone: it never matched a real frame, and keeping it would hide the next SDK shape change instead of failing the fixture.
    2. The assertion did not need parameterizing. The Cursor transcript streams line 1 too, so the existing final-vs-live endsWith check already requires every line, and the Codex/Claude expectations stay in one place. The change is the transcript plus one provider row.

    The transcript includes an exit frame after the stdout chunks, so treating the exit status as text fails the test, and a \r-rewritten progress line so the live tail is checked after terminal normalization.

    Verification

    Before the fix (main @ cefb544af3):

    $ vp test run apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts -t "command_output_streaming/cursor"
    AssertionError: the running command must stream its output: expected undefined to not equal undefined
    

    After:

    $ vp test run apps/server/src/orchestration-v2/testkit/CursorReplayFixtures.integration.test.ts -t "command_output_streaming"
         Tests  1 passed | 11 skipped (12)
    
    $ vp test run apps/server/src/orchestration-v2/testkit/CodexReplayFixtures.integration.test.ts -t "command_output_streaming"
         Tests  1 passed | 24 skipped (25)
    
    $ vp test run apps/server/src/orchestration-v2/testkit/ClaudeReplayFixtures.integration.test.ts -t "command_output_streaming"
         Tests  1 passed | 37 skipped (38)
    
    $ vp test run packages/provider-cursor/src/server/adapter.test.ts
         Tests  21 passed (21)
    
    $ vp run check:changed
      pass  format / lint / knip / graphql / typecheck (10 packages) / test (2 files)
    
  3. Zburgers commented on Nov 4, 2026

    @Zburgers
    Author

    Hi maintainers, let me know if theres anything you'd like me to do here.
    I'd love to work more on this issue and become a contributor to the t3 community as well, the pr is already done and good to go.

    would love a vouch!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions