1import {
2 EventId,
3 GUEST_COMMAND_OUTPUT_ROUTE,
4 GUEST_RENDER_ROUTE,
5 GuestMessageId,
6 GuestShareId,
7 GuestStreamEvent,
8 MessageId,
9 ProviderDriverKind,
10 RunId,
11 TurnItemId,
12 type GuestShareView,
13 type OrchestrationV2Run,
14 type OrchestrationV2TurnItem,
15} from "@t3tools/contracts";
16import * as DateTime from "effect/DateTime";
17import * as Schema from "effect/Schema";
18import { describe, expect, it, vi } from "vite-plus/test";
19import { userEvent } from "vite-plus/test/browser";
20
21import "~/index.css";
22
23import { render } from "../../../test/render";
24import { makeThreadProjectionFixture } from "../../test-fixtures";
25import { GuestPage } from "./GuestPage";
26
27const SHARE_ID = "share-1";
28const encodeEvent = Schema.encodeSync(Schema.fromJsonString(Schema.toCodecJson(GuestStreamEvent)));
29
30const fixture = makeThreadProjectionFixture();
31const threadId = fixture.thread.id;
32const started = DateTime.makeUnsafe("2026-10-23T10:00:00.000Z");
33const finished = DateTime.makeUnsafe("2026-10-23T10:00:05.000Z");
34
35const run = (id: string, ordinal: number, status: OrchestrationV2Run["status"]) =>
36 ({
37 id: RunId.make(id),
38 threadId,
39 ordinal,
40 providerInstanceId: fixture.thread.providerInstanceId,
41 modelSelection: fixture.thread.modelSelection,
42 providerThreadId: null,
43 userMessageId: MessageId.make(`user-${id}`),
44 rootNodeId: null,
45 activeAttemptId: null,
46 status,
47 requestedAt: started,
48 startedAt: started,
49 completedAt: status === "running" ? null : finished,
50 checkpointId: null,
51 contextHandoffId: null,
52 }) satisfies OrchestrationV2Run;
53
54const itemBase = (id: string, ordinal: number, runId = "run-1") => ({
55 id: TurnItemId.make(id),
56 threadId,
57 runId: RunId.make(runId),
58 nodeId: null,
59 providerThreadId: null,
60 providerTurnId: null,
61 nativeItemRef: null,
62 parentItemId: null,
63 ordinal,
64 status: "completed" as const,
65 title: null,
66 startedAt: started,
67 completedAt: finished,
68 updatedAt: finished,
69});
70
71const reply = (id: string, ordinal: number, text: string, runId?: string) =>
72 ({
73 ...itemBase(id, ordinal, runId),
74 type: "assistant_message",
75 messageId: MessageId.make(id),
76 text,
77 streaming: false,
78 }) satisfies OrchestrationV2TurnItem;
79
80const items: ReadonlyArray<OrchestrationV2TurnItem> = [
81 {
82 ...itemBase("user", 0),
83 type: "user_message",
84 createdBy: "user",
85 creationSource: "web",
86 messageId: MessageId.make("user"),
87 inputIntent: "turn_start",
88 text: "Run the checks",
89 attachments: [],
90 },
91 // As the owner's stream sends it: the output loads when the row expands.
92 {
93 ...itemBase("command", 1),
94 type: "command_execution",
95 input: "vp run check:changed",
96 outputOmitted: true,
97 exitCode: 0,
98 },
99 {
100 ...itemBase("visual", 2),
101 type: "dynamic_tool",
102 toolName: "mcp__t3-code__html_render",
103 input: null,
104 output: { htmlRender: { attachmentId: "thread-a-1", title: "Chart", height: 200 } },
105 },
106 reply("reply", 3, "All checks pass."),
107];
108
109const view: GuestShareView = {
110 shareId: GuestShareId.make(SHARE_ID),
111 title: "Fix login",
112 guestName: "Sam",
113 expiresAt: "2026-10-24T10:00:00.000Z",
114 messages: [],
115 model: { driver: ProviderDriverKind.make("codex"), name: "GPT-5.4", shortName: "5.4" },
116};
117
118/** The stream's opening, as the server sends it: the share, then the owner's snapshot. */
119const opening = (
120 runs: ReadonlyArray<OrchestrationV2Run>,
121 turnItems: ReadonlyArray<OrchestrationV2TurnItem>,
122): ReadonlyArray<GuestStreamEvent> => [
123 { type: "view", view },
124 {
125 type: "thread",
126 item: {
127 kind: "snapshot",
128 snapshotSequence: 1,
129 projection: {
130 ...fixture,
131 runs,
132 turnItems,
133 visibleTurnItems: turnItems.map((item, position) => ({
134 position,
135 visibility: "local",
136 sourceThreadId: threadId,
137 sourceItemId: item.id,
138 item,
139 })),
140 },
141 },
142 },
143];
144
145/** One of the owner's thread events, numbered after the snapshot. */
146const threadEvent = (
147 sequence: number,
148 event:
149 | { readonly type: "turn-item.updated"; readonly payload: OrchestrationV2TurnItem }
150 | { readonly type: "run.updated"; readonly payload: OrchestrationV2Run },
151): GuestStreamEvent => {
152 const common = {
153 id: EventId.make(`event-${sequence}`),
154 threadId,
155 providerInstanceId: fixture.thread.providerInstanceId,
156 occurredAt: finished,
157 };
158 return {
159 type: "thread",
160 item: {
161 kind: "event",
162 sequence,
163 event:
164 event.type === "run.updated"
165 ? { ...common, type: "run.updated", runId: event.payload.id, payload: event.payload }
166 : {
167 ...common,
168 type: "turn-item.updated",
169 driver: ProviderDriverKind.make("codex"),
170 payload: event.payload,
171 },
172 },
173 };
174};
175
176const finalOutput = JSON.stringify({
177 kind: "replace",
178 text: "4 files checked\n",
179 truncated: false,
180 running: false,
181});
182
183/**
184 * Delivers the opening as soon as the page subscribes, or a command's final output. `sources`
185 * collects the streams, so a test can send more events.
186 */
187function fakeEventSource(
188 events: ReadonlyArray<GuestStreamEvent>,
189 sources: Array<EventTarget> = [],
190) {
191 return class extends EventTarget {
192 static readonly CLOSED = 2;
193 readyState = 1;
194 constructor(readonly url: string) {
195 super();
196 sources.push(this);
197 const frames = url.startsWith(GUEST_COMMAND_OUTPUT_ROUTE)
198 ? [finalOutput]
199 : events.map((event) => encodeEvent(event));
200 queueMicrotask(() => {
201 for (const data of frames) this.dispatchEvent(new MessageEvent("message", { data }));
202 });
203 }
204 close() {
205 this.readyState = 2;
206 }
207 };
208}
209
210function visit(path: string): Disposable {
211 const previous = `${window.location.pathname}${window.location.search}`;
212 window.history.replaceState(window.history.state, "", path);
213 return {
214 [Symbol.dispose]: () => window.history.replaceState(window.history.state, "", previous),
215 };
216}
217
218describe("GuestPage", () => {
219 it("shows the shared thread in the real timeline without opening an environment connection", async () => {
220 using _page = visit(`/guest?share=${SHARE_ID}`);
221 using _session = vi
222 .spyOn(window, "fetch")
223 .mockImplementation(async () => new Response(null, { status: 204 }));
224 using _stream = vi
225 .spyOn(window, "EventSource")
226 .mockImplementation(fakeEventSource(opening([run("run-1", 1, "completed")], items)) as never);
227 using socket = vi.spyOn(window, "WebSocket");
228 const container = document.createElement("div");
229 container.style.cssText = "height: 900px; width: 900px; display: flex; flex-direction: column;";
230 await using screen = await render(<GuestPage />, { container });
231
232 await expect.element(screen.getByText("All checks pass.")).toBeVisible();
233 // A finished turn folds its work, as it does for the owner; the command is inside.
234 await userEvent.click(screen.getByRole("button", { name: /Worked for/ }));
235 await expect.element(screen.getByText("vp run check:changed")).toBeVisible();
236 // Expanding the command shows its output, read through the guest route.
237 await userEvent.click(screen.getByText("vp run check:changed"));
238 await expect.element(screen.getByText("4 files checked")).toBeVisible();
239 await expect
240 .element(screen.getByTitle("Chart"))
241 .toHaveAttribute(
242 "src",
243 expect.stringContaining(`${GUEST_RENDER_ROUTE}?share=${SHARE_ID}&item=visual`),
244 );
245 // Guests cannot act on the thread, so no control offers to.
246 expect(screen.container.querySelector('[aria-label="Fork from this response"]')).toBeNull();
247 expect(socket).not.toHaveBeenCalled();
248 // The composer names the thread's model, as the owner's does, with nothing to pick.
249 await expect.element(screen.getByLabelText("Model: GPT-5.4")).toBeVisible();
250 });
251
252 it("sends attached files with the message, as form data", async () => {
253 using _page = visit(`/guest?share=${SHARE_ID}`);
254 using fetchSpy = vi
255 .spyOn(window, "fetch")
256 .mockImplementation(async () => new Response(null, { status: 204 }));
257 using _stream = vi
258 .spyOn(window, "EventSource")
259 .mockImplementation(fakeEventSource(opening([run("run-1", 1, "completed")], items)) as never);
260 await using screen = await render(<GuestPage />);
261
262 await expect.element(screen.getByLabelText("Attach files")).toBeVisible();
263 const notes = new File(["hello"], "notes.txt", { type: "text/plain" });
264 await userEvent.upload(screen.getByLabelText("Files to attach"), notes);
265 await expect.element(screen.getByText("notes.txt")).toBeVisible();
266 await userEvent.click(screen.getByRole("button", { name: "Send" }));
267 await vi.waitFor(() =>
268 expect(fetchSpy).toHaveBeenCalledWith(
269 `/api/guest/messages?share=${SHARE_ID}`,
270 expect.objectContaining({ method: "POST" }),
271 ),
272 );
273 const body = fetchSpy.mock.calls.find(
274 ([url]) => url === `/api/guest/messages?share=${SHARE_ID}`,
275 )?.[1]?.body;
276 expect(body).toBeInstanceOf(FormData);
277 const form = body as FormData;
278 expect(form.get("shareId")).toBe(SHARE_ID);
279 expect(form.get("text")).toBe("");
280 expect((form.get("file") as File).name).toBe("notes.txt");
281 // Sent files leave the composer.
282 await expect.element(screen.getByText("notes.txt")).not.toBeInTheDocument();
283 });
284
285 it("follows the end as replies stream, and shows the guest's own message even after they scrolled up", async () => {
286 using _page = visit(`/guest?share=${SHARE_ID}`);
287 using _session = vi
288 .spyOn(window, "fetch")
289 .mockImplementation(async () => new Response(null, { status: 204 }));
290 const sources: Array<EventTarget> = [];
291 // Enough turns that the thread overflows the page.
292 const longItems = Array.from({ length: 12 }, (_, index) =>
293 reply(
294 `reply-${index}`,
295 index,
296 `Reply ${index}\n\n${"A long paragraph about the change. ".repeat(20)}`,
297 ),
298 );
299 using _stream = vi
300 .spyOn(window, "EventSource")
301 .mockImplementation(
302 fakeEventSource(opening([run("run-1", 1, "running")], longItems), sources) as never,
303 );
304 const send = (event: GuestStreamEvent) =>
305 sources.at(-1)!.dispatchEvent(new MessageEvent("message", { data: encodeEvent(event) }));
306 const container = document.createElement("div");
307 container.style.cssText = "height: 600px; width: 900px; display: flex; flex-direction: column;";
308 await using screen = await render(<GuestPage />, { container });
309 await expect.element(screen.getByText("Reply 11")).toBeInTheDocument();
310 const scroller = () =>
311 screen.container.querySelector<HTMLElement>(".messages-timeline-scroll")!;
312 const expectAtEnd = () =>
313 vi.waitFor(() => {
314 const node = scroller();
315 expect(node.scrollHeight - node.scrollTop - node.clientHeight).toBeLessThan(2);
316 });
317 await expectAtEnd();
318
319 // A new reply streams in at the end.
320 send(
321 threadEvent(2, {
322 type: "turn-item.updated",
323 payload: {
324 ...reply(
325 "reply-new",
326 20,
327 `Newest reply\n\n${"More words as the agent writes. ".repeat(30)}`,
328 ),
329 streaming: true,
330 },
331 }),
332 );
333 await expect.element(screen.getByText("Newest reply")).toBeInTheDocument();
334 await expectAtEnd();
335
336 // The guest reads back a little, then sends: their message comes into view anyway, as the
337 // owner's own send does.
338 const node = scroller();
339 node.dispatchEvent(new WheelEvent("wheel", { deltaY: -400, bubbles: true }));
340 node.scrollTop = node.scrollTop - 400;
341 node.dispatchEvent(new Event("scroll"));
342 await expect.element(screen.getByRole("button", { name: /scroll to/i })).toBeVisible();
343
344 // The guest sends; their message waits at the end for the owner.
345 const waiting = {
346 id: GuestMessageId.make("guest-1"),
347 text: "Can you also check the logout flow?",
348 status: "pending" as const,
349 submittedAt: "2026-10-23T10:01:00.000Z",
350 };
351 await userEvent.type(screen.getByRole("textbox", { name: "Message" }), waiting.text);
352 await userEvent.click(screen.getByRole("button", { name: "Send" }));
353 send({ type: "view", view: { ...view, messages: [waiting] } });
354 await expect.element(screen.getByText(waiting.text)).toBeInTheDocument();
355 await expectAtEnd();
356
357 // The owner sends it: the run ends, the message becomes a row, and a new run's reply grows.
358 const sent = { ...waiting, status: "sent" as const };
359 send(threadEvent(3, { type: "run.updated", payload: run("run-1", 1, "completed") }));
360 send({ type: "view", view: { ...view, messages: [sent] } });
361 send(threadEvent(4, { type: "run.updated", payload: run("run-2", 2, "running") }));
362 send(
363 threadEvent(5, {
364 type: "turn-item.updated",
365 payload: {
366 ...itemBase("guest-row", 30, "run-2"),
367 type: "user_message",
368 createdBy: "user",
369 creationSource: "web",
370 messageId: MessageId.make("guest-row"),
371 inputIntent: "turn_start",
372 text: `Message from guest Sam, sent by the owner: ${sent.text}`,
373 attachments: [],
374 context: {
375 version: 1,
376 guest: { guestMessageId: sent.id, name: "Sam", text: sent.text },
377 records: [],
378 },
379 },
380 }),
381 );
382 await expectAtEnd();
383 for (const [index, words] of [10, 40, 120].entries()) {
384 send(
385 threadEvent(6 + index, {
386 type: "turn-item.updated",
387 payload: {
388 ...reply(
389 "logout-reply",
390 31,
391 `Logout reply\n\n${"Checking the logout flow now. ".repeat(words)}`,
392 "run-2",
393 ),
394 streaming: true,
395 },
396 }),
397 );
398 await expectAtEnd();
399 await expect.element(screen.getByText("Logout reply")).toBeInTheDocument();
400 }
401 });
402});