From 372a4543e181ce3ec31e145651b4fab4b961a60e Mon Sep 17 00:00:00 2001 From: etodanik Date: Tue, 3 Nov 2026 16:33:44 +0300 Subject: [PATCH 1/2] fix(web): stop thinking when the coordinator replies End the live reply on connector.reply so a missed message-end frame cannot leave the thinking indicator active. Add one regression test covering completion, text preservation, and thinking restarting for the next generation. --- apps/web/src/pages/workspace/live-run.ts | 7 +++- .../src/pages/workspace/transcript.test.tsx | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/web/src/pages/workspace/live-run.ts b/apps/web/src/pages/workspace/live-run.ts index ae0cc6b3..49c8cd69 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -111,7 +111,12 @@ export function foldLiveRun( reload, }; } - if (event.type === "message.run.ended" || event.type === "reactor.done") { + // A connector reply is terminal for this message even if the following end frame is missed. + if ( + event.type === "connector.reply" || + event.type === "message.run.ended" || + event.type === "reactor.done" + ) { return { run: { ...run, ended: true, running: false, endedAt: Date.now() }, reload, diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index d62c89d2..be6d498b 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -7,6 +7,7 @@ import { awaitingCommit, emptyLiveRun, foldLiveRun, + pendingLiveTurn, waitingOnPerson, } from "./live-run.ts"; import { RoomThread } from "./transcript.tsx"; @@ -164,6 +165,39 @@ describe("conversation transcript", () => { ); }); + test("a connector reply stops thinking without an end frame and keeps text until it is committed", () => { + const started = foldLiveRun( + emptyLiveRun, + { type: "inference.start" }, + null, + ).run; + const generated = foldLiveRun( + started, + { + type: "inference.text.delta", + data: { partial: { text: "The constraints are ready." } }, + }, + null, + ).run; + const done = foldLiveRun(generated, { type: "inference.done" }, null).run; + expect(done.running).toBe(true); + const replied = foldLiveRun(done, { type: "connector.reply" }, null); + expect(replied.reload).toBe(true); + expect(replied.run.running).toBe(false); + expect(replied.run.ended).toBe(true); + expect(replied.run.endedAt).not.toBeNull(); + expect(pendingLiveTurn(replied.run, [])?.text).toBe( + "The constraints are ready.", + ); + const committed = [ + { role: "assistant" as const, text: "The constraints are ready.", at: 1 }, + ]; + expect(pendingLiveTurn(replied.run, committed)).toBeNull(); + const next = foldLiveRun(replied.run, { type: "inference.start" }, 1).run; + expect(next.running).toBe(true); + expect(next.ended).toBe(false); + }); + test("an inference call that finishes with tool work settles even while the message run stays open on a park", () => { const started = foldLiveRun( emptyLiveRun, From e2dd8949971a0d2d335002ff04048c3e6fd8ecba Mon Sep 17 00:00:00 2001 From: etodanik Date: Tue, 3 Nov 2026 22:20:44 +0300 Subject: [PATCH 2/2] fix(web): preserve the first reply completion time Keep the first terminal timestamp when duplicate completion frames arrive, so an old message end cannot clear a newer send's waiting indicator. Cover duplicate completion and the next generation with a controlled clock. --- apps/web/src/pages/workspace/live-run.ts | 7 ++++- .../src/pages/workspace/transcript.test.tsx | 31 ++++++++++++++++++- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/apps/web/src/pages/workspace/live-run.ts b/apps/web/src/pages/workspace/live-run.ts index 49c8cd69..bbd84392 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -118,7 +118,12 @@ export function foldLiveRun( event.type === "reactor.done" ) { return { - run: { ...run, ended: true, running: false, endedAt: Date.now() }, + run: { + ...run, + ended: true, + running: false, + endedAt: run.ended ? run.endedAt : Date.now(), + }, reload, }; } diff --git a/apps/web/src/pages/workspace/transcript.test.tsx b/apps/web/src/pages/workspace/transcript.test.tsx index be6d498b..7c73aae3 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -1,4 +1,4 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import { isValidElement, type ReactNode } from "react"; import { renderToStaticMarkup } from "react-dom/server"; import { wording } from "../../config.ts"; @@ -198,6 +198,35 @@ describe("conversation transcript", () => { expect(next.ended).toBe(false); }); + test("duplicate completion frames keep the first end time before a new send", () => { + const clock = spyOn(Date, "now").mockReturnValue(100); + try { + const replied = foldLiveRun( + emptyLiveRun, + { type: "connector.reply" }, + null, + ).run; + const sentAt = 150; + clock.mockReturnValue(200); + const ended = foldLiveRun( + replied, + { type: "message.run.ended" }, + null, + ).run; + expect(ended.endedAt).toBe(replied.endedAt); + expect(ended.endedAt).toBeLessThan(sentAt); + const next = foldLiveRun(ended, { type: "inference.start" }, null).run; + const nextReply = foldLiveRun( + next, + { type: "connector.reply" }, + null, + ).run; + expect(nextReply.endedAt).toBe(200); + } finally { + clock.mockRestore(); + } + }); + test("an inference call that finishes with tool work settles even while the message run stays open on a park", () => { const started = foldLiveRun( emptyLiveRun,