From e8c3d4a1ab419430bc57cd9b27c302b05d137e4f Mon Sep 17 00:00:00 2001 From: rekram1-node Date: Mon, 7 Sep 2026 15:58:16 +0000 Subject: [PATCH 1/3] fix(tui): finish reasoning rows on end event --- packages/tui/src/routes/session/rows.ts | 32 ++++++++-- .../tui/test/cli/tui/session-rows.test.ts | 17 ++++++ packages/tui/test/reasoning-duration.test.tsx | 60 +++++++++++++++++++ 3 files changed, 103 insertions(+), 6 deletions(-) create mode 100644 packages/tui/test/reasoning-duration.test.tsx diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index b565268db570..1e9fbb46266f 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -176,6 +176,20 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi }), ) + const completeReasoning = (ref: PartRef) => + setRows( + produce((draft) => { + if (!hasPart(draft, ref)) append(draft, ref, { type: "reasoning" }, queuedStart(draft)) + const row = draft.find( + (row) => + row.type === "group" && + row.kind === "reasoning" && + row.refs.some((item) => item.messageID === ref.messageID && item.partID === ref.partID), + ) + if (row?.type === "group" && row.kind === "reasoning") row.completed = true + }), + ) + const appendFooter = (messageID: string) => setRows( produce((draft) => { @@ -248,10 +262,7 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi }), data.on("session.reasoning.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) - appendPart( - { messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }, - { type: "reasoning" }, - ) + completeReasoning({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }) }), data.on("session.tool.input.started", (event) => { if (event.data.sessionID === sessionID()) @@ -437,17 +448,26 @@ export function resolvePart(message: SessionMessageAssistant, partID: string) { return message.content.filter((part) => part.type === match[1])[ordinal] } -type AppendPart = { type: "text" } | { type: "reasoning" } | { type: "tool"; name: string } +type AppendPart = + | { type: "text" } + | { type: "reasoning"; time?: { completed?: number } } + | { type: "tool"; name: string } function append(rows: SessionRow[], ref: PartRef, part: AppendPart, index = rows.length) { if (part.type === "reasoning") { const previous = rows[index - 1] if (previous?.type === "group" && previous.kind === "reasoning") { previous.refs.push(ref) + previous.completed &&= part.time?.completed !== undefined return } completePrevious(rows, index) - rows.splice(index, 0, { type: "group", kind: "reasoning", refs: [ref], completed: false }) + rows.splice(index, 0, { + type: "group", + kind: "reasoning", + refs: [ref], + completed: part.time?.completed !== undefined, + }) return } if (part.type === "tool" && exploration(part.name)) { diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index 2a084443176a..9d700b314997 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -376,6 +376,23 @@ test("groups adjacent reasoning parts until a visible boundary", () => { ]) }) +test("completes a reasoning group before the assistant step finishes", () => { + const messages: SessionMessageInfo[] = [ + assistant("assistant-1", [ + { type: "reasoning", text: "Finished thinking", time: { created: 1_000, completed: 6_800 } }, + ]), + ] + + expect(reduceSessionRows(messages)).toEqual([ + { + type: "group", + kind: "reasoning", + completed: true, + refs: [{ messageID: "assistant-1", partID: "reasoning:0" }], + }, + ]) +}) + test("groups across empty assistant reasoning parts", () => { const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ diff --git a/packages/tui/test/reasoning-duration.test.tsx b/packages/tui/test/reasoning-duration.test.tsx new file mode 100644 index 000000000000..ac7c106315b5 --- /dev/null +++ b/packages/tui/test/reasoning-duration.test.tsx @@ -0,0 +1,60 @@ +import { expect, test } from "bun:test" +import { createAppFixture } from "./fixture/app" +import { tmpdir } from "./fixture/fixture" +import { directory, json } from "./fixture/tui-client" + +test("shows reasoning duration as soon as reasoning ends", async () => { + await using state = await tmpdir() + const session = { + id: "ses_reasoning", + title: "Reasoning duration", + projectID: "project", + location: { directory }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + } + const messages = [ + { id: "user-1", type: "user", text: "Think", time: { created: 0 } }, + { + id: "assistant-1", + type: "assistant", + agent: "build", + model: { providerID: "test", id: "test" }, + content: [{ type: "reasoning", text: "Working", time: { created: 1_000 } }], + time: { created: 1_000 }, + }, + ] + await using setup = await createAppFixture({ + state: state.path, + args: { sessionID: session.id }, + config: { animations: false, tabs: { enabled: false }, session: { thinking: "hide" } }, + fetch: (url) => { + if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) + if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) + if (url.pathname === `/api/session/${session.id}/message`) + return json({ data: messages.toReversed(), cursor: {} }) + if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] }) + if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] }) + return undefined + }, + }) + + await setup.waitForFrame((frame) => frame.includes("Thinking")) + expect(setup.captureCharFrame()).not.toContain("5.8s") + setup.events.emit({ + id: "evt_reasoning_ended", + created: 6_800, + type: "session.reasoning.ended", + durable: { aggregateID: session.id, seq: 1, version: 1 }, + data: { + sessionID: session.id, + assistantMessageID: "assistant-1", + ordinal: 0, + text: "Working", + }, + }) + + await setup.waitForFrame((frame) => frame.includes("Thought") && frame.includes("5.8s")) + expect(setup.captureCharFrame()).toContain("Thought · 5.8s") +}) From 8d7417460edabb5be14eed73a45183acd6dda33e Mon Sep 17 00:00:00 2001 From: rekram1-node Date: Mon, 7 Sep 2026 16:02:45 +0000 Subject: [PATCH 2/3] refactor(tui): reuse reasoning append path --- packages/tui/src/routes/session/rows.ts | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/packages/tui/src/routes/session/rows.ts b/packages/tui/src/routes/session/rows.ts index 1e9fbb46266f..c1f61f849c15 100644 --- a/packages/tui/src/routes/session/rows.ts +++ b/packages/tui/src/routes/session/rows.ts @@ -171,15 +171,11 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi const appendPart = (ref: PartRef, part: AppendPart) => setRows( produce((draft) => { - if (hasPart(draft, ref)) return - append(draft, ref, part, queuedStart(draft)) - }), - ) - - const completeReasoning = (ref: PartRef) => - setRows( - produce((draft) => { - if (!hasPart(draft, ref)) append(draft, ref, { type: "reasoning" }, queuedStart(draft)) + if (!hasPart(draft, ref)) { + append(draft, ref, part, queuedStart(draft)) + return + } + if (part.type !== "reasoning" || part.time?.completed === undefined) return const row = draft.find( (row) => row.type === "group" && @@ -262,7 +258,10 @@ export function createSessionRows(sessionID: Accessor, onSynced?: (sessi }), data.on("session.reasoning.ended", (event) => { if (event.data.sessionID === sessionID() && event.data.text.trim()) - completeReasoning({ messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }) + appendPart( + { messageID: event.data.assistantMessageID, partID: `reasoning:${event.data.ordinal}` }, + { type: "reasoning", time: { completed: event.created } }, + ) }), data.on("session.tool.input.started", (event) => { if (event.data.sessionID === sessionID()) From e056b1ad3f18c1fd8bd4bb30ae960f8026458ad8 Mon Sep 17 00:00:00 2001 From: rekram1-node Date: Mon, 7 Sep 2026 16:06:52 +0000 Subject: [PATCH 3/3] test(tui): remove reasoning timing coverage --- .../tui/test/cli/tui/session-rows.test.ts | 17 ------ packages/tui/test/reasoning-duration.test.tsx | 60 ------------------- 2 files changed, 77 deletions(-) delete mode 100644 packages/tui/test/reasoning-duration.test.tsx diff --git a/packages/tui/test/cli/tui/session-rows.test.ts b/packages/tui/test/cli/tui/session-rows.test.ts index 9d700b314997..2a084443176a 100644 --- a/packages/tui/test/cli/tui/session-rows.test.ts +++ b/packages/tui/test/cli/tui/session-rows.test.ts @@ -376,23 +376,6 @@ test("groups adjacent reasoning parts until a visible boundary", () => { ]) }) -test("completes a reasoning group before the assistant step finishes", () => { - const messages: SessionMessageInfo[] = [ - assistant("assistant-1", [ - { type: "reasoning", text: "Finished thinking", time: { created: 1_000, completed: 6_800 } }, - ]), - ] - - expect(reduceSessionRows(messages)).toEqual([ - { - type: "group", - kind: "reasoning", - completed: true, - refs: [{ messageID: "assistant-1", partID: "reasoning:0" }], - }, - ]) -}) - test("groups across empty assistant reasoning parts", () => { const messages: SessionMessageInfo[] = [ assistant("assistant-1", [ diff --git a/packages/tui/test/reasoning-duration.test.tsx b/packages/tui/test/reasoning-duration.test.tsx deleted file mode 100644 index ac7c106315b5..000000000000 --- a/packages/tui/test/reasoning-duration.test.tsx +++ /dev/null @@ -1,60 +0,0 @@ -import { expect, test } from "bun:test" -import { createAppFixture } from "./fixture/app" -import { tmpdir } from "./fixture/fixture" -import { directory, json } from "./fixture/tui-client" - -test("shows reasoning duration as soon as reasoning ends", async () => { - await using state = await tmpdir() - const session = { - id: "ses_reasoning", - title: "Reasoning duration", - projectID: "project", - location: { directory }, - cost: 0, - tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, - time: { created: 0, updated: 0 }, - } - const messages = [ - { id: "user-1", type: "user", text: "Think", time: { created: 0 } }, - { - id: "assistant-1", - type: "assistant", - agent: "build", - model: { providerID: "test", id: "test" }, - content: [{ type: "reasoning", text: "Working", time: { created: 1_000 } }], - time: { created: 1_000 }, - }, - ] - await using setup = await createAppFixture({ - state: state.path, - args: { sessionID: session.id }, - config: { animations: false, tabs: { enabled: false }, session: { thinking: "hide" } }, - fetch: (url) => { - if (url.pathname === "/api/session") return json({ data: [session], cursor: {} }) - if (url.pathname === `/api/session/${session.id}`) return json({ data: session }) - if (url.pathname === `/api/session/${session.id}/message`) - return json({ data: messages.toReversed(), cursor: {} }) - if (url.pathname === `/api/session/${session.id}/inbox`) return json({ data: [] }) - if (url.pathname === `/api/session/${session.id}/permission`) return json({ data: [] }) - return undefined - }, - }) - - await setup.waitForFrame((frame) => frame.includes("Thinking")) - expect(setup.captureCharFrame()).not.toContain("5.8s") - setup.events.emit({ - id: "evt_reasoning_ended", - created: 6_800, - type: "session.reasoning.ended", - durable: { aggregateID: session.id, seq: 1, version: 1 }, - data: { - sessionID: session.id, - assistantMessageID: "assistant-1", - ordinal: 0, - text: "Working", - }, - }) - - await setup.waitForFrame((frame) => frame.includes("Thought") && frame.includes("5.8s")) - expect(setup.captureCharFrame()).toContain("Thought · 5.8s") -})