Skip to content

Commit 4841bab

Browse files
committed
fix(stream): close open text/reasoning parts before tool blocks
The earlier ordering fix (328aecc) closed parts on text<->reasoning transitions, but blocks-mode tool parts were enqueued while the narration part stayed open. Hosts position a part where it STARTED, so text streamed after a tool call appended to the pre-tool part and rendered ABOVE the tool block. Close the open text/reasoning part before emitting tool parts — but only when parts are actually emitted: edit calls buffer until their result (no parts at call time), so the narration part stays alive across that gap instead of splitting needlessly. Adds three regression tests: text/tool/text ordering, reasoning/tool/ reasoning ordering, and the buffered-edit no-split case.
1 parent efe029a commit 4841bab

2 files changed

Lines changed: 135 additions & 4 deletions

File tree

src/provider/stream-map.ts

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -967,12 +967,22 @@ export function cursorEventsToStream(
967967
break;
968968
case "tool-call":
969969
if (toolDisplay === "blocks") {
970-
for (const part of blockToolCallParts(
970+
const parts = blockToolCallParts(
971971
event.id,
972972
event.name,
973973
event.input,
974974
toolState,
975-
)) {
975+
);
976+
// Edit calls buffer until their result (no parts yet) — keep
977+
// the open narration part alive across the gap. Every other
978+
// tool emits immediately, so close open text/reasoning first
979+
// so post-tool narration lands in a later part (hosts position
980+
// parts where they START).
981+
if (parts.length > 0) {
982+
closeText();
983+
closeReasoning();
984+
}
985+
for (const part of parts) {
976986
controller.enqueue(part);
977987
}
978988
} else {
@@ -981,13 +991,18 @@ export function cursorEventsToStream(
981991
break;
982992
case "tool-result":
983993
if (toolDisplay === "blocks") {
984-
for (const part of blockToolResultParts(
994+
const parts = blockToolResultParts(
985995
event.id,
986996
event.name,
987997
event.result,
988998
event.isError,
989999
toolState,
990-
)) {
1000+
);
1001+
if (parts.length > 0) {
1002+
closeText();
1003+
closeReasoning();
1004+
}
1005+
for (const part of parts) {
9911006
controller.enqueue(part);
9921007
}
9931008
} else if (event.isError) {

test/stream-map.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,122 @@ describe("cursorEventsToStream", () => {
9292
});
9393
});
9494

95+
it("closes the open text part before tool blocks so post-tool text renders below them", async () => {
96+
// Interleaved turn: narration → tool activity → conclusion. The conclusion
97+
// must land in a NEW part that starts after the tool block — appending it
98+
// to the pre-tool part makes it render ABOVE the tool block in the UI.
99+
const events: CursorEvent[] = [
100+
{ type: "text-delta", text: "Let me check. " },
101+
{
102+
type: "tool-call",
103+
id: "c1",
104+
name: "shell",
105+
input: { command: "ls" },
106+
},
107+
{
108+
type: "tool-result",
109+
id: "c1",
110+
name: "shell",
111+
result: { status: "success", value: { stdout: "a.ts", stderr: "", exitCode: 0 } },
112+
isError: false,
113+
},
114+
{ type: "text-delta", text: "Found it." },
115+
{ type: "finish" },
116+
];
117+
const parts = await collect(cursorEventsToStream(gen(events)));
118+
expect(types(parts)).toEqual([
119+
"stream-start",
120+
"text-start", // text-0: narration
121+
"text-delta",
122+
"text-end", // closed BEFORE the tool block
123+
"tool-call",
124+
"tool-result",
125+
"text-start", // text-1: conclusion, positioned after the tool block
126+
"text-delta",
127+
"text-end",
128+
"finish",
129+
]);
130+
const starts = parts.filter((p) => p.type === "text-start");
131+
expect(starts.map((p) => (p as { id: string }).id)).toEqual([
132+
"text-0",
133+
"text-1",
134+
]);
135+
});
136+
137+
it("closes the open reasoning part before tool blocks", async () => {
138+
const events: CursorEvent[] = [
139+
{ type: "reasoning-delta", text: "hmm " },
140+
{
141+
type: "tool-call",
142+
id: "c1",
143+
name: "shell",
144+
input: { command: "ls" },
145+
},
146+
{
147+
type: "tool-result",
148+
id: "c1",
149+
name: "shell",
150+
result: { status: "success", value: { stdout: "", stderr: "", exitCode: 0 } },
151+
isError: false,
152+
},
153+
{ type: "reasoning-delta", text: "now I see" },
154+
{ type: "finish", text: "done" },
155+
];
156+
const parts = await collect(cursorEventsToStream(gen(events)));
157+
expect(types(parts)).toEqual([
158+
"stream-start",
159+
"reasoning-start", // reasoning-0
160+
"reasoning-delta",
161+
"reasoning-end", // closed BEFORE the tool block
162+
"tool-call",
163+
"tool-result",
164+
"reasoning-start", // reasoning-1, after the tool block
165+
"reasoning-delta",
166+
"reasoning-end",
167+
"text-start",
168+
"text-delta",
169+
"text-end",
170+
"finish",
171+
]);
172+
});
173+
174+
it("does not split the text part for a buffered edit call (parts emit at result time)", async () => {
175+
// Edit calls emit NO parts at call time (the diff arrives with the
176+
// result), so the open text part must NOT be closed until the result
177+
// actually emits the edit block.
178+
const events: CursorEvent[] = [
179+
{ type: "text-delta", text: "editing " },
180+
{ type: "tool-call", id: "e1", name: "edit", input: { path: "/a.ts" } },
181+
{ type: "text-delta", text: "still narrating " },
182+
{
183+
type: "tool-result",
184+
id: "e1",
185+
name: "edit",
186+
result: {
187+
status: "success",
188+
value: { diffString: "@@\n-x\n+y", linesAdded: 1, linesRemoved: 1 },
189+
},
190+
isError: false,
191+
},
192+
{ type: "text-delta", text: "done" },
193+
{ type: "finish" },
194+
];
195+
const parts = await collect(cursorEventsToStream(gen(events)));
196+
expect(types(parts)).toEqual([
197+
"stream-start",
198+
"text-start", // text-0 spans the buffered call (nothing emitted yet)
199+
"text-delta",
200+
"text-delta",
201+
"text-end", // closed when the edit parts actually emit
202+
"tool-call",
203+
"tool-result",
204+
"text-start", // text-1
205+
"text-delta",
206+
"text-end",
207+
"finish",
208+
]);
209+
});
210+
95211
it("closes the open text part when reasoning resumes so parts render in true order", async () => {
96212
// Interleaved turn: intro text → tool/reasoning activity → final text. The
97213
// final text must land in a NEW part (text-1) that starts after the

0 commit comments

Comments
 (0)