diff --git a/apps/web/src/pages/workspace/live-run.ts b/apps/web/src/pages/workspace/live-run.ts index ae0cc6b3..bbd84392 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -111,9 +111,19 @@ 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() }, + 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 d62c89d2..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"; @@ -7,6 +7,7 @@ import { awaitingCommit, emptyLiveRun, foldLiveRun, + pendingLiveTurn, waitingOnPerson, } from "./live-run.ts"; import { RoomThread } from "./transcript.tsx"; @@ -164,6 +165,68 @@ 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("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,