Merge PR #6990: fix(collab-web): make host state authoritative for guest liveness (@metaphorics)

This commit is contained in:
can1357
2026-07-31 19:41:44 +02:00
4 changed files with 38 additions and 2 deletions
+1
View File
@@ -6,6 +6,7 @@
### 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` ([#6990](https://github.com/can1357/oh-my-pi/pull/6990) by [@metaphorics](https://github.com/metaphorics)).
- Fixed an issue where the agent would stop silently without a message by ensuring terminal auto-retry failures are properly surfaced as error notices.
## [17.1.0] - 2026-07-24
@@ -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;
+8 -1
View File
@@ -334,8 +334,15 @@ 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 covers a connected guest that misses the discrete `agent_start`
// without receiving a new `welcome` (for example, mid-stream).
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;
+28
View File
@@ -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();