diff --git a/packages/coding-agent/src/collab/guest.ts b/packages/coding-agent/src/collab/guest.ts index 0f33fcda4..2ae87719e 100644 --- a/packages/coding-agent/src/collab/guest.ts +++ b/packages/coding-agent/src/collab/guest.ts @@ -275,6 +275,16 @@ export class CollabGuestLink { } return; } + if (frame.t === "error" && !this.#welcomed && !this.#left) { + // Pre-welcome errors are the host's targeted reply to our + // hello (e.g. protocol mismatch): no welcome will follow. + // Fail the join with the host's message instead of hanging + // until the welcome timeout. + this.#clearWelcomeTimer(); + if (joined) this.#ctx.showError(`Collab host: ${frame.message}`); + else firstWelcome.reject(new Error(frame.message)); + return; + } if (!this.#welcomed || this.#left) return; this.#applyFrame(frame); }) diff --git a/packages/coding-agent/test/collab/guest-ui-request.test.ts b/packages/coding-agent/test/collab/guest-ui-request.test.ts index c7bfef88e..13838859d 100644 --- a/packages/coding-agent/test/collab/guest-ui-request.test.ts +++ b/packages/coding-agent/test/collab/guest-ui-request.test.ts @@ -14,10 +14,13 @@ import { afterEach, beforeEach, describe, expect, it, spyOn } from "bun:test"; import { generateRoomKey, importRoomKey } from "@oh-my-pi/pi-coding-agent/collab/crypto"; import { CollabGuestLink } from "@oh-my-pi/pi-coding-agent/collab/guest"; +import { CollabHost } from "@oh-my-pi/pi-coding-agent/collab/host"; import { COLLAB_PROTO, + type CollabFrame, type CollabSessionState, formatCollabLink, + parseCollabLink, rewriteEnvelopePeer, unpackEnvelope, } from "@oh-my-pi/pi-coding-agent/collab/protocol"; @@ -523,3 +526,167 @@ describe("collab TUI guest ui-request handling (#4049)", () => { expect(h.uiResponses).toEqual([]); }); }); + +// ── Proto handshake (#4049: ui-request frames require COLLAB_PROTO >= 3) ─── +// +// The ui-request/ui-response grammar shipped without a proto bump, so v2 +// guests joined fine and silently dropped host asks. These tests pin the +// enforcement: a real CollabHost must reject stale-proto hellos with an +// observable error frame (never a welcome), current-proto guests must still +// complete a full ui-request round trip, and a rejected CollabGuestLink +// join must fail fast with the host's reason instead of hanging until the +// welcome timeout. + +/** Minimal InteractiveModeContext double: only the members CollabHost touches. */ +function makeHostContext(): InteractiveModeContext { + return { + settings: { get: () => "" }, + sessionManager: { + getSessionId: () => "sess-proto", + getCwd: () => "/tmp", + snapshotForReplication: () => ({ + header: { type: "session", id: "sess-proto", timestamp: new Date().toISOString(), cwd: "/tmp" }, + entries: [], + }), + onEntryAppended: undefined, + }, + session: { + isStreaming: false, + queuedMessageCount: 0, + sessionName: "proto test", + model: undefined, + thinkingLevel: undefined, + subscribe: () => () => {}, + emitNotice: () => {}, + promptCustomMessage: () => Promise.resolve(), + abort: () => Promise.resolve(), + }, + eventBus: undefined, + statusLine: { + setCollabStatus: () => {}, + invalidate: () => {}, + getCachedContextBreakdown: () => ({ usedTokens: 0, contextWindow: 0 }), + }, + ui: { requestRender: () => {} }, + showStatus: () => {}, + collabHost: undefined, + } as unknown as InteractiveModeContext; +} + +/** Raw wire-speaking guest with a configurable hello proto. */ +async function joinRawGuest( + link: string, + proto: number, +): Promise<{ socket: CollabSocket; nextFrame(): Promise }> { + const parsed = parseCollabLink(link); + if ("error" in parsed) throw new Error(parsed.error); + const writeToken = parsed.writeToken ? Buffer.from(parsed.writeToken).toString("base64url") : undefined; + const key = await importRoomKey(parsed.key); + const socket = new CollabSocket({ wsUrl: parsed.wsUrl, role: "guest", key }); + const queue: CollabFrame[] = []; + const waiters: ((frame: CollabFrame) => void)[] = []; + // Directed welcome/error/ui frames only: the host's debounced broadcasts + // (state/agents/entry/event/bus) and the snapshot-chunk train interleave + // nondeterministically with the frames these tests assert on. + const filtered: Record = { + state: true, + agents: true, + entry: true, + event: true, + bus: true, + "snapshot-chunk": true, + }; + socket.onFrame = frame => { + if (filtered[frame.t]) return; + const waiter = waiters.shift(); + if (waiter) waiter(frame); + else queue.push(frame); + }; + socket.onOpen = () => socket.send({ t: "hello", proto, name: `guest-v${proto}`, writeToken }); + socket.connect(); + const nextFrame = (): Promise => { + const queued = queue.shift(); + if (queued) return Promise.resolve(queued); + const { promise, resolve } = Promise.withResolvers(); + waiters.push(resolve); + return promise; + }; + return { socket, nextFrame }; +} + +describe("collab proto handshake (#4049)", () => { + it("host rejects a stale-proto hello with a protocol-mismatch error and never welcomes or admits the guest", async () => { + const host = new CollabHost(makeHostContext()); + await host.start("ws://localhost:8787"); + const guest = await joinRawGuest(host.link, COLLAB_PROTO - 1); + try { + const reply = await guest.nextFrame(); + if (reply.t !== "error") throw new Error(`expected error, got ${reply.t}`); + expect(reply.message).toContain("protocol mismatch"); + expect(reply.message).toContain(`host speaks v${COLLAB_PROTO}`); + expect(reply.message).toContain(`guest sent v${COLLAB_PROTO - 1}`); + // The rejected guest was never admitted: no participant entry, and a + // host ask finds no writable peer to route to. + expect(host.participants.filter(p => p.role !== "host")).toEqual([]); + expect(host.requestGuestUi({ kind: "select", title: "anyone?", options: ["Yes"] })).toBeNull(); + } finally { + guest.socket.close(); + await host.stop("test done"); + } + }); + + it("welcomes a current-proto guest at v3 and round-trips a ui-request", async () => { + const host = new CollabHost(makeHostContext()); + await host.start("ws://localhost:8787"); + const guest = await joinRawGuest(host.link, COLLAB_PROTO); + try { + const welcome = await guest.nextFrame(); + if (welcome.t !== "welcome") throw new Error(`expected welcome, got ${welcome.t}`); + expect(welcome.proto).toBe(COLLAB_PROTO); + expect(welcome.proto).toBe(3); + + const pending = host.requestGuestUi({ kind: "select", title: "Continue?", options: ["Yes"] }); + if (!pending) throw new Error("expected writable guest UI request"); + const request = await guest.nextFrame(); + if (request.t !== "ui-request") throw new Error(`expected ui-request, got ${request.t}`); + guest.socket.send({ t: "ui-response", reqId: request.request.reqId, value: "Yes" }); + expect(await pending).toBe("Yes"); + } finally { + guest.socket.close(); + await host.stop("test done"); + } + }); + + it("CollabGuestLink.join fails fast with the host's rejection message instead of hanging for the welcome", async () => { + // Scripted host that rejects every hello the way CollabHost does for a + // proto mismatch. The real guest must surface that message from join(). + const roomId = "proto-reject-room"; + const roomKey = generateRoomKey(); + const cryptoKey = await importRoomKey(roomKey); + const link = formatCollabLink("ws://localhost:8788", roomId, roomKey); + const hostSocket = new CollabSocket({ wsUrl: `ws://localhost:8788/r/${roomId}`, role: "host", key: cryptoKey }); + const hostOpen = Promise.withResolvers(); + hostSocket.onOpen = () => hostOpen.resolve(); + hostSocket.onFrame = frame => { + if (frame.t === "hello") { + hostSocket.send({ + t: "error", + message: `protocol mismatch: host speaks v${COLLAB_PROTO + 1}, guest sent v${frame.proto}`, + }); + } + }; + hostSocket.connect(); + await hostOpen.promise; + + const ctx = { + settings: { get: () => "" }, + sessionManager: { getSessionFile: () => null }, + } as unknown as InteractiveModeContext; + const guest = new CollabGuestLink(ctx); + try { + await expect(guest.join(link)).rejects.toThrow(/protocol mismatch/); + } finally { + hostSocket.close(); + } + }); +}); diff --git a/packages/collab-web/CHANGELOG.md b/packages/collab-web/CHANGELOG.md index 5da3e00a7..9405d12f2 100644 --- a/packages/collab-web/CHANGELOG.md +++ b/packages/collab-web/CHANGELOG.md @@ -6,6 +6,7 @@ - Fixed an issue in the mobile collaboration web UI where 'ask' questions were displayed without response controls. - Fixed the agent transcript drawer hot-retrying forever when the host reports a terminal transcript error (such as an oversized row); the error now stops polling and is shown below any rows already loaded. +- Fixed pre-welcome host `error` frames (such as a protocol-version rejection) being invisible until the welcome timeout; the session now ends immediately with the host's reason. ## [16.2.0] - 2026-06-27 diff --git a/packages/collab-web/src/lib/client.ts b/packages/collab-web/src/lib/client.ts index b386681f7..6de49294c 100644 --- a/packages/collab-web/src/lib/client.ts +++ b/packages/collab-web/src/lib/client.ts @@ -379,6 +379,14 @@ export class GuestClient { this.#end(frame.reason); return; // #end already committed case "error": + if (!this.#welcomed) { + // Pre-welcome errors are the host's targeted reply to our + // hello (e.g. protocol mismatch): no welcome will follow. + // End with the host's reason instead of waiting out the + // welcome timeout. + this.#end(frame.message); + return; // #end already committed + } this.#pushNotice("error", frame.message); break; default: diff --git a/packages/collab-web/test/client.test.ts b/packages/collab-web/test/client.test.ts index 2f92df1ab..11f8ada91 100644 --- a/packages/collab-web/test/client.test.ts +++ b/packages/collab-web/test/client.test.ts @@ -11,7 +11,7 @@ import type { WireMessage, } from "@oh-my-pi/pi-wire"; import { GuestClient } from "../src/lib/client"; -import { encodeBase64Url } from "../src/lib/link"; +import { COLLAB_PROTO, encodeBase64Url } from "../src/lib/link"; import { CollabSocket } from "../src/lib/socket"; const LINK = `roomroomroom1234#${encodeBase64Url(new Uint8Array(32))}`; @@ -53,7 +53,7 @@ function messageEntry(id: string, message: WireMessage): SessionEntry { } function welcomeFrame(entryCount = 0, readOnly?: boolean): HostFrame { - return { t: "welcome", proto: 2, header: HEADER, state: STATE, agents: AGENTS, entryCount, readOnly }; + return { t: "welcome", proto: COLLAB_PROTO, header: HEADER, state: STATE, agents: AGENTS, entryCount, readOnly }; } function snapshotChunk(entries: SessionEntry[], final = true): HostFrame { @@ -234,6 +234,18 @@ describe("GuestClient frame apply", () => { expect(notices[0]).toMatchObject({ level: "error", message: "boom" }); }); + it("a pre-welcome error (hello rejection, e.g. protocol mismatch) ends the session with the host's reason", () => { + const client = new GuestClient(LINK, "tester"); + client.applyFrameForTest({ + t: "error", + message: `protocol mismatch: host speaks v${COLLAB_PROTO}, guest sent v${COLLAB_PROTO - 1}`, + }); + const snap = client.getSnapshot(); + expect(snap.phase).toBe("ended"); + expect(snap.endedReason).toContain("protocol mismatch"); + expect(snap.endedReason).toContain(`v${COLLAB_PROTO}`); + }); + it("tracks host UI requests and sends responses", () => { const sent: GuestFrame[] = []; const sendSpy = vi.spyOn(CollabSocket.prototype, "send").mockImplementation((frame: GuestFrame) => {