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
This commit is contained in:
roboomp
2026-06-18 03:35:44 +00:00
parent 1dfac0322c
commit 4cd3ca4eca
3 changed files with 73 additions and 29 deletions
+1
View File
@@ -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
+37 -29
View File
@@ -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;
@@ -116,6 +116,16 @@ function completeMcpToolCall(h: Harness, args: Record<string, Uint8Array> | 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();