From 12d0363b7598e4bc70976991bda7fff8670e9ada Mon Sep 17 00:00:00 2001 From: robomp-bot Date: Wed, 29 Jul 2026 17:33:52 +0900 Subject: [PATCH] fix(collab-web): make host state authoritative for guest liveness --- packages/collab-web/CHANGELOG.md | 4 +++ .../src/components/shell/Composer.tsx | 2 +- packages/collab-web/src/lib/client.ts | 10 ++++++- packages/collab-web/test/client.test.ts | 28 +++++++++++++++++++ 4 files changed, 42 insertions(+), 2 deletions(-) diff --git a/packages/collab-web/CHANGELOG.md b/packages/collab-web/CHANGELOG.md index cf07b4023..35bbe08f4 100644 --- a/packages/collab-web/CHANGELOG.md +++ b/packages/collab-web/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed the guest rendering idle while the host agent is running (spinner vanishing mid-turn after a reconnect dropped `agent_start`): host `state` frames are now authoritative for liveness in both directions, and an idle report clears tool cards pinned by a dropped `tool_execution_end`. + ## [17.1.0] - 2026-07-24 ### Fixed diff --git a/packages/collab-web/src/components/shell/Composer.tsx b/packages/collab-web/src/components/shell/Composer.tsx index 69b2ca033..01bd1a670 100644 --- a/packages/collab-web/src/components/shell/Composer.tsx +++ b/packages/collab-web/src/components/shell/Composer.tsx @@ -117,7 +117,7 @@ export function Composer({ client, snapshot }: ComposerProps): ReactNode { const readOnly = snapshot.readOnly; const uiRequest = snapshot.uiRequest; const canPrompt = live && !readOnly; - const busy = snapshot.working || (snapshot.state?.isStreaming ?? false); + const busy = snapshot.working; const queued = snapshot.state?.queuedMessageCount ?? 0; const canSend = canPrompt && text.trim().length > 0; diff --git a/packages/collab-web/src/lib/client.ts b/packages/collab-web/src/lib/client.ts index 6de49294c..0ab8e0e30 100644 --- a/packages/collab-web/src/lib/client.ts +++ b/packages/collab-web/src/lib/client.ts @@ -334,8 +334,16 @@ export class GuestClient { break; case "state": this.#state = frame.state; + // Host state is authoritative for liveness in both directions: the + // payload is built at fire time, so `isStreaming` is never stale. + // This is the only path that recovers a stuck-idle guest — a dropped + // `agent_start` (most often a mid-stream reconnect) otherwise leaves + // `#working` false forever, since nothing else sets it true. + this.#working = frame.state.isStreaming; if (!frame.state.isStreaming) { - this.#working = false; + // Host idle implies no tool can be running, so clear any card + // pinned by a dropped `tool_execution_end` off this signal. + this.#activeTools = new Map(); if (this.#streamDone) { this.#stream = null; this.#streamDone = false; diff --git a/packages/collab-web/test/client.test.ts b/packages/collab-web/test/client.test.ts index 11f8ada91..087dc1364 100644 --- a/packages/collab-web/test/client.test.ts +++ b/packages/collab-web/test/client.test.ts @@ -192,6 +192,34 @@ describe("GuestClient frame apply", () => { client.applyFrameForTest({ t: "state", state: { ...STATE, isStreaming: false } }); expect(client.getSnapshot().working).toBe(false); }); + it("a state frame recovers a stuck-idle guest when agent_start was dropped", () => { + // The host begins streaming mid-turn, but the matching `agent_start` + // never arrived (e.g. dropped on a reconnect). Before the fix nothing + // set `working` true except `agent_start`, so the guest stayed idle. + const client = liveClient(); + expect(client.getSnapshot().working).toBe(false); + client.applyFrameForTest({ t: "state", state: { ...STATE, isStreaming: true } }); + expect(client.getSnapshot().working).toBe(true); + }); + + it("an idle state frame clears a pinned tool card when tool_execution_end was dropped", () => { + // Host reports idle, but the matching `tool_execution_end` was dropped, + // leaving a stuck tool card. The authoritative idle signal must clear it. + const client = liveClient(); + client.applyFrameForTest({ + t: "event", + event: { + type: "tool_execution_start", + toolCallId: "tc1", + toolName: "bash", + args: { command: "ls" }, + intent: "Listing", + }, + }); + expect(client.getSnapshot().activeTools.size).toBe(1); + client.applyFrameForTest({ t: "state", state: { ...STATE, isStreaming: false } }); + expect(client.getSnapshot().activeTools.size).toBe(0); + }); it("bus progress frames update the progress map", () => { const client = liveClient();