fix(collab): chunked welcome so large session snapshots can join

The host used to ship the entire transcript inside a single welcome
frame, so a multi-MB session spent the guest's 30s first-welcome
timeout on the relay transfer itself: ~1.3 MB took ~3s, ~4.2 MB took
~12s, and ~13.6 MB never arrived before the guest gave up with
'timed out waiting for the host's welcome'.

Bump COLLAB_PROTO to 2 and split the welcome:

- welcome carries metadata only (header, state, agents, entryCount,
  readOnly) and lands in well under one second.
- a train of snapshot-chunk frames (SNAPSHOT_CHUNK_BYTES = 512 KB,
  oversize entries ship alone) carries the transcript. Last chunk
  flips final: true; an empty snapshot still emits one final chunk.
- the host queues welcome + chunks synchronously inside #handleHello,
  preserving the host comment's ordering invariant (later broadcast
  frames cannot interleave between them).
- the TUI guest accumulates chunks under a SNAPSHOT_PROGRESS_TIMEOUT_MS
  that resets per chunk; only after final does it write the replica
  jsonl, switchSession, and render. The first-welcome timeout still
  guards arrival of the small welcome.
- the collab-web GuestClient streams entries into the snapshot as
  chunks arrive and flips phase to 'live' on final.

Includes a contract test (in-process relay) asserting the welcome is
metadata-only, the chunk train fans the 1.5 MB synthetic transcript
across multiple frames with only the last marked final, and the
flattened entries match the source snapshot.

Fixes #3144
This commit is contained in:
roboomp
2026-06-20 18:33:59 +00:00
parent 62756efb8f
commit 5b937511c4
14 changed files with 576 additions and 40 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `/join` failing with `timed out waiting for the host's welcome` on collab sessions whose existing transcript was more than a few MB. The host now sends a small `welcome` frame (header + state + agents + `entryCount`) followed by a train of `snapshot-chunk` frames (`SNAPSHOT_CHUNK_BYTES = 512 KB`), and the guest accumulates them under a per-chunk progress timeout that resets on each chunk arrival. The first welcome lands well under one second on the default relay, so the guest's 30s first-welcome budget is no longer spent transferring the snapshot. Requires the new `COLLAB_PROTO = 2` on both sides; older hosts/guests are rejected with the existing protocol-mismatch error. ([#3144](https://github.com/can1357/oh-my-pi/issues/3144))
## [16.1.7] - 2026-06-20
### Fixed
+161 -19
View File
@@ -20,6 +20,7 @@ import type { AgentHubRemote } from "../modes/components/agent-hub";
import type { InteractiveModeContext } from "../modes/types";
import { AgentRegistry } from "../registry/agent-registry";
import type { AgentSessionEvent } from "../session/agent-session";
import type { SessionEntry } from "../session/session-entries";
import { shouldDisableReasoning, toReasoningEffort } from "../thinking";
import { setSessionTerminalTitle } from "../utils/title-generator";
import { importRoomKey } from "./crypto";
@@ -47,10 +48,35 @@ export const COLLAB_GUEST_ALLOWED_COMMANDS: Record<string, true> = {
exit: true,
quit: true,
};
/**
* How long the guest waits for the host's small `welcome` frame before giving
* up on the join. The welcome carries metadata only (`entryCount`, header,
* state, agents), so it lands well under one second on any working relay.
*/
const WELCOME_TIMEOUT_MS = 30_000;
/**
* How long the guest waits between `snapshot-chunk` frames during the initial
* sync. Resets on each chunk arrival, so a multi-MB snapshot only fails when
* the relay genuinely stalls — not because the total wall-clock crossed the
* welcome budget. The default relay sustains ~350 KB/s; a 512 KB chunk lands
* in under two seconds with comfortable headroom.
*/
const SNAPSHOT_PROGRESS_TIMEOUT_MS = 30_000;
const TRANSCRIPT_TIMEOUT_MS = 20_000;
type WelcomeFrame = Extract<CollabFrame, { t: "welcome" }>;
type SnapshotChunkFrame = Extract<CollabFrame, { t: "snapshot-chunk" }>;
/** Accumulator for an in-flight chunked welcome — see {@link CollabGuestLink}. */
interface PendingSnapshot {
header: WelcomeFrame["header"];
state: WelcomeFrame["state"];
agents: AgentSnapshot[];
readOnly: boolean;
entryCount: number;
entries: SessionEntry[];
isResync: boolean;
}
export class CollabGuestLink {
#ctx: InteractiveModeContext;
@@ -60,8 +86,24 @@ export class CollabGuestLink {
#returnSessionFile: string | null = null;
/** Frames apply strictly in arrival order through this chain. */
#applyChain: Promise<void> = Promise.resolve();
/** True after the initial snapshot has been written to disk and resumed. */
#welcomed = false;
#left = false;
/**
* Buffer for the in-flight chunked welcome. Set by the small `welcome`
* frame, accumulated by every `snapshot-chunk`, drained when the final
* chunk lands (or the snapshot-progress timer fires).
*/
#pendingSnapshot: PendingSnapshot | null = null;
/**
* Fires `firstWelcome.reject` from a stalled welcome/snapshot during the
* initial join. Set in {@link join}, cleared on resolve/reject; arming a
* timer after that point is a no-op so reconnect-time stalls fall through
* to the normal socket close handling instead of aborting the live session.
*/
#joinReject: ((err: Error) => void) | null = null;
#welcomeTimer: Timer | null = null;
#snapshotProgressTimer: Timer | null = null;
/** base64url write token from a full link; absent when joined via a view link. */
#writeToken: string | undefined;
/** True when the host marked this peer read-only (view link). */
@@ -143,11 +185,23 @@ export class CollabGuestLink {
const firstWelcome = Promise.withResolvers<void>();
let joined = false;
this.#joinReject = err => firstWelcome.reject(err);
const finishJoin = (): void => {
if (joined) return;
joined = true;
firstWelcome.resolve();
};
socket.onOpen = () => {
// (Re)connect: re-introduce ourselves; the host answers with a fresh
// welcome which (re)syncs the replica.
// welcome which (re)syncs the replica. Discard any partially-streamed
// snapshot from a prior connection: the host will resend the full
// chunk train.
this.#welcomed = false;
this.#pendingSnapshot = null;
this.#clearSnapshotProgressTimer();
this.#armWelcomeTimer();
socket.send({
t: "hello",
proto: COLLAB_PROTO,
@@ -159,10 +213,19 @@ export class CollabGuestLink {
this.#applyChain = this.#applyChain
.then(async () => {
if (frame.t === "welcome") {
await this.#applyWelcome(frame, joined);
if (!joined) {
joined = true;
firstWelcome.resolve();
this.#clearWelcomeTimer();
this.#beginWelcome(frame, joined);
if (frame.entryCount === 0) {
await this.#finalizeSnapshot();
finishJoin();
}
return;
}
if (frame.t === "snapshot-chunk") {
const ready = this.#accumulateSnapshotChunk(frame);
if (ready) {
await this.#finalizeSnapshot();
finishJoin();
}
return;
}
@@ -172,6 +235,8 @@ export class CollabGuestLink {
.catch(err => logger.warn("collab guest frame apply failed", { type: frame.t, error: String(err) }));
};
socket.onClose = (reason, willReconnect) => {
this.#clearWelcomeTimer();
this.#clearSnapshotProgressTimer();
this.#flushPendingTranscripts();
if (this.#left) return;
if (!joined) {
@@ -187,10 +252,6 @@ export class CollabGuestLink {
};
socket.connect();
const timeout = setTimeout(
() => firstWelcome.reject(new Error("timed out waiting for the host's welcome")),
WELCOME_TIMEOUT_MS,
);
try {
await firstWelcome.promise;
} catch (err) {
@@ -199,7 +260,9 @@ export class CollabGuestLink {
this.#socket = null;
throw err;
} finally {
clearTimeout(timeout);
this.#joinReject = null;
this.#clearWelcomeTimer();
this.#clearSnapshotProgressTimer();
}
this.#ctx.collabGuest = this;
@@ -222,11 +285,56 @@ export class CollabGuestLink {
this.#socket?.send({ t: "abort" });
}
/** Write the welcome snapshot to the replica file and (re)load it through the resume machinery. */
async #applyWelcome(frame: WelcomeFrame, isResync: boolean): Promise<void> {
/**
* Latch the welcome metadata and prime the snapshot accumulator. The
* heavy resume work (file write, `switchSession`, render) only happens in
* {@link #finalizeSnapshot}, so the small welcome frame clears the join
* timeout immediately even when the transcript still has to stream in.
*/
#beginWelcome(frame: WelcomeFrame, isResync: boolean): void {
if (this.#left) return;
this.#pendingSnapshot = {
header: frame.header,
state: frame.state,
agents: frame.agents,
readOnly: frame.readOnly === true,
entryCount: frame.entryCount,
entries: [],
isResync,
};
this.#armSnapshotProgressTimer();
}
/**
* Append a chunk to the pending snapshot. Returns `true` when the
* accumulator has gathered every entry the welcome promised, or the host
* tagged this chunk as `final`. The caller is responsible for invoking
* {@link #finalizeSnapshot} on the same applyChain microtask.
*/
#accumulateSnapshotChunk(frame: SnapshotChunkFrame): boolean {
const pending = this.#pendingSnapshot;
if (!pending) {
logger.debug("collab guest dropping orphan snapshot-chunk");
return false;
}
pending.entries.push(...frame.entries);
const complete = frame.final || pending.entries.length >= pending.entryCount;
if (complete) {
this.#clearSnapshotProgressTimer();
} else {
this.#armSnapshotProgressTimer();
}
return complete;
}
/** Write the accumulated welcome snapshot to the replica file and (re)load it through the resume machinery. */
async #finalizeSnapshot(): Promise<void> {
const pending = this.#pendingSnapshot;
this.#pendingSnapshot = null;
this.#clearSnapshotProgressTimer();
if (!pending || this.#left) return;
const replicaPath = path.join(getConfigRootDir(), "collab", `${this.#roomId}.jsonl`);
const lines = [frame.header, ...frame.entries].map(entry => JSON.stringify(entry)).join("\n");
const lines = [pending.header, ...pending.entries].map(entry => JSON.stringify(entry)).join("\n");
await Bun.write(replicaPath, `${lines}\n`);
// Resume sequence (selector-controller.handleResumeSession) minus
@@ -235,20 +343,54 @@ export class CollabGuestLink {
this.#clearTransientUi();
this.#clearAgentMirror();
await this.#ctx.session.switchSession(replicaPath);
this.state = frame.state;
this.#applyHostState(frame.state);
this.state = pending.state;
this.#applyHostState(pending.state);
this.#ctx.resetObserverRegistry();
this.#applyAgentSnapshots(frame.agents);
this.#applyAgentSnapshots(pending.agents);
this.#assistantStreamSynced = false;
setSessionTerminalTitle(frame.state.sessionName ?? frame.header.title, frame.state.cwd);
setSessionTerminalTitle(pending.state.sessionName ?? pending.header.title, pending.state.cwd);
this.#ctx.chatContainer.clear();
this.#ctx.renderInitialMessages({ clearTerminalHistory: true });
await this.#ctx.reloadTodos();
this.#updateStatusSegment();
this.#readOnly = frame.readOnly === true;
this.#readOnly = pending.readOnly;
this.#welcomed = true;
const suffix = this.#readOnly ? " (read-only)" : "";
this.#ctx.showStatus(isResync ? `Reconnected to collab session${suffix}` : `Joined collab session${suffix}`);
this.#ctx.showStatus(
pending.isResync ? `Reconnected to collab session${suffix}` : `Joined collab session${suffix}`,
);
}
#armWelcomeTimer(): void {
if (this.#joinReject === null) return;
this.#clearWelcomeTimer();
this.#welcomeTimer = setTimeout(() => {
this.#welcomeTimer = null;
this.#joinReject?.(new Error("timed out waiting for the host's welcome"));
}, WELCOME_TIMEOUT_MS);
}
#clearWelcomeTimer(): void {
if (this.#welcomeTimer !== null) {
clearTimeout(this.#welcomeTimer);
this.#welcomeTimer = null;
}
}
#armSnapshotProgressTimer(): void {
if (this.#joinReject === null) return;
this.#clearSnapshotProgressTimer();
this.#snapshotProgressTimer = setTimeout(() => {
this.#snapshotProgressTimer = null;
this.#joinReject?.(new Error("timed out waiting for the host's session snapshot"));
}, SNAPSHOT_PROGRESS_TIMEOUT_MS);
}
#clearSnapshotProgressTimer(): void {
if (this.#snapshotProgressTimer !== null) {
clearTimeout(this.#snapshotProgressTimer);
this.#snapshotProgressTimer = null;
}
}
#applyFrame(frame: CollabFrame): void {
+47 -5
View File
@@ -94,6 +94,13 @@ function isWireSessionEntry(entry: StoredSessionEntry): entry is StoredSessionEn
const CONNECT_TIMEOUT_MS = 15_000;
/** Max bytes served per fetch-transcript reply (guest re-requests from `newSize`). */
const TRANSCRIPT_READ_CAP = 4 * 1024 * 1024;
/**
* Soft byte cap per `snapshot-chunk` frame. The first MB of a snapshot takes
* ~3s through the default relay, so a 512 KB chunk lands well under the
* guest's 30 s per-chunk progress timeout; oversized single entries still
* ship in a chunk of their own.
*/
const SNAPSHOT_CHUNK_BYTES = 512 * 1024;
/** Display name for this process's user in collab sessions. */
export function collabDisplayName(ctx: InteractiveModeContext): string {
@@ -323,9 +330,10 @@ export class CollabHost {
const canWrite = this.#verifyWriteToken(writeToken);
this.#peers.set(fromPeer, { name: cleanName, canWrite });
// Snapshot and send synchronously: no awaits between snapshot and send, so
// later entries/events queue behind the welcome on the same socket and the
// guest never sees a gap.
// Snapshot and send synchronously: no awaits between snapshot, welcome,
// and chunk sends, so subsequent broadcast frames (entry/event/state/bus)
// queue behind the snapshot on the same socket and the guest can't
// observe a gap between the snapshot fragment and live traffic.
const snapshot = this.#ctx.sessionManager.snapshotForReplication();
if (JSON.stringify(snapshot).length > WELCOME_IMAGE_STRIP_THRESHOLD) {
let stripped = 0;
@@ -335,18 +343,21 @@ export class CollabHost {
logger.info("collab welcome exceeded size threshold; stripped images", { stripped });
}
const entries = snapshot.entries.filter(isWireSessionEntry);
this.#socket?.send(
const socket = this.#socket;
if (!socket) return;
socket.send(
{
t: "welcome",
proto: COLLAB_PROTO,
header: snapshot.header,
entries,
state: this.#buildState(),
agents: this.#snapshotAgents(),
entryCount: entries.length,
readOnly: canWrite ? undefined : true,
},
fromPeer,
);
this.#sendSnapshotChunks(entries, fromPeer);
this.#ctx.session.emitNotice(
"info",
`${cleanName} joined the collab session${canWrite ? "" : " (read-only)"}`,
@@ -356,6 +367,37 @@ export class CollabHost {
this.#scheduleStateBroadcast();
}
/**
* Slice {@link entries} into byte-bounded `snapshot-chunk` frames targeted
* at {@link fromPeer}. Every batch carries at least one entry (a single
* oversize entry ships alone), and the last batch is tagged `final: true`
* so the guest can finalize the replica. An empty snapshot still emits one
* `final` chunk so the guest never blocks on a missing terminator.
*/
#sendSnapshotChunks(entries: (StoredSessionEntry & WireSessionEntry)[], fromPeer: number): void {
const socket = this.#socket;
if (!socket) return;
if (entries.length === 0) {
socket.send({ t: "snapshot-chunk", entries: [], final: true }, fromPeer);
return;
}
let i = 0;
while (i < entries.length) {
const batch: (StoredSessionEntry & WireSessionEntry)[] = [];
let batchBytes = 0;
while (i < entries.length) {
const entry = entries[i];
if (!entry) break;
const entryBytes = JSON.stringify(entry).length;
if (batch.length > 0 && batchBytes + entryBytes > SNAPSHOT_CHUNK_BYTES) break;
batch.push(entry);
batchBytes += entryBytes;
i++;
}
socket.send({ t: "snapshot-chunk", entries: batch, final: i >= entries.length }, fromPeer);
}
}
#handlePrompt(text: string, images: ImageContent[] | undefined, fromPeer: number): void {
const peer = this.#peers.get(fromPeer);
if (!peer?.canWrite) {
+16 -1
View File
@@ -65,12 +65,27 @@ export type CollabFrame =
t: "welcome";
proto: number;
header: SessionHeader;
entries: SessionEntry[];
state: CollabSessionState;
agents: AgentSnapshot[];
/**
* Total number of `SessionEntry` items the host will deliver in the
* `snapshot-chunk` frames that follow. The guest stays in the
* snapshot-loading phase until it has accumulated that many entries
* (or a chunk arrives with `final: true`).
*/
entryCount: number;
/** True when this peer joined through a read-only (view) link. */
readOnly?: boolean;
}
/**
* Targeted snapshot fragment delivered after `welcome`. Splits a large
* transcript across many small frames so the guest's per-chunk progress
* timeout resets each time the relay delivers another batch; without
* chunking, a multi-MB session has to fit one giant frame inside the
* 30 s first-welcome budget. The last chunk carries `final: true` so the
* guest can finalize the replica session.
*/
| { t: "snapshot-chunk"; entries: SessionEntry[]; final: boolean }
| { t: "entry"; entry: SessionEntry }
| { t: "event"; event: AgentSessionEvent }
| { t: "state"; state: CollabSessionState }
@@ -0,0 +1,268 @@
/**
* Contract: a large session snapshot is delivered as a small `welcome` frame
* plus a train of `snapshot-chunk` frames, so the guest can clear its 30s
* first-welcome timeout long before the full transcript arrives — the fix for
* [#3144](https://github.com/can1357/oh-my-pi/issues/3144) where a multi-MB
* single-frame welcome timed out on the default relay.
*
* The test drives the production `CollabHost` (real sealing, real envelopes)
* through an in-process relay + fake WebSocket, mirroring the relay's
* forwarding contract exactly; only the TUI context and the network transport
* are stubbed.
*/
import { afterAll, afterEach, beforeAll, describe, expect, it } from "bun:test";
import { importRoomKey } from "@oh-my-pi/pi-coding-agent/collab/crypto";
import { CollabHost } from "@oh-my-pi/pi-coding-agent/collab/host";
import {
COLLAB_PROTO,
type CollabFrame,
parseCollabLink,
rewriteEnvelopePeer,
unpackEnvelope,
} from "@oh-my-pi/pi-coding-agent/collab/protocol";
import { CollabSocket } from "@oh-my-pi/pi-coding-agent/collab/relay-client";
import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types";
import type { SessionEntry } from "@oh-my-pi/pi-coding-agent/session/session-entries";
// ── In-memory transport (verbatim copy of the relay used in read-only.test.ts) ──
let activeRelay: InMemoryRelay | null = null;
class FakeWebSocket {
static readonly CONNECTING = 0;
static readonly OPEN = 1;
static readonly CLOSING = 2;
static readonly CLOSED = 3;
binaryType = "blob";
readyState: number = FakeWebSocket.CONNECTING;
readonly role: "host" | "guest";
peerId = 0;
onopen: (() => void) | null = null;
onmessage: ((event: { data: unknown }) => void) | null = null;
onerror: (() => void) | null = null;
onclose: ((event: { code: number; reason: string }) => void) | null = null;
readonly #relay: InMemoryRelay;
constructor(url: string) {
const relay = activeRelay;
if (!relay) throw new Error("FakeWebSocket: no active in-memory relay");
this.#relay = relay;
this.role = new URL(url).searchParams.get("role") === "host" ? "host" : "guest";
queueMicrotask(() => {
if (this.readyState !== FakeWebSocket.CONNECTING) return;
this.readyState = FakeWebSocket.OPEN;
relay.connect(this);
this.onopen?.();
});
}
send(data: Uint8Array): void {
if (this.readyState !== FakeWebSocket.OPEN) return;
const bytes = new Uint8Array(data);
queueMicrotask(() => this.#relay.forward(this, bytes));
}
close(_code?: number): void {
if (this.readyState === FakeWebSocket.CLOSED) return;
this.readyState = FakeWebSocket.CLOSED;
this.#relay.disconnect(this);
queueMicrotask(() => this.onclose?.({ code: 1000, reason: "closed" }));
}
deliver(bytes: Uint8Array): void {
if (this.readyState !== FakeWebSocket.OPEN) return;
const copy = new Uint8Array(bytes);
queueMicrotask(() => this.onmessage?.({ data: copy.buffer }));
}
deliverControl(json: string): void {
if (this.readyState !== FakeWebSocket.OPEN) return;
queueMicrotask(() => this.onmessage?.({ data: json }));
}
}
class InMemoryRelay {
#host: FakeWebSocket | null = null;
readonly #guests = new Map<number, FakeWebSocket>();
#nextPeerId = 1;
connect(ws: FakeWebSocket): void {
if (ws.role === "host") {
this.#host = ws;
return;
}
ws.peerId = this.#nextPeerId++;
this.#guests.set(ws.peerId, ws);
this.#host?.deliverControl(JSON.stringify({ t: "peer-joined", peer: ws.peerId }));
}
forward(from: FakeWebSocket, bytes: Uint8Array): void {
if (from.role === "host") {
const envelope = unpackEnvelope(bytes);
if (!envelope) return;
if (envelope.peerId === 0) {
for (const guest of this.#guests.values()) guest.deliver(bytes);
} else {
this.#guests.get(envelope.peerId)?.deliver(bytes);
}
return;
}
rewriteEnvelopePeer(bytes, from.peerId);
this.#host?.deliver(bytes);
}
disconnect(ws: FakeWebSocket): void {
if (ws.role === "host") {
if (this.#host === ws) this.#host = null;
return;
}
this.#guests.delete(ws.peerId);
this.#host?.deliverControl(JSON.stringify({ t: "peer-left", peer: ws.peerId }));
}
}
// ── Host harness with a configurable transcript ────────────────────────────
interface SizedSnapshot {
header: { type: "session"; id: string; timestamp: string; cwd: string };
entries: SessionEntry[];
}
/**
* Build a synthetic transcript whose total serialized size comfortably
* exceeds the host's `SNAPSHOT_CHUNK_BYTES` (512 KB), forcing several
* chunks. Each entry is ~16 KB of repeated text, so 96 entries → ~1.5 MB,
* cleanly above three chunks without making the test slow.
*/
function makeLargeSnapshot(): SizedSnapshot {
const body = "x".repeat(16 * 1024);
const entries: SessionEntry[] = [];
for (let i = 0; i < 96; i++) {
entries.push({
type: "message",
id: `e${i}`,
parentId: null,
timestamp: "2026-06-20T00:00:00Z",
message: { role: "user", content: body, timestamp: 0 },
});
}
return {
header: { type: "session", id: "sess-large", timestamp: "2026-06-20T00:00:00Z", cwd: "/tmp" },
entries,
};
}
function makeHostContext(snapshot: SizedSnapshot): InteractiveModeContext {
const ctx = {
settings: { get: () => "" },
sessionManager: {
getSessionId: () => snapshot.header.id,
getCwd: () => snapshot.header.cwd,
snapshotForReplication: () => snapshot,
onEntryAppended: undefined,
},
session: {
isStreaming: false,
queuedMessageCount: 0,
sessionName: "large",
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,
};
return ctx as unknown as InteractiveModeContext;
}
// ── Shared host/relay ───────────────────────────────────────────────────────
const RealWebSocket = globalThis.WebSocket;
const snapshot = makeLargeSnapshot();
let host: CollabHost;
beforeAll(async () => {
globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket;
activeRelay = new InMemoryRelay();
host = new CollabHost(makeHostContext(snapshot));
await host.start("ws://localhost:8788");
});
afterAll(async () => {
globalThis.WebSocket = RealWebSocket;
activeRelay = null;
await host.stop("test done");
});
const guestCleanups: (() => void)[] = [];
afterEach(() => {
for (const cleanup of guestCleanups.splice(0).reverse()) cleanup();
});
describe("collab chunked welcome (#3144)", () => {
it("delivers a small welcome before chunking the transcript across multiple frames", async () => {
const parsed = parseCollabLink(host.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 });
guestCleanups.push(() => socket.close());
const frames: CollabFrame[] = [];
const trainDone = Promise.withResolvers<void>();
socket.onFrame = frame => {
frames.push(frame);
if (frame.t === "snapshot-chunk" && frame.final) trainDone.resolve();
};
socket.onOpen = () => socket.send({ t: "hello", proto: COLLAB_PROTO, name: "test", writeToken });
socket.connect();
await trainDone.promise;
const welcomeIdx = frames.findIndex(f => f.t === "welcome");
expect(welcomeIdx).toBeGreaterThanOrEqual(0);
const welcome = frames[welcomeIdx];
if (!welcome || welcome.t !== "welcome") throw new Error("expected welcome frame");
expect(welcome.entryCount).toBe(snapshot.entries.length);
expect(welcome.header.id).toBe(snapshot.header.id);
// Critical fix: the welcome itself MUST NOT carry the transcript inline —
// inline bytes were what spent the guest's 30s timeout in #3144.
const welcomeBytes = JSON.stringify(welcome).length;
const snapshotBytes = JSON.stringify(snapshot).length;
expect(welcomeBytes).toBeLessThan(snapshotBytes / 10);
// The chunk train starts immediately after the welcome and the host
// queues every chunk synchronously, so no other directed frame may
// interleave between them.
const chunks: { entries: SessionEntry[]; final: boolean }[] = [];
for (let i = welcomeIdx + 1; i < frames.length; i++) {
const f = frames[i];
if (!f || f.t !== "snapshot-chunk") {
throw new Error(`unexpected ${f?.t ?? "missing"} between welcome and final chunk`);
}
chunks.push({ entries: f.entries, final: f.final });
if (f.final) break;
}
// Three+ chunks proves we honor the 512 KB cap with the 1.5 MB transcript;
// only the last carries `final: true`.
expect(chunks.length).toBeGreaterThan(1);
expect(chunks.at(-1)?.final).toBe(true);
expect(chunks.slice(0, -1).every(c => !c.final)).toBe(true);
const flattened: SessionEntry[] = [];
for (const chunk of chunks) flattened.push(...chunk.entries);
expect(flattened.length).toBe(snapshot.entries.length);
expect(flattened.map(e => e.id)).toEqual(snapshot.entries.map(e => e.id));
});
});
@@ -197,8 +197,20 @@ interface TestGuest {
nextFrame(): Promise<CollabFrame>;
}
/** Frames the host broadcasts on its own schedule (debounced state/agents, entry/event/bus taps). */
const BROADCAST_FRAME_TYPES: Record<string, true> = { state: true, agents: true, entry: true, event: true, bus: true };
/**
* Frames the test harness skips: the host's debounced broadcasts (state,
* agents, entry, event, bus) and the per-peer snapshot-chunk train that
* follows every welcome. They interleave nondeterministically with the
* directed welcome/error frames these tests actually assert on.
*/
const FILTERED_FRAME_TYPES: Record<string, true> = {
state: true,
agents: true,
entry: true,
event: true,
bus: true,
"snapshot-chunk": true,
};
/**
* Raw guest speaking the wire protocol directly. `writeToken` overrides the link's token (e.g. forged).
@@ -216,7 +228,7 @@ async function joinAsGuest(link: string, name: string, writeTokenOverride?: stri
const queue: CollabFrame[] = [];
const waiters: ((frame: CollabFrame) => void)[] = [];
socket.onFrame = frame => {
if (BROADCAST_FRAME_TYPES[frame.t]) return;
if (FILTERED_FRAME_TYPES[frame.t]) return;
const waiter = waiters.shift();
if (waiter) waiter(frame);
else queue.push(frame);
@@ -151,6 +151,10 @@ async function joinAsGuest(link: string, name: string): Promise<TestGuest> {
const queue: CollabFrame[] = [];
const waiters: ((frame: CollabFrame) => void)[] = [];
socket.onFrame = frame => {
// The host follows every welcome with a `snapshot-chunk` train carrying
// the transcript. This harness ships zero entries, so the chunks are
// pure noise around the welcome/prompt-reply assertions.
if (frame.t === "snapshot-chunk") return;
const waiter = waiters.shift();
if (waiter) waiter(frame);
else queue.push(frame);
+8
View File
@@ -2,6 +2,14 @@
## [Unreleased]
### Breaking Changes
- Bumped `COLLAB_PROTO` to `2`: the `welcome` frame now carries metadata only (header/state/agents/`entryCount`) and the transcript follows in a train of targeted `snapshot-chunk` frames terminated by `final: true`. Old guests speaking proto v1 are rejected with the existing protocol-mismatch error.
### Fixed
- Fixed the guest hanging in the "waiting" phase on large host sessions: the client now accumulates `snapshot-chunk` frames into the transcript snapshot and only transitions to `live` after the final chunk lands (or immediately when the host's snapshot is empty). ([#3144](https://github.com/can1357/oh-my-pi/issues/3144))
## [16.0.10] - 2026-06-18
### Added
+2 -1
View File
@@ -197,12 +197,13 @@ function handleHello(name: string, proto: number, fromPeer: number): void {
t: "welcome",
proto: COLLAB_PROTO,
header: fixtureHeader,
entries: [...entries],
state: buildState(),
agents: agents.map(agent => ({ ...agent })),
entryCount: entries.length,
},
fromPeer,
);
sendFrame({ t: "snapshot-chunk", entries: [...entries], final: true }, fromPeer);
console.log(`mock-host: ${cleanName} joined (peer ${fromPeer})`);
broadcastState();
}
+13 -3
View File
@@ -236,8 +236,10 @@ export class GuestClient {
#applyFrame(frame: HostFrame): void {
switch (frame.t) {
case "welcome":
// Reset accumulator: a fresh welcome arriving mid-load (reconnect)
// supersedes any partially-streamed snapshot from the prior session.
this.#header = frame.header;
this.#entries = [...frame.entries];
this.#entries = [];
this.#state = frame.state;
this.#agents = [...frame.agents];
this.#stream = null;
@@ -246,12 +248,20 @@ export class GuestClient {
this.#progress = new Map();
this.#lifecycle = new Map();
this.#working = frame.state.isStreaming;
this.#phase = "live";
this.#endedReason = null;
this.#readOnly = frame.readOnly === true;
this.#welcomed = true;
this.#clearWelcomeTimer();
if (frame.entryCount === 0) this.#phase = "live";
this.#endedReason = null;
break;
case "snapshot-chunk": {
// Stream transcript fragments into the live snapshot. The host
// always closes the train with `final: true`; that flip is what
// moves the guest from "waiting" to "live".
this.#entries = [...this.#entries, ...frame.entries];
if (frame.final) this.#phase = "live";
break;
}
case "entry":
this.#entries = [...this.#entries, frame.entry];
if (this.#streamDone && frame.entry.type === "message" && frame.entry.message.role === "assistant") {
+9 -4
View File
@@ -50,13 +50,18 @@ function messageEntry(id: string, message: WireMessage): SessionEntry {
return { type: "message", id, parentId: null, timestamp: "2026-06-12T00:00:01Z", message };
}
function welcomeFrame(entries: SessionEntry[] = [], readOnly?: boolean): HostFrame {
return { t: "welcome", proto: 1, header: HEADER, entries, state: STATE, agents: AGENTS, readOnly };
function welcomeFrame(entryCount = 0, readOnly?: boolean): HostFrame {
return { t: "welcome", proto: 2, header: HEADER, state: STATE, agents: AGENTS, entryCount, readOnly };
}
function snapshotChunk(entries: SessionEntry[], final = true): HostFrame {
return { t: "snapshot-chunk", entries, final };
}
function liveClient(entries: SessionEntry[] = []): GuestClient {
const client = new GuestClient(LINK, "tester");
client.applyFrameForTest(welcomeFrame(entries));
client.applyFrameForTest(welcomeFrame(entries.length));
if (entries.length > 0) client.applyFrameForTest(snapshotChunk(entries));
return client;
}
@@ -82,7 +87,7 @@ describe("GuestClient frame apply", () => {
it("welcome readOnly flag lands in the snapshot", () => {
const client = new GuestClient(LINK, "tester");
expect(client.getSnapshot().readOnly).toBe(false);
client.applyFrameForTest(welcomeFrame([], true));
client.applyFrameForTest(welcomeFrame(0, true));
expect(client.getSnapshot().readOnly).toBe(true);
});
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Breaking Changes
- Bumped `COLLAB_PROTO` to `2`. The `welcome` host frame now carries metadata only (`header`, `state`, `agents`, `entryCount`, optional `readOnly`) — the transcript moves to a new `snapshot-chunk` host frame (`{ entries: SessionEntry[]; final: boolean }`) sent immediately after the welcome. Hosts split large snapshots into multiple chunks; the last chunk carries `final: true`. Old guests speaking proto v1 are rejected with the existing protocol-mismatch error. ([#3144](https://github.com/can1357/oh-my-pi/issues/3144))
## [15.12.4] - 2026-06-13
### Changed
+24 -3
View File
@@ -323,12 +323,25 @@ export type HostFrame =
t: "welcome";
proto: number;
header: SessionHeader;
entries: SessionEntry[];
state: SessionState;
agents: AgentSnapshot[];
/**
* Total number of `SessionEntry` items the host will deliver in the
* `snapshot-chunk` frames that follow. Guests stay in the loading
* phase until they have accumulated all of them (or a chunk arrives
* with `final: true`).
*/
entryCount: number;
/** True when this peer joined through a read-only (view) link. */
readOnly?: boolean;
}
/**
* Targeted snapshot fragment delivered after `welcome`. Hosts split the
* transcript into chunks bounded by byte size so a multi-MB session is not
* forced through one giant frame the relay may stall on. The last chunk
* carries `final: true`; guests finalize the replica on that frame.
*/
| { t: "snapshot-chunk"; entries: SessionEntry[]; final: boolean }
| { t: "entry"; entry: SessionEntry }
| { t: "event"; event: AgentEvent }
| { t: "state"; state: SessionState }
@@ -342,8 +355,16 @@ export type HostFrame =
export type WireFrame = GuestFrame | HostFrame;
/** Wire protocol version carried in `hello`; the host rejects mismatches. */
export const COLLAB_PROTO = 1;
/**
* Wire protocol version carried in `hello`; the host rejects mismatches.
*
* - `1` (legacy): `welcome` carried the full `entries` array inline.
* - `2`: `welcome` carries only metadata (header/state/agents/entryCount);
* transcript entries follow in `snapshot-chunk` frames, so multi-MB
* sessions are not gated on a single welcome frame fitting under the
* guest's first-welcome timeout.
*/
export const COLLAB_PROTO = 2;
/** Parameter key used for intent tracing (e.g. prompt explanation/reasoning) */
export const INTENT_FIELD = "i";
+1 -1
View File
@@ -9,7 +9,7 @@ import {
describe("collab wire constants", () => {
it("exports the protocol constants consumed by host, guest, and relay links", () => {
expect(COLLAB_PROTO).toBe(1);
expect(COLLAB_PROTO).toBe(2);
expect(COLLAB_PROMPT_MESSAGE_TYPE).toBe("collab-prompt");
expect(ENVELOPE_HEADER_LENGTH).toBe(4);
expect(ROOM_ID_BYTES).toBe(16);