From f4737719f671c325c8969ff18daf27a79a93df46 Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 3 Nov 2026 21:20:12 -0800 Subject: [PATCH 1/2] fix(web): end a resumed turn on its reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The reactor closes a message run right after it replies, and the page cleared "Thinking…" only on that close. A turn resumed from an approval restored after a restart has no message run, so it sends no close and the page stayed on "Thinking…" after the coordinator had replied. The page ends the live turn on the coordinator's reply as well. --- apps/web/src/pages/workspace/live-run.ts | 8 +++++++- apps/web/src/pages/workspace/transcript.test.tsx | 12 ++++++++++++ 2 files changed, 19 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..bcc9b386 100644 --- a/apps/web/src/pages/workspace/live-run.ts +++ b/apps/web/src/pages/workspace/live-run.ts @@ -111,7 +111,13 @@ export function foldLiveRun( reload, }; } - if (event.type === "message.run.ended" || event.type === "reactor.done") { + // The reactor closes the message run right after it replies, but a turn resumed from an approval + // restored after a restart has no message run to close, so its reply is the only end it sends. + if ( + event.type === "message.run.ended" || + event.type === "connector.reply" || + 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..2cb3fd09 100644 --- a/apps/web/src/pages/workspace/transcript.test.tsx +++ b/apps/web/src/pages/workspace/transcript.test.tsx @@ -188,6 +188,18 @@ describe("conversation transcript", () => { expect(awaitingCommit(done, [])).toBe(true); }); + test("a turn resumed from an approval after a restart ends on its reply", () => { + const resumed = [ + { type: "message.correlated" }, + { type: "inference.start" }, + { type: "inference.done" }, + ].reduce((run, event) => foldLiveRun(run, event, null).run, emptyLiveRun); + expect(resumed.running).toBe(true); + const replied = foldLiveRun(resumed, { type: "connector.reply" }, null).run; + expect(replied.running).toBe(false); + expect(replied.ended).toBe(true); + }); + test("a specialist's report joins its hand-off as one message and a long run of lone ones folds", () => { const summary = "Wrote the limits for your weather app, covering offline use and units."; From 631244a3b5cf697a619a17145adbefd9bc62c7df Mon Sep 17 00:00:00 2001 From: Sawyer Cutler Date: Tue, 3 Nov 2026 21:26:22 -0800 Subject: [PATCH 2/2] fix(hub): end a resumed turn's activity on its reply MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The activity tracker marked the coordinator idle only on a message run ending. A turn resumed from an approval restored after a restart has no message run, so once it called a tool the hub kept reporting the coordinator as working after it replied, and a page opened later showed "Thinking…". The tracker ends the turn on the reply as well. --- apps/hub/src/artifacts/activity.test.ts | 25 +++++++++++++++++++++++++ apps/hub/src/artifacts/activity.ts | 5 ++++- 2 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 apps/hub/src/artifacts/activity.test.ts diff --git a/apps/hub/src/artifacts/activity.test.ts b/apps/hub/src/artifacts/activity.test.ts new file mode 100644 index 00000000..615f2bcf --- /dev/null +++ b/apps/hub/src/artifacts/activity.test.ts @@ -0,0 +1,25 @@ +import { expect, test } from "bun:test"; +import { createSidecarEmitter } from "@intx/hub-sessions"; +import { createAgentActivity } from "./activity.ts"; + +const runId = "run_04ba732b8686649f77a2797393bafbc5"; + +test("a turn resumed after a restart, with no message run, ends on its reply", () => { + const events = createSidecarEmitter(); + const activity = createAgentActivity(events); + const send = (event: unknown) => + events.emit("agent.event", { + agentAddress: `${runId}@sunny-harbor-panda.localhost`, + sessionId: "session", + event, + }); + send({ + type: "tool.start", + seq: 1, + data: { call: { id: "call-1", name: "write_artifact", arguments: {} } }, + }); + expect(activity.of(runId).working).toBe(true); + send({ type: "connector.reply", seq: 2, data: { content: "Done." } }); + expect(activity.of(runId).working).toBe(false); + activity.dispose(); +}); diff --git a/apps/hub/src/artifacts/activity.ts b/apps/hub/src/artifacts/activity.ts index 3e52765f..27d951f8 100644 --- a/apps/hub/src/artifacts/activity.ts +++ b/apps/hub/src/artifacts/activity.ts @@ -108,7 +108,10 @@ export function createAgentActivity(events: Pick) { delete run.said; return; } - case "message.run.ended": { + // The reactor ends a message run right after it replies, but a turn resumed from an approval + // restored after a restart has no message run, so its reply is the only end it sends. + case "message.run.ended": + case "connector.reply": { const run = entry(local); run.working = false; run.since = now;