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:
@@ -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
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user