Files
oh-my-pi/packages/coding-agent/test/collab/chunked-welcome.test.ts
T
roboomp 5b937511c4 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
2026-06-20 18:33:59 +00:00

269 lines
9.2 KiB
TypeScript

/**
* 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));
});
});