Skip to content

Commit 18f6c1e

Browse files
committed
fix(runtime): close open reasoning message in REASONING_END and on REASONING_START reopen
1 parent a48e9a1 commit 18f6c1e

2 files changed

Lines changed: 81 additions & 0 deletions

File tree

packages/runtime/src/agent/__tests__/converter-tanstack.test.ts

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -496,6 +496,72 @@ describe("TanStack AI converter — reasoning", () => {
496496
expect(types).toContain(EventType.REASONING_END);
497497
});
498498

499+
it("emits REASONING_MESSAGE_END before REASONING_END when upstream sends END with message still open", async () => {
500+
// Regression: if the converter received REASONING_END while a message
501+
// was still open, it cleared run-open and emitted END only — leaving
502+
// message-open true. The next non-reasoning chunk then triggered
503+
// closeReasoningIfOpen, which emitted MSG_END AFTER END (wrong order).
504+
// Fix: REASONING_END handler closes any open message first.
505+
const agent = createAgent("tanstack", [
506+
tanstackReasoningStart("r1"),
507+
tanstackReasoningMessageStart("r1"),
508+
tanstackReasoningMessageContent("r1", "thinking"),
509+
// Upstream skips MSG_END and goes straight to END
510+
tanstackReasoningEnd("r1"),
511+
tanstackTextChunk("Hi"),
512+
]);
513+
const events = await collectEvents(agent.run(createDefaultInput()));
514+
const types = events.map((e) => e.type);
515+
516+
const msgEndIdx = types.indexOf(EventType.REASONING_MESSAGE_END);
517+
const endIdx = types.indexOf(EventType.REASONING_END);
518+
519+
expect(msgEndIdx).toBeGreaterThan(-1);
520+
expect(endIdx).toBeGreaterThan(-1);
521+
expect(msgEndIdx).toBeLessThan(endIdx);
522+
// No duplicate MSG_END or END from auto-close on the text chunk
523+
expect(
524+
types.filter((t) => t === EventType.REASONING_MESSAGE_END),
525+
).toHaveLength(1);
526+
expect(types.filter((t) => t === EventType.REASONING_END)).toHaveLength(1);
527+
});
528+
529+
it("auto-closes prior reasoning run when a new REASONING_START arrives without END", async () => {
530+
// Regression: REASONING_START used to overwrite reasoningMessageId
531+
// unconditionally, orphaning the prior run's MSG_END / END.
532+
// Fix: REASONING_START handler calls closeReasoningIfOpen() first.
533+
const agent = createAgent("tanstack", [
534+
tanstackReasoningStart("r1"),
535+
tanstackReasoningMessageStart("r1"),
536+
tanstackReasoningMessageContent("r1", "first"),
537+
// Second START with no intervening END
538+
tanstackReasoningStart("r2"),
539+
tanstackReasoningMessageStart("r2"),
540+
tanstackReasoningMessageContent("r2", "second"),
541+
tanstackReasoningMessageEnd("r2"),
542+
tanstackReasoningEnd("r2"),
543+
]);
544+
const events = await collectEvents(agent.run(createDefaultInput()));
545+
const types = events.map((e) => e.type);
546+
547+
// Two complete START → MSG_START → ... → MSG_END → END sequences
548+
expect(
549+
types.filter((t) => t === EventType.REASONING_START),
550+
).toHaveLength(2);
551+
expect(types.filter((t) => t === EventType.REASONING_END)).toHaveLength(2);
552+
expect(
553+
types.filter((t) => t === EventType.REASONING_MESSAGE_END),
554+
).toHaveLength(2);
555+
556+
// First START's prior message gets closed BEFORE the second START
557+
const firstEndIdx = types.indexOf(EventType.REASONING_END);
558+
const secondStartIdx = types.indexOf(
559+
EventType.REASONING_START,
560+
firstEndIdx + 1,
561+
);
562+
expect(firstEndIdx).toBeLessThan(secondStartIdx);
563+
});
564+
499565
it("does NOT duplicate REASONING_MESSAGE_END when upstream emits it explicitly before text", async () => {
500566
// Regression: a single isInReasoning flag conflated message-open with
501567
// run-open, so closeReasoningIfOpen on TEXT_MESSAGE_CONTENT emitted a

packages/runtime/src/agent/converters/tanstack.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -367,6 +367,9 @@ export async function* convertTanStackStream(
367367
yield resultEvent;
368368
toolNamesById.delete(toolCallId);
369369
} else if (type === "REASONING_START") {
370+
// If a prior reasoning run is still open (no REASONING_END before this
371+
// new START), close it cleanly first so MSG_END / END pair correctly.
372+
yield* closeReasoningIfOpen();
370373
reasoningRunOpen = true;
371374
reasoningMessageId = (raw.messageId as string) ?? randomUUID();
372375
const startEvt: ReasoningStartEvent = {
@@ -397,6 +400,18 @@ export async function* convertTanStackStream(
397400
};
398401
yield evt;
399402
} else if (type === "REASONING_END") {
403+
// If upstream sends REASONING_END while a message is still open, emit
404+
// the missing REASONING_MESSAGE_END FIRST so the closing pair stays in
405+
// order (MSG_END before END). Otherwise the next non-reasoning chunk
406+
// would trigger closeReasoningIfOpen and emit MSG_END after END.
407+
if (reasoningMessageOpen) {
408+
reasoningMessageOpen = false;
409+
const msgEnd: ReasoningMessageEndEvent = {
410+
type: EventType.REASONING_MESSAGE_END,
411+
messageId: reasoningMessageId,
412+
};
413+
yield msgEnd;
414+
}
400415
reasoningRunOpen = false;
401416
const evt: ReasoningEndEvent = {
402417
type: EventType.REASONING_END,

0 commit comments

Comments
 (0)