Open History to see the last commit
1import {
2 EventId,
3 GuestMessageId,
4 GuestShareId,
5 MessageId,
6 ProviderDriverKind,
7 RunId,
8 TurnItemId,
9 type GuestShareView,
10 type GuestStreamEvent,
11 type OrchestrationV2TurnItem,
12 type ThreadGuestMessage,
13} from "@t3tools/contracts";
14import * as DateTime from "effect/DateTime";
15import { describe, expect, it } from "vite-plus/test";
16
17import { makeThreadProjectionFixture } from "../../test-fixtures";
18import {
19 applyGuestStreamEvent,
20 formatGuestExpiry,
21 guestFilesProblem,
22 initialGuestStreamState,
23 inviteShareId,
24 parseGuestStreamEvent,
25 unplacedGuestMessages,
26 type GuestStreamState,
27} from "./guestStream.logic";
28
29const view: GuestShareView = {
30 shareId: GuestShareId.make("share-1"),
31 title: "Fix login",
32 guestName: "Sam",
33 expiresAt: "2026-10-24T00:00:00.000Z",
34 messages: [],
35 model: { driver: null, name: "gpt-5.4" },
36};
37
38const projection = makeThreadProjectionFixture();
39const now = DateTime.makeUnsafe("2026-10-23T10:00:00.000Z");
40
41const base = {
42 threadId: projection.thread.id,
43 runId: RunId.make("run-1"),
44 nodeId: null,
45 providerThreadId: null,
46 providerTurnId: null,
47 nativeItemRef: null,
48 parentItemId: null,
49 ordinal: 1,
50 status: "completed",
51 title: null,
52 startedAt: now,
53 completedAt: now,
54 updatedAt: now,
55} as const;
56
57const reply = (id: string, text: string): OrchestrationV2TurnItem => ({
58 ...base,
59 id: TurnItemId.make(id),
60 type: "assistant_message",
61 messageId: MessageId.make(id),
62 text,
63 streaming: false,
64});
65
66const guestMessageRow = (id: string, guestMessageId: GuestMessageId): OrchestrationV2TurnItem => ({
67 ...base,
68 id: TurnItemId.make(id),
69 type: "user_message",
70 createdBy: "user",
71 creationSource: "web",
72 messageId: MessageId.make(id),
73 inputIntent: "turn_start",
74 text: "Why?",
75 context: { version: 1, records: [], guest: { guestMessageId, name: "Sam", text: "Why?" } },
76 attachments: [],
77});
78
79const snapshot = (snapshotSequence = 1): GuestStreamEvent => ({
80 type: "thread",
81 item: { kind: "snapshot", snapshotSequence, projection },
82});
83
84const itemEvent = (sequence: number, item: OrchestrationV2TurnItem): GuestStreamEvent => ({
85 type: "thread",
86 item: {
87 kind: "event",
88 sequence,
89 event: {
90 id: EventId.make(`event-${sequence}`),
91 type: "turn-item.updated",
92 threadId: projection.thread.id,
93 driver: ProviderDriverKind.make("codex"),
94 providerInstanceId: projection.thread.providerInstanceId,
95 occurredAt: now,
96 payload: item,
97 },
98 },
99});
100
101const apply = (events: ReadonlyArray<GuestStreamEvent>, state = initialGuestStreamState) =>
102 events.reduce<GuestStreamState>(applyGuestStreamEvent, state);
103
104const itemIds = (state: GuestStreamState) =>
105 state.projection?.visibleTurnItems.map((row) => row.item.id) ?? [];
106
107describe("applyGuestStreamEvent", () => {
108 it("builds the thread from the snapshot and applies each later event once", () => {
109 const once = apply([{ type: "view", view }, snapshot(1), itemEvent(2, reply("a1", "Hi"))]);
110 expect(itemIds(once)).toEqual(["a1"]);
111 // A replayed or older event changes nothing.
112 expect(apply([itemEvent(2, reply("a1", "Changed"))], once)).toBe(once);
113 });
114
115 it("merges older pages in front, only for the cursor the thread is at", () => {
116 const windowed: GuestStreamEvent = {
117 type: "thread",
118 item: {
119 kind: "snapshot",
120 snapshotSequence: 1,
121 projection,
122 historyCursor: "cursor-1",
123 hasMoreHistory: true,
124 },
125 };
126 const page = (id: string, nextCursor: string | null) => ({
127 snapshotSequence: 1,
128 items: [
129 {
130 position: 0,
131 visibility: "local" as const,
132 sourceThreadId: projection.thread.id,
133 sourceItemId: TurnItemId.make(id),
134 item: reply(id, "Earlier"),
135 },
136 ],
137 nextCursor,
138 hasMoreHistory: nextCursor !== null,
139 });
140 const live = apply([windowed, itemEvent(2, reply("a1", "Hi"))]);
141 expect(live.historyCursor).toBe("cursor-1");
142 // A page fetched before a reconnect moved the cursor changes nothing.
143 const stale = { type: "history-page" as const, cursor: "cursor-0", page: page("old", null) };
144 expect(applyGuestStreamEvent(live, stale)).toBe(live);
145 const merged = applyGuestStreamEvent(live, {
146 type: "history-page",
147 cursor: "cursor-1",
148 page: page("o1", null),
149 });
150 expect(itemIds(merged)).toEqual(["o1", "a1"]);
151 expect(merged.historyCursor).toBeNull();
152 });
153
154 it("replaces the thread on a new snapshot, as after a reconnect", () => {
155 const before = apply([snapshot(1), itemEvent(2, reply("a1", "Hi")), { type: "ended" }]);
156 const after = apply([snapshot(1)], { ...before, ended: false });
157 expect(itemIds(after)).toEqual([]);
158 });
159
160 it("ends when the view names another link than the page's", () => {
161 const state = apply([
162 { type: "view", view },
163 { type: "view", view: { ...view, shareId: GuestShareId.make("share-2") } },
164 ]);
165 expect(state.ended).toBe(true);
166 expect(state.view?.shareId).toBe(view.shareId);
167 });
168
169 it("remembers a waiting message across a reconnect until its row arrives", () => {
170 const waiting = {
171 id: GuestMessageId.make("m1"),
172 text: "Why?",
173 status: "pending" as const,
174 submittedAt: "2026-10-23T10:00:00.000Z",
175 };
176 const sent = { ...waiting, status: "sent" as const };
177 const queued = apply([
178 { type: "view", view: { ...view, messages: [waiting] } },
179 snapshot(1),
180 { type: "view", view: { ...view, messages: [sent] } },
181 ]);
182 expect([...queued.awaitingRowIds]).toEqual([waiting.id]);
183
184 // Once placed, a later snapshot whose window no longer holds the row must not resurrect it.
185 const placed = apply([itemEvent(2, guestMessageRow("u1", waiting.id)), snapshot(3)], queued);
186 expect(placed.awaitingRowIds.size).toBe(0);
187 });
188});
189
190describe("parseGuestStreamEvent", () => {
191 it("decodes a valid frame and rejects malformed ones", () => {
192 expect(parseGuestStreamEvent(JSON.stringify({ type: "ended" }))).toEqual({ type: "ended" });
193 expect(parseGuestStreamEvent("{not json")).toBeNull();
194 expect(parseGuestStreamEvent(JSON.stringify({ type: "unknown" }))).toBeNull();
195 });
196});
197
198describe("unplacedGuestMessages", () => {
199 const message = (id: string, status: ThreadGuestMessage["status"], submittedAt: string) => ({
200 id: GuestMessageId.make(id),
201 text: id,
202 status,
203 submittedAt,
204 });
205
206 it("shows pending ones, and resolved ones only if this page saw them waiting", () => {
207 const messages = [
208 message("late", "pending", "2026-10-23T10:05:00.000Z"),
209 message("placed", "sent", "2026-10-23T10:00:00.000Z"),
210 message("queued", "sent", "2026-10-23T09:59:00.000Z"),
211 message("dismissed", "dismissed", "2026-10-23T10:01:00.000Z"),
212 message("old-history", "sent", "2026-10-23T09:00:00.000Z"),
213 ];
214 const seen = new Set(
215 ["placed", "queued", "dismissed", "late"].map((id) => GuestMessageId.make(id)),
216 );
217 const items = [
218 {
219 position: 0,
220 visibility: "local" as const,
221 sourceThreadId: projection.thread.id,
222 sourceItemId: TurnItemId.make("u1"),
223 item: guestMessageRow("u1", GuestMessageId.make("placed")),
224 },
225 ];
226 expect(unplacedGuestMessages(messages, items, seen).map((m) => m.id)).toEqual([
227 "queued",
228 "dismissed",
229 "late",
230 ]);
231 });
232});
233
234describe("formatGuestExpiry", () => {
235 const now = Date.parse("2026-10-23T00:00:00.000Z");
236 it("shows whole hours, then minutes, then expired", () => {
237 expect(formatGuestExpiry("2026-10-23T23:59:00.000Z", now)).toBe("23h left");
238 expect(formatGuestExpiry("2026-10-23T00:45:30.000Z", now)).toBe("45m left");
239 expect(formatGuestExpiry("2026-10-23T00:00:00.000Z", now)).toBe("Expired");
240 });
241});
242
243describe("inviteShareId", () => {
244 it("reads the share id before the signature and rejects anything else", () => {
245 expect(inviteShareId("k3J9xQ2mT8vL5nR1pW7yZa.sig")).toBe("k3J9xQ2mT8vL5nR1pW7yZa");
246 expect(inviteShareId("no-signature")).toBeNull();
247 expect(inviteShareId(".sig")).toBeNull();
248 expect(inviteShareId("")).toBeNull();
249 });
250});
251
252describe("guestFilesProblem", () => {
253 const file = (type: string, size = 1) => ({ type, size });
254 it("takes up to four files and 10 MB, with images in the types the owner's composer takes", () => {
255 expect(guestFilesProblem([file("image/png"), file("application/pdf"), file("")])).toBeNull();
256 expect(guestFilesProblem([1, 2, 3, 4, 5].map(() => file("image/png")))).toMatch(/up to 4/);
257 expect(guestFilesProblem([file("image/png", 10 * 1024 * 1024 + 1)])).toMatch(/10 MB/);
258 expect(guestFilesProblem([file("image/svg+xml")])).toMatch(/PNG, JPEG, GIF, or WebP/);
259 expect(guestFilesProblem([file("text/plain", 0)])).toMatch(/cannot be attached/);
260 });
261});