From 4cd3ca4eca52cba41c2bc294621da4dd675c4fe9 Mon Sep 17 00:00:00 2001 From: roboomp Date: Thu, 18 Jun 2026 03:35:44 +0000 Subject: [PATCH] fix(ai): preserved cursor tool call order Closed Cursor stream text blocks before starting tool calls so post-tool deltas create a new assistant content block instead of appending to earlier text. Added a regression test for text/tool/text block ordering in Cursor interaction updates. Fixes #2924 --- packages/ai/CHANGELOG.md | 1 + packages/ai/src/providers/cursor.ts | 66 +++++++++++-------- .../ai/test/cursor-streaming-args.test.ts | 35 ++++++++++ 3 files changed, 73 insertions(+), 29 deletions(-) diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index 7f34ea30e..019c7d3e0 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -42,6 +42,7 @@ - Fixed OpenRouter Responses requests tagging the streamed assistant message with a hardcoded `openai-responses` API instead of the runtime `model.api`, which silently disabled native-history replay (`buildResponsesInput`) and cross-model tool-call item-id stripping on subsequent OpenRouter turns. The message now carries `model.api` (matching the Chat Completions path). - Fixed OpenAI-family streaming leaking a pre-retry `errorMessage` onto a successful turn: the OpenRouter Anthropic compiled-grammar strict-tool fallback set `errorMessage` before retrying with strict tools disabled and never cleared it on success, and the Chat Completions success path could carry an `errorMessage` from an internally-retried attempt — both made a successful turn read as errored in agent state and telemetry. The Responses fallback no longer assigns `errorMessage`, and the Completions success path clears it before emitting the terminal `done` event. - Fixed Codex stream-error `.code` resolution to use the same nested-first precedence (`error.code` → `error.type` → top-level `code`) as `isRetryableCodexFailureEvent` and the formatted message. Previously the error factory resolved top-level-first, so a failure event carrying both a top-level and a differing nested error code surfaced a `.code` that could disagree with its own `retryable` flag and message text. +- Fixed Cursor provider streaming to close text blocks before tool calls so post-tool text opens a new content block and TUI transcript cards render inline instead of grouped near the bottom. ([#2924](https://github.com/can1357/oh-my-pi/issues/2924)) ## [16.0.5] - 2026-06-17 diff --git a/packages/ai/src/providers/cursor.ts b/packages/ai/src/providers/cursor.ts index 8b1a92d40..0e8c96d38 100644 --- a/packages/ai/src/providers/cursor.ts +++ b/packages/ai/src/providers/cursor.ts @@ -548,24 +548,8 @@ export const streamCursor: StreamFunction<"cursor-agent"> = ( } }); - if (state.currentTextBlock) { - const idx = output.content.indexOf(state.currentTextBlock); - stream.push({ - type: "text_end", - contentIndex: idx, - content: state.currentTextBlock.text, - partial: output, - }); - } - if (state.currentThinkingBlock) { - const idx = output.content.indexOf(state.currentThinkingBlock); - stream.push({ - type: "thinking_end", - contentIndex: idx, - content: state.currentThinkingBlock.thinking, - partial: output, - }); - } + endCurrentTextBlock(output, stream, state); + endCurrentThinkingBlock(output, stream, state); if (state.currentToolCall) { const idx = output.content.indexOf(state.currentToolCall); state.currentToolCall.arguments = parseStreamingJson(state.currentToolCall.partialJson); @@ -1972,6 +1956,38 @@ export function mergeCursorMcpToolCallArgs( return merged; } +function endCurrentTextBlock(output: AssistantMessage, stream: AssistantMessageEventStream, state: BlockState): void { + const block = state.currentTextBlock; + if (!block) return; + const idx = output.content.indexOf(block); + delete (block as { index?: number }).index; + stream.push({ + type: "text_end", + contentIndex: idx, + content: block.text, + partial: output, + }); + state.setTextBlock(null); +} + +function endCurrentThinkingBlock( + output: AssistantMessage, + stream: AssistantMessageEventStream, + state: BlockState, +): void { + const block = state.currentThinkingBlock; + if (!block) return; + const idx = output.content.indexOf(block); + delete (block as { index?: number }).index; + stream.push({ + type: "thinking_end", + contentIndex: idx, + content: block.thinking, + partial: output, + }); + state.setThinkingBlock(null); +} + /** Exported for tests: drives one Cursor interaction update through the streaming state machine. */ export function processInteractionUpdate( update: any, @@ -2017,18 +2033,10 @@ export function processInteractionUpdate( const idx = output.content.indexOf(state.currentThinkingBlock!); stream.push({ type: "thinking_delta", contentIndex: idx, delta, partial: output }); } else if (updateCase === "thinkingCompleted") { - if (state.currentThinkingBlock) { - const idx = output.content.indexOf(state.currentThinkingBlock); - delete (state.currentThinkingBlock as any).index; - stream.push({ - type: "thinking_end", - contentIndex: idx, - content: state.currentThinkingBlock.thinking, - partial: output, - }); - state.setThinkingBlock(null); - } + endCurrentThinkingBlock(output, stream, state); } else if (updateCase === "toolCallStarted") { + endCurrentTextBlock(output, stream, state); + endCurrentThinkingBlock(output, stream, state); const toolCall = update.message.value.toolCall; if (toolCall) { const mcpCall = toolCall.mcpToolCall; diff --git a/packages/ai/test/cursor-streaming-args.test.ts b/packages/ai/test/cursor-streaming-args.test.ts index 9e6c5b1a3..ecb5ae7c4 100644 --- a/packages/ai/test/cursor-streaming-args.test.ts +++ b/packages/ai/test/cursor-streaming-args.test.ts @@ -116,6 +116,16 @@ function completeMcpToolCall(h: Harness, args: Record | unde ); } +function pushTextDelta(h: Harness, text: string): void { + processInteractionUpdate( + { message: { case: "textDelta", value: { text } } }, + h.output, + h.stream, + h.state, + h.usageState, + ); +} + describe("mergeCursorMcpToolCallArgs", () => { it("returns streamed args unchanged when completion is undefined", () => { const streamed = { tasks: [{ assignment: "do" }], context: "ctx" }; @@ -160,6 +170,31 @@ describe("mergeCursorMcpToolCallArgs", () => { }); }); +describe("processInteractionUpdate content block ordering", () => { + it("opens a new text block after a completed tool call", () => { + const h = newHarness(); + + pushTextDelta(h, "before "); + startMcpToolCall(h, "bash"); + completeMcpToolCall(h, undefined); + pushTextDelta(h, "after"); + + expect(h.output.content.map(block => block.type)).toEqual(["text", "toolCall", "text"]); + expect(h.output.content[0]).toMatchObject({ type: "text", text: "before " }); + expect(h.output.content[1]).toMatchObject({ type: "toolCall", name: "bash" }); + expect(h.output.content[2]).toMatchObject({ type: "text", text: "after" }); + expect(h.captured.map(event => event.type)).toEqual([ + "text_start", + "text_delta", + "text_end", + "toolcall_start", + "toolcall_end", + "text_start", + "text_delta", + ]); + }); +}); + describe("processInteractionUpdate args_text_delta handling", () => { it("treats cumulative argsTextDelta snapshots as snapshots, not append-only fragments", () => { const h = newHarness();