Skip to content
Source
Sign inSign up

GuestShare.test.ts

343 lines11.4 KB

1import { assert, it } from "@effect/vitest";
2import {
3 CommandId,
4 GuestMessageId,
5 GuestShareId,
6 MessageId,
7 ProjectId,
8 ProviderDriverKind,
9 ProviderInstanceId,
10 ThreadId,
11} from "@t3tools/contracts";
12import * as DateTime from "effect/DateTime";
13import * as Effect from "effect/Effect";
14import * as Exit from "effect/Exit";
15import * as Layer from "effect/Layer";
16import type * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter";
17​
18import * as SqlitePersistence from "../persistence/Sqlite.ts";
19import { CodexProviderCapabilitiesV2 } from "./Adapters/CodexAdapterV2.ts";
20import * as EffectOutbox from "./EffectOutbox.ts";
21import * as Orchestrator from "./Orchestrator.ts";
22import * as ProjectionStore from "./ProjectionStore.ts";
23import * as ProviderAdapterRegistry from "./ProviderAdapterRegistry.ts";
24import * as ProviderReplayHarness from "./testkit/ProviderReplayHarness.ts";
25​
26const instanceId = ProviderInstanceId.make("codex");
27const adapter = {
28 instanceId,
29 driver: ProviderDriverKind.make("codex"),
30 getCapabilities: () => Effect.succeed(CodexProviderCapabilitiesV2),
31 planSelectionTransition: () => Effect.succeed({ type: "apply_on_next_turn" as const }),
32 openSession: () => Effect.die("No provider process needed for guest shares"),
33} as ProviderAdapter.ProviderAdapterV2["Service"];
34const layerDatabase = SqlitePersistence.layerMemory;
35const layerTest = Layer.mergeAll(
36 layerDatabase,
37 ProjectionStore.layer.pipe(Layer.provide(layerDatabase)),
38 EffectOutbox.layer.pipe(Layer.provide(layerDatabase)),
39 ProviderReplayHarness.layerWithRegistry(
40 { name: "guest-share" },
41 ProviderAdapterRegistry.layerFromAdapters([adapter]),
42 { databaseLayer: layerDatabase, runEffectWorker: false },
43 ),
44);
45​
46const threadId = ThreadId.make("thread:guest-share");
47const shareId = GuestShareId.make("share:one");
48const guestMessageId = GuestMessageId.make("guest-message:one");
49​
50const setup = Effect.gen(function* () {
51 const orchestrator = yield* Orchestrator.OrchestratorV2;
52 yield* orchestrator.dispatch({
53 type: "thread.create",
54 commandId: CommandId.make("create-guest-share"),
55 threadId,
56 projectId: ProjectId.make("project:guest-share"),
57 title: "Review me",
58 modelSelection: { instanceId, model: "gpt-5.1-codex" },
59 runtimeMode: "full-access",
60 interactionMode: "default",
61 branch: null,
62 worktreePath: null,
63 createdBy: "user",
64 creationSource: "web",
65 });
66 yield* orchestrator.dispatch({
67 type: "thread.guest-share.open",
68 commandId: CommandId.make("open-guest-share"),
69 threadId,
70 shareId,
71 host: "env.t3.test",
72 expiresAt: DateTime.formatIso(DateTime.add(yield* DateTime.now, { hours: 24 })),
73 });
74 return orchestrator;
75});
76​
77const sendGuestMessage = (orchestrator: Orchestrator.OrchestratorV2["Service"], id: string) =>
78 orchestrator.dispatch({
79 type: "message.dispatch",
80 commandId: CommandId.make(id),
81 threadId,
82 messageId: MessageId.make(id),
83 guestMessageId,
84 text: "ignored: the server sends the guest's own text",
85 attachments: [],
86 dispatchMode: { type: "defer_start" },
87 createdBy: "user",
88 creationSource: "web",
89 });
90​
91it.effect("redeems a guest link once and sends each guest message once, with its turn", () =>
92 Effect.gen(function* () {
93 const orchestrator = yield* setup;
94 const projections = yield* ProjectionStore.ProjectionStoreV2;
95 const redeem = (id: string) =>
96 orchestrator.dispatch({
97 type: "thread.guest-share.redeem",
98 commandId: CommandId.make(id),
99 threadId,
100 shareId,
101 guestName: "Sam",
102 });
103 yield* redeem("redeem-1");
104 assert.isTrue(Exit.isFailure(yield* Effect.exit(redeem("redeem-2"))));
105​
106 yield* orchestrator.dispatch({
107 type: "thread.guest-message.submit",
108 commandId: CommandId.make("submit-1"),
109 threadId,
110 shareId,
111 guestMessageId,
112 text: "Why not keep the old cache?",
113 });
114 assert.equal((yield* projections.getThreadShell(threadId))?.pendingGuestMessageCount, 1);
115​
116 yield* sendGuestMessage(orchestrator, "send-1");
117 const projection = yield* projections.getThreadProjection(threadId);
118 assert.deepEqual(
119 projection.thread.guestShare?.messages.map((message) => message.status),
120 ["sent"],
121 );
122 const sent = projection.messages.find((message) => message.id === MessageId.make("send-1"));
123 assert.equal(
124 sent?.text,
125 "Message from guest Sam, sent by the owner:\n\nWhy not keep the old cache?",
126 );
127 assert.deepEqual(sent?.context?.guest, {
128 guestMessageId,
129 name: "Sam",
130 text: "Why not keep the old cache?",
131 shareId,
132 });
133 assert.lengthOf(projection.runs, 1);
134 assert.equal((yield* projections.getThreadShell(threadId))?.pendingGuestMessageCount, 0);
135​
136 // A second owner device clicking Send must not start another turn.
137 assert.isTrue(Exit.isFailure(yield* Effect.exit(sendGuestMessage(orchestrator, "send-2"))));
138 assert.lengthOf((yield* projections.getThreadProjection(threadId)).runs, 1);
139 }).pipe(Effect.provide(layerTest)),
140);
141​
142it.effect("rejects guest messages until the link is redeemed and after archive", () =>
143 Effect.gen(function* () {
144 const orchestrator = yield* setup;
145 const projections = yield* ProjectionStore.ProjectionStoreV2;
146 const submit = orchestrator.dispatch({
147 type: "thread.guest-message.submit",
148 commandId: CommandId.make("submit-early"),
149 threadId,
150 shareId,
151 guestMessageId,
152 text: "Hello",
153 });
154 assert.isTrue(Exit.isFailure(yield* Effect.exit(submit)));
155​
156 yield* orchestrator.dispatch({
157 type: "thread.archive",
158 commandId: CommandId.make("archive-guest-share"),
159 threadId,
160 });
161 assert.isNull((yield* projections.getThread(threadId)).guestShare ?? null);
162 }).pipe(Effect.provide(layerTest)),
163);
164​
165it.effect("drops guest attribution that a client sends on its own message", () =>
166 Effect.gen(function* () {
167 const orchestrator = yield* setup;
168 const projections = yield* ProjectionStore.ProjectionStoreV2;
169 yield* orchestrator.dispatch({
170 type: "message.dispatch",
171 commandId: CommandId.make("owner-message"),
172 threadId,
173 messageId: MessageId.make("owner-message"),
174 text: "Mine",
175 context: { version: 1, records: [], guest: { guestMessageId, name: "Sam", text: "Mine" } },
176 attachments: [],
177 dispatchMode: { type: "defer_start" },
178 createdBy: "user",
179 creationSource: "web",
180 });
181 const projection = yield* projections.getThreadProjection(threadId);
182 const sent = projection.messages.find(
183 (message) => message.id === MessageId.make("owner-message"),
184 );
185 assert.isUndefined(sent?.context?.guest);
186 }).pipe(Effect.provide(layerTest)),
187);
188​
189it.effect("keeps a queued guest message unchanged when the owner tries to edit it", () =>
190 Effect.gen(function* () {
191 const orchestrator = yield* setup;
192 const projections = yield* ProjectionStore.ProjectionStoreV2;
193 yield* orchestrator.dispatch({
194 type: "message.dispatch",
195 commandId: CommandId.make("owner-first"),
196 threadId,
197 messageId: MessageId.make("owner-first"),
198 text: "Start",
199 attachments: [],
200 dispatchMode: { type: "defer_start" },
201 createdBy: "user",
202 creationSource: "web",
203 });
204 yield* orchestrator.dispatch({
205 type: "thread.guest-share.redeem",
206 commandId: CommandId.make("redeem"),
207 threadId,
208 shareId,
209 guestName: "Sam",
210 });
211 yield* orchestrator.dispatch({
212 type: "thread.guest-message.submit",
213 commandId: CommandId.make("submit"),
214 threadId,
215 shareId,
216 guestMessageId,
217 text: "Why?",
218 });
219 yield* orchestrator.dispatch({
220 type: "message.dispatch",
221 commandId: CommandId.make("send-queued"),
222 threadId,
223 messageId: MessageId.make("send-queued"),
224 guestMessageId,
225 text: "",
226 attachments: [],
227 dispatchMode: { type: "queue_after_active" },
228 createdBy: "user",
229 creationSource: "web",
230 });
231 const queued = (yield* projections.getThreadProjection(threadId)).runs.find(
232 (run) => run.status === "queued",
233 );
234 assert.isDefined(queued);
235​
236 const edit = orchestrator.dispatch({
237 type: "queued-run.edit",
238 commandId: CommandId.make("edit"),
239 threadId,
240 runId: queued.id,
241 text: "My own words",
242 });
243 assert.isTrue(Exit.isFailure(yield* Effect.exit(edit)));
244 const message = (yield* projections.getThreadProjection(threadId)).messages.find(
245 (candidate) => candidate.id === MessageId.make("send-queued"),
246 );
247 assert.equal(message?.context?.guest?.text, "Why?");
248 }).pipe(Effect.provide(layerTest)),
249);
250​
251it.effect("shows a guest message as dismissed when the owner cancels its queued send", () =>
252 Effect.gen(function* () {
253 const orchestrator = yield* setup;
254 const projections = yield* ProjectionStore.ProjectionStoreV2;
255 yield* orchestrator.dispatch({
256 type: "message.dispatch",
257 commandId: CommandId.make("owner-first"),
258 threadId,
259 messageId: MessageId.make("owner-first"),
260 text: "Start",
261 attachments: [],
262 dispatchMode: { type: "defer_start" },
263 createdBy: "user",
264 creationSource: "web",
265 });
266 yield* orchestrator.dispatch({
267 type: "thread.guest-share.redeem",
268 commandId: CommandId.make("redeem"),
269 threadId,
270 shareId,
271 guestName: "Sam",
272 });
273 yield* orchestrator.dispatch({
274 type: "thread.guest-message.submit",
275 commandId: CommandId.make("submit"),
276 threadId,
277 shareId,
278 guestMessageId,
279 text: "Why?",
280 });
281 yield* orchestrator.dispatch({
282 type: "message.dispatch",
283 commandId: CommandId.make("send-queued"),
284 threadId,
285 messageId: MessageId.make("send-queued"),
286 guestMessageId,
287 text: "",
288 attachments: [],
289 dispatchMode: { type: "queue_after_active" },
290 createdBy: "user",
291 creationSource: "web",
292 });
293 const queued = (yield* projections.getThreadProjection(threadId)).runs.find(
294 (run) => run.status === "queued",
295 );
296 assert.isDefined(queued);
297 yield* orchestrator.dispatch({
298 type: "queued-run.cancel",
299 commandId: CommandId.make("cancel"),
300 threadId,
301 runId: queued.id,
302 });
303 const { thread } = yield* projections.getThreadProjection(threadId);
304 assert.equal(
305 thread.guestShare?.messages.find((message) => message.id === guestMessageId)?.status,
306 "dismissed",
307 );
308 }).pipe(Effect.provide(layerTest)),
309);
310​
311it.effect("brings a settled thread back when its guest sends a request", () =>
312 Effect.gen(function* () {
313 const orchestrator = yield* setup;
314 const projections = yield* ProjectionStore.ProjectionStoreV2;
315 yield* orchestrator.dispatch({
316 type: "thread.guest-share.redeem",
317 commandId: CommandId.make("redeem"),
318 threadId,
319 shareId,
320 guestName: "Sam",
321 });
322 yield* orchestrator.dispatch({
323 type: "thread.settle",
324 commandId: CommandId.make("settle"),
325 threadId,
326 });
327 assert.equal(
328 (yield* projections.getThreadProjection(threadId)).thread.settledOverride,
329 "settled",
330 );
331 yield* orchestrator.dispatch({
332 type: "thread.guest-message.submit",
333 commandId: CommandId.make("submit"),
334 threadId,
335 shareId,
336 guestMessageId,
337 text: "Why?",
338 });
339 const { thread } = yield* projections.getThreadProjection(threadId);
340 assert.isNull(thread.settledOverride);
341 assert.isNull(thread.settledAt);
342 }).pipe(Effect.provide(layerTest)),
343);