From ce3f4abd96e5fc414514fb290917802cfbb15887 Mon Sep 17 00:00:00 2001 From: Iury Souza Date: Mon, 27 Jul 2026 22:30:03 +0200 Subject: [PATCH] fix(cursor): suppress duplicate shell progress --- ...-27-cursor-shell-progress-deduplication.md | 397 ++++++++++++++++++ .../src/cursor-native-replay-routing.ts | 9 + .../src/cursor-provider-turn-coordinator.ts | 22 +- .../src/cursor-provider-turn-shell-output.ts | 13 +- .../test/cursor-native-replay-routing.test.ts | 33 ++ ...provider-incomplete-tools-live-run.test.ts | 55 +++ .../cursor-provider-replay-live-run.test.ts | 93 ++++ .../test/cursor-provider-replay-shell.test.ts | 60 ++- .../cursor-provider-turn-shell-output.test.ts | 23 + 9 files changed, 700 insertions(+), 5 deletions(-) create mode 100644 ai-artifacts/specs/2026-07-27-cursor-shell-progress-deduplication.md diff --git a/ai-artifacts/specs/2026-07-27-cursor-shell-progress-deduplication.md b/ai-artifacts/specs/2026-07-27-cursor-shell-progress-deduplication.md new file mode 100644 index 0000000..5c09c37 --- /dev/null +++ b/ai-artifacts/specs/2026-07-27-cursor-shell-progress-deduplication.md @@ -0,0 +1,397 @@ +# Cursor Shell Progress Deduplication + +## Summary + +When Cursor SDK executes a shell command that will later be replayed as a Pi `bash` call, the transcript currently shows both: + +```text +Cursor shell: +``` + +and a completed Tidy `bash` card. Shell output deltas may add up to three more `Cursor shell stdout/stderr` lines. + +Suppress those transient Cursor shell lines only when the existing replay router says the completed call will be queued as a native/Tidy card. Continue buffering shell output deltas so they can populate the final card when Cursor's completed result omits output. + +This is a Cursor SDK presentation change only. It does not change execution, replay protocol, Tidy, persisted configuration, or normal Pi tools. + +## Current State + +### Normal Pi execution + +```text +model emits Pi tool call + -> Pi creates pending tool row + -> Tidy renderCall(... isPartial=true) + -> Pi executes tool + -> Tidy renderResult(... isPartial=false) + -> the same row settles +``` + +The user sees one live card that becomes one completed card. + +### Cursor SDK execution + +```text +Cursor tool-call-started + -> CursorToolLifecycleEmitter waits 75 ms + -> emits "Cursor shell: " as thinking text + +Cursor shell-output-delta + -> CursorShellOutputTracker buffers output + -> emits up to three stdout/stderr preview lines as thinking text + +Cursor tool-call-completed + -> CursorTurnDisplayRouter resolves replay disposition + -> queue_replay records a display-only Pi bash call + -> pi-ext consumes the recorded result + -> Tidy renders the completed bash card +``` + +Cursor executes the command once. The duplication is two presentation paths for that one execution. + +Relevant code: + +- `packages/pi-cursor-sdk/src/cursor-provider-turn-coordinator.ts` +- `packages/pi-cursor-sdk/src/cursor-provider-turn-lifecycle-emitter.ts` +- `packages/pi-cursor-sdk/src/cursor-provider-turn-shell-output.ts` +- `packages/pi-cursor-sdk/src/cursor-native-replay-routing.ts` +- `packages/pi-cursor-sdk/src/cursor-provider-turn-display-router.ts` +- `packages/pi-ext/extensions/tool-presentation/index.ts` + +## Goals + +- Show one completed `bash` card for a Cursor shell call when native/Tidy replay is available. +- Remove the preceding `Cursor shell: ` line in that path. +- Remove `Cursor shell stdout/stderr: ...` transcript previews in that path. +- Preserve output-delta buffering and merge it into the final replay result. +- Preserve lifecycle and output progress text whenever replay cannot produce a card. +- Preserve visible failure or abort output for calls that never complete. +- Keep normal non-Cursor Pi tool UX unchanged. + +## Non-Goals + +- Live or pending Tidy cards for Cursor-owned execution. +- Changes to Cursor command execution or cancellation. +- Changes to the pi-ext/Cursor replay protocol. +- Changes to Tidy rendering or configuration. +- Generic suppression of Cursor-only MCP, task, plan, web, image, or semantic-search activity. +- Removal of shell output from the completed card. +- Parsing or filtering Cursor text inside pi-ext. + +## Invariants and Constraints + +1. A Cursor replay must never execute the underlying Pi `bash` tool. +2. Suppression is allowed only when `resolveNativeReplayDisposition()` returns `queue_replay` for Pi tool name `bash`. +3. `inactive_trace` and `transcript_trace` keep current progress behavior. +4. Output deltas remain buffered even when their preview text is suppressed. +5. Missing completion, SDK failure, run drain, and abort remain visible through existing incomplete-tool handling. +6. The active-tool decision uses the provider context snapshot, matching completed replay routing. +7. Cursor SDK remains usable without pi-ext. If its own native replay wrapper can produce the card, the same deduplication applies. +8. No settings, files, or durable state are added. +9. Pi-ext remains the sole built-in presentation owner when installed; Cursor SDK owns the decision not to emit redundant provider traces. + +## Alternatives + +### A. Gate progress at the Cursor replay-routing seam — recommended + +Use the same routing facts for start-time progress and completion-time replay. If `bash` will route to `queue_replay`, cancel shell lifecycle text and mark shell output previews as hidden while still buffering their bytes. + +**Benefits:** correct ownership, no text parsing, standalone-safe, fallback-safe, no protocol change. + +**Cost:** a small state addition to the shell output tracker and coordinator tests. + +### B. Suppress every Cursor shell progress line + +Never emit lifecycle or output previews for shell calls. + +**Rejected:** simple but creates silent long-running work when native replay is disabled, unavailable, inactive in the context snapshot, or replaced by an incompatible tool owner. + +### C. Filter Cursor text inside pi-ext/Tidy + +Have pi-ext recognize and discard `Cursor shell:` thinking text. + +**Rejected:** wrong ownership and brittle string coupling. Pi-ext would need provider-specific parsing and could not know reliably whether a final replay card will arrive. + +### D. Emit a pending replay card + +Create a synthetic Pi call at Cursor start and settle it at Cursor completion. + +**Rejected for this change:** closest parity with normal Pi, but requires pending-result state, replay protocol changes, turn coordination, and new abort/concurrency semantics. The UX defect does not justify that architecture. + +## Recommendation + +Implement Alternative A entirely in `@iurysza/pi-cursor-sdk`. + +The replay router already decides whether a completed Cursor tool becomes a Pi card. Reuse that decision before emitting shell progress. Do not add a second policy or inspect whether Tidy specifically is installed. + +## Domain Model and Types + +Add an internal shell progress mode: + +```ts +export type CursorShellProgressMode = "transcript" | "card-only"; +``` + +Add a pure resolver beside native replay routing: + +```ts +export type CursorShellProgressRoutingInput = Omit< + NativeReplayRoutingInput, + "toolName" +>; + +export function resolveCursorShellProgressMode( + input: CursorShellProgressRoutingInput, +): CursorShellProgressMode { + return resolveNativeReplayDisposition({ ...input, toolName: "bash" }) === "queue_replay" + ? "card-only" + : "transcript"; +} +``` + +`card-only` means: + +- cancel any delayed shell lifecycle line; +- do not emit stdout/stderr preview text; +- keep buffering output; +- let completion or incomplete-call handling provide the visible terminal state. + +Extend shell tracking without importing replay-routing concerns into the tracker: + +```ts +export interface CursorShellStartOptions { + progressMode?: CursorShellProgressMode; // default: "transcript" +} + +class CursorShellOutputTracker { + onShellToolStarted(callId: string, options?: CursorShellStartOptions): void; + appendShellOutputDelta(delta: CursorShellOutputDelta): + | CursorShellOutputProgressDelta + | undefined; +} +``` + +The tracker stores progress mode by call ID. `appendShellOutputDelta()` always buffers attributable data first. It returns a user-visible progress delta only in `transcript` mode and within the existing three-preview limit. + +## Interfaces and APIs + +No public package or cross-package API changes. + +Changed internal contracts: + +1. `resolveCursorShellProgressMode()` becomes the single start-time policy for shell transcript progress. +2. `CursorShellOutputTracker.onShellToolStarted()` accepts optional display policy while preserving the current default. +3. `CursorSdkTurnCoordinator` computes the policy from its existing fields: + +```ts +const progressMode = resolveCursorShellProgressMode({ + useNativeToolReplay: this.useNativeToolReplay, + activeToolNames: this.activeToolNames, + hasLiveRun: this.liveRun !== undefined, +}); +``` + +The resolver itself relies on `canRenderCursorToolNatively("bash")`, exactly as completion routing does. + +## Boundaries and Adapters + +### Cursor SDK event boundary + +`tool-call-started` and `shell-output-delta` remain external SDK inputs. No Cursor SDK payload is changed. + +### Replay-routing boundary + +`cursor-native-replay-routing.ts` owns the decision because it already combines: + +- runtime replay enablement; +- registered/shared native renderer availability; +- active context tool names; +- live-run availability. + +### Shell output boundary + +`CursorShellOutputTracker` continues to own attribution, overlap ambiguity, buffering, and visible preview limits. It gains only per-call visibility state. + +### Pi/Tidy boundary + +Unchanged. Completion still queues the existing recorded replay result. pi-ext still consumes it once and Tidy renders it. No Tidy-specific import or event is introduced. + +## Call Stacks and Data Flow + +### Proposed replay-card path + +```text +Cursor partial-tool-call + -> lifecycle may be tentatively scheduled + -> timer cannot emit before the call is registered as started + +Cursor tool-call-started(shell, callId) + -> resolveCursorShellProgressMode(..., Pi name "bash") + -> resolveNativeReplayDisposition() == queue_replay + -> lifecycleEmitter.cancel(callId) + -> shellOutput.onShellToolStarted(callId, { progressMode: "card-only" }) + -> optional debug decision: tool_lifecycle_skip/native-replay-card + +Cursor shell-output-delta + -> attribute and buffer bytes + -> card-only returns no progress preview + -> no thinking text is emitted + +Cursor tool-call-completed + -> merge buffered bytes if completed stdout/stderr are empty + -> existing duplicate ledger + -> existing routeCompletedToolCall() + -> queue_replay + -> recorded replay consumed once + -> one completed Tidy/native bash card +``` + +### Proposed fallback path + +```text +Cursor tool-call-started(shell, callId) + -> resolveCursorShellProgressMode(...) + -> disposition is inactive_trace or transcript_trace + -> lifecycle remains scheduled + -> tracker starts in transcript mode + +75 ms / shell output deltas + -> current Cursor shell lifecycle/output text remains + +completion + -> current transcript/inactive trace remains +``` + +### Incomplete or aborted replay path + +```text +card-only shell starts + -> no transient progress text + -> completion never arrives + -> discardIncompleteStartedToolCalls() + -> existing incomplete display/trace reports missing completion, SDK failure, + run drain, or abort +``` + +No failure path may end without either a completed card or existing incomplete-tool output. + +## Files to Add, Change, or Delete + +### Add + +None. + +### Change + +- `packages/pi-cursor-sdk/src/cursor-native-replay-routing.ts` + - Add `CursorShellProgressMode` and `resolveCursorShellProgressMode()`. + +- `packages/pi-cursor-sdk/src/cursor-provider-turn-coordinator.ts` + - Resolve shell progress mode on `tool-call-started`. + - Cancel a lifecycle timer for `card-only` calls, including timers created by `partial-tool-call`. + - Pass progress mode into the shell output tracker. + - Optionally record a bounded debug skip reason. + +- `packages/pi-cursor-sdk/src/cursor-provider-turn-shell-output.ts` + - Store and clear per-call progress mode. + - Suppress preview return values in `card-only` mode while preserving buffers. + +- `packages/pi-cursor-sdk/test/cursor-native-replay-routing.test.ts` + - Cover all progress-mode routing outcomes. + +- `packages/pi-cursor-sdk/test/cursor-provider-turn-shell-output.test.ts` + - Prove hidden previews still populate buffered output. + +- `packages/pi-cursor-sdk/test/cursor-provider-replay-shell.test.ts` + - Update replay-path expectations from progress text plus card to card only. + - Preserve fallback trace expectations. + +- `packages/pi-cursor-sdk/test/cursor-provider-replay-live-run.test.ts` + - Add a shell call lasting beyond 75 ms and prove lifecycle text is absent when replay is queued. + +- `packages/pi-cursor-sdk/test/cursor-provider-incomplete-tools-live-run.test.ts` + - Prove a suppressed-progress shell remains visible on failure/abort. + +### Delete + +None. + +### Explicitly unchanged + +- `packages/pi-ext/extensions/tool-presentation/**` +- Cursor replay broker/protocol files +- package manifests and settings + +## Red-Green Test Plan + +### Slice 1 — Routing contract + +1. Add failing tests for `resolveCursorShellProgressMode()`. +2. Expect `card-only` only when replay is enabled, `bash` is renderable and active, and a live run exists. +3. Expect `transcript` for disabled replay, inactive `bash`, no live run, or unavailable native renderer. +4. Implement the pure resolver through `resolveNativeReplayDisposition()`. + +### Slice 2 — Hidden preview still buffers + +1. Add a failing tracker test starting a call in `card-only` mode. +2. Assert stdout/stderr appends return `undefined`. +3. Assert `takeDeltasForCall()` returns all bytes unchanged. +4. Assert default/transcript mode still returns at most three previews. +5. Implement per-call mode storage and cleanup. + +### Slice 3 — One-card replay path + +1. Add a failing provider test with a shell call that remains started for more than 75 ms and emits output deltas before completion. +2. Assert thinking text contains neither `Cursor shell:` nor `Cursor shell stdout/stderr:`. +3. Assert the resulting Pi call is `bash` with the original command. +4. Execute the replay wrapper and assert the output delta appears in the final result when completed output is empty. +5. Implement coordinator gating and lifecycle cancellation. + +### Slice 4 — Fallback remains informative + +1. Add a failing test where native replay is disabled or `bash` is absent from `activeToolNames`. +2. Assert the delayed command lifecycle and bounded output preview still appear. +3. Assert completed fallback trace behavior is unchanged. +4. Adjust only if the new gating accidentally suppresses fallback output. + +### Slice 5 — Failure and abort remain visible + +1. Start a `card-only` shell call and omit completion. +2. Exercise SDK failure and abort outcomes. +3. Assert existing incomplete-tool card/trace is emitted. +4. Assert no stale tracker mode, buffer, lifecycle timer, or native result remains. + +### Slice 6 — Visual and package verification + +Run: + +```bash +npm run typecheck --workspace @iurysza/pi-cursor-sdk +npm test --workspace @iurysza/pi-cursor-sdk +npm run check +``` + +Then use a disposable Pi setup containing both `@iurysza/pi-ext` and `@iurysza/pi-cursor-sdk`: + +- run one shell command long enough to cross the 75 ms lifecycle threshold; +- capture PNG and JSONL through the existing visual audit workflow; +- verify exactly one completed Tidy `bash` card; +- verify no `Cursor shell:` or `Cursor shell stdout/stderr:` transcript line; +- verify JSONL contains one `bash` tool call/result pair; +- verify the command executed once; +- repeat with native replay disabled and verify fallback progress remains visible. + +## Risks and Open Questions + +### Risks + +- **Start/completion routing drift:** mitigated by delegating both decisions to `resolveNativeReplayDisposition()` rather than duplicating conditions. +- **Tentative partial-call timer leaks:** `tool-call-started` must cancel an existing lifecycle timer before it can emit. +- **Lost result text:** output preview suppression must occur after buffering, not instead of buffering. +- **Silent incomplete calls:** existing incomplete-tool behavior must be integration-tested for failure and abort. +- **Parallel shell calls:** preserve current ambiguity rule; do not attempt new attribution logic in this change. +- **Runtime registration changes mid-call:** completion may theoretically choose a different disposition after reload. Existing completed or incomplete fallback output remains authoritative; no new recovery mechanism is warranted. + +### Open Questions + +None. The chosen scope suppresses both command lifecycle and stdout/stderr previews only for calls already eligible for a replay card. diff --git a/packages/pi-cursor-sdk/src/cursor-native-replay-routing.ts b/packages/pi-cursor-sdk/src/cursor-native-replay-routing.ts index b8eb9b2..a02a9dc 100644 --- a/packages/pi-cursor-sdk/src/cursor-native-replay-routing.ts +++ b/packages/pi-cursor-sdk/src/cursor-native-replay-routing.ts @@ -11,6 +11,9 @@ export interface NativeReplayRoutingInput { hasLiveRun: boolean; } +export type CursorShellProgressMode = "transcript" | "card-only"; +export type CursorShellProgressRoutingInput = Omit; + export function isNativeToolActiveInContext(toolName: string, activeToolNames?: ReadonlySet): boolean { return activeToolNames === undefined || activeToolNames.has(toolName); } @@ -32,6 +35,12 @@ export function resolveNativeReplayDisposition(input: NativeReplayRoutingInput): return "transcript_trace"; } +export function resolveCursorShellProgressMode(input: CursorShellProgressRoutingInput): CursorShellProgressMode { + return resolveNativeReplayDisposition({ ...input, toolName: "bash" }) === "queue_replay" + ? "card-only" + : "transcript"; +} + export function partitionNativeToolsByActiveContext( context: Context, tools: readonly T[], diff --git a/packages/pi-cursor-sdk/src/cursor-provider-turn-coordinator.ts b/packages/pi-cursor-sdk/src/cursor-provider-turn-coordinator.ts index 8155b50..b39d7b3 100644 --- a/packages/pi-cursor-sdk/src/cursor-provider-turn-coordinator.ts +++ b/packages/pi-cursor-sdk/src/cursor-provider-turn-coordinator.ts @@ -33,6 +33,7 @@ import { getToolFingerprint, } from "./cursor-provider-turn-tool-ledger.js"; import { readCursorSdkTurnUsageFromUpdate, type CursorSdkTurnUsage } from "./cursor-usage-accounting.js"; +import { resolveCursorShellProgressMode } from "./cursor-native-replay-routing.js"; export interface CursorSdkTurnCoordinatorOptions { stream: AssistantMessageEventStream; @@ -185,10 +186,25 @@ export class CursorSdkTurnCoordinator { if (this.liveRun?.bridgeRun?.isBridgeMcpToolCall(update.toolCall)) { if (typeof update.callId === "string") this.ledger.markBridgeStarted(update.callId); } else { - this.lifecycleEmitter.maybeSchedule(update.callId, update.toolCall); + const shellCallId = + isCursorShellToolCall(update.toolCall) && typeof update.callId === "string" + ? update.callId + : undefined; + const shellProgressMode = shellCallId + ? resolveCursorShellProgressMode({ + useNativeToolReplay: this.useNativeToolReplay, + activeToolNames: this.activeToolNames, + hasLiveRun: this.liveRun !== undefined, + }) + : undefined; + if (shellCallId && shellProgressMode === "card-only") { + this.lifecycleEmitter.cancel(shellCallId); + } else { + this.lifecycleEmitter.maybeSchedule(update.callId, update.toolCall); + } this.ledger.registerStartedToolCall(update.callId, update.toolCall); - if (isCursorShellToolCall(update.toolCall) && typeof update.callId === "string") { - this.shellOutput.onShellToolStarted(update.callId); + if (shellCallId) { + this.shellOutput.onShellToolStarted(shellCallId, { progressMode: shellProgressMode }); } } return; diff --git a/packages/pi-cursor-sdk/src/cursor-provider-turn-shell-output.ts b/packages/pi-cursor-sdk/src/cursor-provider-turn-shell-output.ts index 1533baf..63007aa 100644 --- a/packages/pi-cursor-sdk/src/cursor-provider-turn-shell-output.ts +++ b/packages/pi-cursor-sdk/src/cursor-provider-turn-shell-output.ts @@ -3,6 +3,7 @@ import { asRecord, getField, hasUsableText } from "./cursor-record-utils.js"; import { scrubSensitiveText } from "./cursor-sensitive-text.js"; import { truncateCursorDisplayLine } from "./cursor-display-text.js"; import { classifyCursorToolVisibility } from "./cursor-tool-visibility.js"; +import type { CursorShellProgressMode } from "./cursor-native-replay-routing.js"; export interface CursorShellOutputDelta { stream: "stdout" | "stderr"; @@ -18,6 +19,10 @@ export interface CursorShellOutputProgressDelta extends CursorShellOutputDelta { callId: string; } +export interface CursorShellStartOptions { + progressMode?: CursorShellProgressMode; +} + const SHELL_OUTPUT_PROGRESS_MAX_DELTAS_PER_CALL = 3; export function isCursorShellToolCall(toolCall: unknown): boolean { @@ -90,15 +95,18 @@ export class CursorShellOutputTracker { private readonly ambiguousShellOutputCallIds = new Set(); private readonly shellOutputDeltasByCallId = new Map(); private readonly shellOutputProgressCountsByCallId = new Map(); + private readonly shellProgressModesByCallId = new Map(); - onShellToolStarted(callId: string): void { + onShellToolStarted(callId: string, options?: CursorShellStartOptions): void { this.activeShellCallIds.add(callId); + this.shellProgressModesByCallId.set(callId, options?.progressMode ?? "transcript"); } onShellToolCleared(callId: string): void { this.activeShellCallIds.delete(callId); this.ambiguousShellOutputCallIds.delete(callId); this.shellOutputProgressCountsByCallId.delete(callId); + this.shellProgressModesByCallId.delete(callId); } appendShellOutputDelta(delta: CursorShellOutputDelta): CursorShellOutputProgressDelta | undefined { @@ -119,6 +127,7 @@ export class CursorShellOutputTracker { } deltas[delta.stream].push(delta.data); + if (this.shellProgressModesByCallId.get(callId) === "card-only") return undefined; if (!getCursorShellOutputProgressPreview(delta.data)) return undefined; const progressCount = this.shellOutputProgressCountsByCallId.get(callId) ?? 0; if (progressCount >= SHELL_OUTPUT_PROGRESS_MAX_DELTAS_PER_CALL) return undefined; @@ -130,6 +139,7 @@ export class CursorShellOutputTracker { const deltas = this.shellOutputDeltasByCallId.get(callId); this.shellOutputDeltasByCallId.delete(callId); this.shellOutputProgressCountsByCallId.delete(callId); + this.shellProgressModesByCallId.delete(callId); return deltas; } @@ -138,5 +148,6 @@ export class CursorShellOutputTracker { this.ambiguousShellOutputCallIds.clear(); this.shellOutputDeltasByCallId.clear(); this.shellOutputProgressCountsByCallId.clear(); + this.shellProgressModesByCallId.clear(); } } diff --git a/packages/pi-cursor-sdk/test/cursor-native-replay-routing.test.ts b/packages/pi-cursor-sdk/test/cursor-native-replay-routing.test.ts index 6931f0b..61da4ea 100644 --- a/packages/pi-cursor-sdk/test/cursor-native-replay-routing.test.ts +++ b/packages/pi-cursor-sdk/test/cursor-native-replay-routing.test.ts @@ -4,6 +4,7 @@ import { __testUtils as nativeToolDisplayTestUtils } from "../src/cursor-native- import { isNativeToolActiveInContext, partitionNativeToolsByActiveContext, + resolveCursorShellProgressMode, resolveNativeReplayDisposition, } from "../src/cursor-native-replay-routing.js"; @@ -11,6 +12,7 @@ describe("cursor-native-replay-routing", () => { beforeEach(() => { nativeToolDisplayTestUtils.reset(); nativeToolDisplayTestUtils.registerNativeToolNameForTests("grep"); + nativeToolDisplayTestUtils.registerNativeToolNameForTests("bash"); }); it("queues replay when tool is active in context and live run exists", () => { expect( @@ -45,6 +47,37 @@ describe("cursor-native-replay-routing", () => { ).toBe("transcript_trace"); }); + it("uses card-only shell progress when bash replay will be queued", () => { + expect( + resolveCursorShellProgressMode({ + useNativeToolReplay: true, + activeToolNames: new Set(["bash"]), + hasLiveRun: true, + }), + ).toBe("card-only"); + }); + + it.each([ + ["native replay is disabled", false, new Set(["bash"]), true], + ["bash is inactive", true, new Set(["read"]), true], + ["there is no live run", true, new Set(["bash"]), false], + ] as const)("uses transcript shell progress when %s", (_label, useNativeToolReplay, activeToolNames, hasLiveRun) => { + expect(resolveCursorShellProgressMode({ useNativeToolReplay, activeToolNames, hasLiveRun })).toBe("transcript"); + }); + + it("uses transcript shell progress when bash has no native renderer", () => { + nativeToolDisplayTestUtils.reset(); + nativeToolDisplayTestUtils.registerNativeToolNameForTests("grep"); + + expect( + resolveCursorShellProgressMode({ + useNativeToolReplay: true, + activeToolNames: new Set(["bash"]), + hasLiveRun: true, + }), + ).toBe("transcript"); + }); + it("treats undefined activeToolNames as all tools active", () => { expect(isNativeToolActiveInContext("grep", undefined)).toBe(true); }); diff --git a/packages/pi-cursor-sdk/test/cursor-provider-incomplete-tools-live-run.test.ts b/packages/pi-cursor-sdk/test/cursor-provider-incomplete-tools-live-run.test.ts index c3d3676..5be7096 100644 --- a/packages/pi-cursor-sdk/test/cursor-provider-incomplete-tools-live-run.test.ts +++ b/packages/pi-cursor-sdk/test/cursor-provider-incomplete-tools-live-run.test.ts @@ -13,6 +13,7 @@ import { hasEventType, isToolCallBlock, registerNativeToolDisplayForTest, + delayBeforeToolCompletion, type CursorDeltaHandler, type RegisteredTool, mockCreatedAgent, @@ -72,6 +73,60 @@ describe("streamCursor incomplete native replay tools", () => { expect(mockDispose).toHaveBeenCalledTimes(1); }); + it("surfaces card-only shell failure on abort without stale progress", async () => { + process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY = "1"; + const registeredTools: RegisteredTool[] = []; + await registerNativeToolDisplayForTest(registeredTools); + + const controller = new AbortController(); + const mockDispose = vi.fn().mockResolvedValue(undefined); + const cancelRun = vi.fn().mockResolvedValue(undefined); + const runWait = vi.fn(() => new Promise<{ id: string; status: "finished"; result: string }>(() => {})); + const mockSend = vi.fn().mockImplementation(async (_msg: unknown, opts: { onDelta: CursorDeltaHandler }) => { + opts.onDelta({ + update: { + type: "tool-call-started", + toolCall: { name: "shell", args: { command: "sleep 10" } }, + callId: "shell-1", + }, + }); + opts.onDelta({ + update: { + type: "shell-output-delta", + event: { case: "stdout", value: { data: "still running\n" } }, + }, + }); + return { + id: "run-1", + agentId: "agent-1", + status: "running", + wait: runWait, + cancel: cancelRun, + supports: () => true, + unsupportedReason: () => undefined, + }; + }); + mockCreatedAgent({ send: mockSend, [Symbol.asyncDispose]: mockDispose }); + + const eventsPromise = collectEvents(streamCursor(makeModel(), makeContext(), { apiKey: "test-key", signal: controller.signal })); + await vi.waitFor(() => expect(mockSend).toHaveBeenCalled()); + await delayBeforeToolCompletion(); + controller.abort(); + const events = await eventsPromise; + const trace = collectThinkingDeltas(events); + + expect(getErrorEvent(events).reason).toBe("aborted"); + expect(trace).not.toContain("Cursor shell:"); + expect(trace).not.toContain("Cursor shell stdout:"); + expect(trace).toContain("Cursor shell did not complete"); + expect(trace).toContain("aborted"); + expect(getEventsOfType(events, "toolcall_start")).toHaveLength(0); + expect(nativeToolDisplayTestUtils.nativeToolResultCount()).toBe(0); + expect(cursorProviderTestUtils.pendingCursorNativeRunCount()).toBe(0); + expect(cancelRun).toHaveBeenCalled(); + expect(mockDispose).toHaveBeenCalledTimes(1); + }); + it("surfaces incomplete started Cursor tools when aborting a scoped native live run", async () => { process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY = "1"; const registeredTools: RegisteredTool[] = []; diff --git a/packages/pi-cursor-sdk/test/cursor-provider-replay-live-run.test.ts b/packages/pi-cursor-sdk/test/cursor-provider-replay-live-run.test.ts index a397369..1ec11c4 100644 --- a/packages/pi-cursor-sdk/test/cursor-provider-replay-live-run.test.ts +++ b/packages/pi-cursor-sdk/test/cursor-provider-replay-live-run.test.ts @@ -24,6 +24,7 @@ import { connectMcpClient, createBuiltinToolInfo, createTestToolInfo, + delayBeforeToolCompletion, cursorModelItems, type CursorDeltaHandler, type CursorStepHandler, @@ -46,6 +47,98 @@ import { join } from "node:path"; describe("streamCursor native replay live run", () => { beforeEach(resetCursorProviderTestState); + it("shows only the completed bash replay card for a delayed shell call", async () => { + process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY = "1"; + const registeredTools: RegisteredTool[] = []; + await registerNativeToolDisplayForTest(registeredTools); + + const command = "sleep 1 && printf done"; + let resolveRun: (result: { id: string; status: "finished"; result: string }) => void = () => {}; + const runWait = vi.fn( + () => + new Promise<{ id: string; status: "finished"; result: string }>((resolve) => { + resolveRun = resolve; + }), + ); + const mockSend = vi.fn().mockImplementation(async (_msg: unknown, opts: { onDelta: CursorDeltaHandler }) => { + const startedShellCall = { name: "shell", args: { command } }; + opts.onDelta({ update: { type: "partial-tool-call", toolCall: startedShellCall, callId: "shell-1" } }); + opts.onDelta({ update: { type: "tool-call-started", toolCall: startedShellCall, callId: "shell-1" } }); + opts.onDelta({ + update: { + type: "shell-output-delta", + event: { case: "stdout", value: { data: "done\n" } }, + }, + }); + await delayBeforeToolCompletion(); + opts.onDelta({ + update: { + type: "tool-call-completed", + toolCall: { + name: "shell", + result: { status: "success", value: { stdout: "", stderr: "", exitCode: 0 } }, + }, + callId: "shell-1", + }, + }); + return asMockCursorRun({ + id: "run-1", + agentId: "agent-1", + status: "running", + wait: runWait, + cancel: vi.fn(), + supports: () => true, + unsupportedReason: () => undefined, + }); + }); + mockCreatedAgent({ + agentId: "agent-1", + send: mockSend, + [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined), + }); + + const firstEvents = await collectEvents(streamCursor(makeModel(), makeContext(), { apiKey: "test-key" })); + const firstDone = getDoneEvent(firstEvents); + const toolCall = firstDone.message.content.find(isToolCallBlock); + const bashTool = registeredTools.find((tool) => tool.name === "bash"); + const toolResult = await bashTool!.execute( + toolCall!.id, + toolCall!.arguments, + undefined, + undefined, + createExtensionTestContext(), + ); + + resolveRun({ id: "run-1", status: "finished", result: "Done." }); + const replayContext = makeContext(); + replayContext.messages = [ + ...replayContext.messages, + firstDone.message, + { + role: "toolResult", + toolCallId: toolCall!.id, + toolName: "bash", + content: toolResult.content, + details: toolResult.details, + isError: false, + timestamp: 2, + }, + ]; + await collectEvents(streamCursor(makeModel(), replayContext, { apiKey: "test-key" })); + + const trace = collectThinkingDeltas(firstEvents); + expect(trace).not.toContain("Cursor shell:"); + expect(trace).not.toContain("Cursor shell stdout:"); + expect(trace).not.toContain("Cursor shell stderr:"); + expect(firstDone.reason).toBe("toolUse"); + expect(toolCall).toMatchObject({ name: "bash", arguments: { command } }); + expect(toolResult).toMatchObject({ + content: [{ type: "text", text: "done" }], + terminate: false, + }); + expect(cursorProviderTestUtils.pendingCursorNativeRunCount()).toBe(0); + }); + it("uses bounded approximate usage on the final native replay stop turn when no turn-ended usage arrives", async () => { process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY = "1"; const registeredTools: RegisteredTool[] = []; diff --git a/packages/pi-cursor-sdk/test/cursor-provider-replay-shell.test.ts b/packages/pi-cursor-sdk/test/cursor-provider-replay-shell.test.ts index 90b9162..58f7efc 100644 --- a/packages/pi-cursor-sdk/test/cursor-provider-replay-shell.test.ts +++ b/packages/pi-cursor-sdk/test/cursor-provider-replay-shell.test.ts @@ -24,6 +24,7 @@ import { connectMcpClient, createBuiltinToolInfo, createTestToolInfo, + delayBeforeToolCompletion, cursorModelItems, type CursorDeltaHandler, type CursorStepHandler, @@ -90,7 +91,7 @@ it("uses Cursor shell-output-delta as display-only fallback when completed shell const firstDone = getDoneEvent(firstEvents); const toolCall = firstDone.message.content.find(isToolCallBlock); - expect(collectThinkingDeltas(firstEvents)).toContain("Cursor shell stdout: background job done"); + expect(collectThinkingDeltas(firstEvents)).not.toContain("Cursor shell stdout: background job done"); expect(firstDone.reason).toBe("toolUse"); expect(toolCall!.name).toBe("bash"); expect(toolCall!.arguments).toEqual({ command }); @@ -123,6 +124,63 @@ it("uses Cursor shell-output-delta as display-only fallback when completed shell expect(replayText).toBe("Done."); }); + it("keeps delayed lifecycle and output progress when bash is inactive", async () => { + process.env.PI_CURSOR_NATIVE_TOOL_DISPLAY = "1"; + await registerNativeToolDisplayForTest([]); + + const command = "sleep 1 && printf done"; + const mockSend = vi.fn().mockImplementation(async (_msg: unknown, opts: { onDelta: CursorDeltaHandler }) => { + opts.onDelta({ + update: { + type: "tool-call-started", + toolCall: { name: "shell", args: { command } }, + callId: "shell-1", + }, + }); + opts.onDelta({ + update: { + type: "shell-output-delta", + event: { case: "stdout", value: { data: "working\n" } }, + }, + }); + await delayBeforeToolCompletion(); + opts.onDelta({ + update: { + type: "tool-call-completed", + toolCall: { + name: "shell", + result: { status: "success", value: { stdout: "done\n", stderr: "", exitCode: 0 } }, + }, + callId: "shell-1", + }, + }); + return asMockCursorRun({ + id: "run-1", + agentId: "agent-1", + status: "finished", + wait: vi.fn().mockResolvedValue({ id: "run-1", status: "finished", result: "Done." }), + cancel: vi.fn(), + supports: () => true, + unsupportedReason: () => undefined, + }); + }); + mockCreatedAgent({ + agentId: "agent-1", + send: mockSend, + [Symbol.asyncDispose]: vi.fn().mockResolvedValue(undefined), + }); + + const context = makeContext(); + context.tools = [{ name: "read", description: "Read files", parameters: Type.Object({}) }]; + const events = await collectEvents(streamCursor(makeModel(), context, { apiKey: "test-key" })); + const trace = collectThinkingDeltas(events); + + expect(trace).toContain(`Cursor shell: ${command}`); + expect(trace).toContain("Cursor shell stdout: working"); + expect(trace).toContain("Cursor bash:"); + expect(hasEventType(events, "toolcall_start")).toBe(false); + }); + it("drops shell-output-delta fallback data when overlapping shell calls make attribution ambiguous", async () => { const mockSend = vi.fn().mockImplementation(async (_msg: unknown, opts: { onDelta: CursorDeltaHandler }) => { opts.onDelta({ update: { type: "tool-call-started", toolCall: { name: "shell", args: { command: "sleep 1" } }, callId: "shell-1" } }); diff --git a/packages/pi-cursor-sdk/test/cursor-provider-turn-shell-output.test.ts b/packages/pi-cursor-sdk/test/cursor-provider-turn-shell-output.test.ts index 2def7c2..0a0cc43 100644 --- a/packages/pi-cursor-sdk/test/cursor-provider-turn-shell-output.test.ts +++ b/packages/pi-cursor-sdk/test/cursor-provider-turn-shell-output.test.ts @@ -28,6 +28,29 @@ describe("CursorShellOutputTracker", () => { }); }); + it("buffers card-only stdout/stderr without returning progress previews", () => { + const tracker = new CursorShellOutputTracker(); + tracker.onShellToolStarted("shell-1", { progressMode: "card-only" }); + + expect(tracker.appendShellOutputDelta({ stream: "stdout", data: "line one\n" })).toBeUndefined(); + expect(tracker.appendShellOutputDelta({ stream: "stderr", data: "warn\n" })).toBeUndefined(); + expect(tracker.takeDeltasForCall("shell-1")).toEqual({ + stdout: ["line one\n"], + stderr: ["warn\n"], + }); + }); + + it("clears card-only mode and buffered output", () => { + const tracker = new CursorShellOutputTracker(); + tracker.onShellToolStarted("shell-1", { progressMode: "card-only" }); + tracker.appendShellOutputDelta({ stream: "stdout", data: "hidden\n" }); + tracker.clear(); + tracker.onShellToolStarted("shell-1"); + + expect(tracker.appendShellOutputDelta({ stream: "stdout", data: "visible\n" })).toBeDefined(); + expect(tracker.takeDeltasForCall("shell-1")?.stdout).toEqual(["visible\n"]); + }); + it("bounds user-visible shell output progress per call", () => { const tracker = new CursorShellOutputTracker(); tracker.onShellToolStarted("shell-1"); -- 2.51.2