1import {
2 EnvironmentId,
3 MessageId,
4 NodeId,
5 ProviderDriverKind,
6 ProviderInstanceId,
7 ProviderSessionId,
8 ProviderThreadId,
9 RunId,
10 RuntimeRequestId,
11 TurnItemId,
12} from "@t3tools/contracts";
13import * as DateTime from "effect/DateTime";
14import { describe, expect, it } from "vite-plus/test";
15
16import {
17 InvalidScopedProjectRefCollectionKeyError,
18 InvalidScopedThreadKeyError,
19 parseProjectRefCollectionKey,
20 parseThreadKey,
21} from "./entities.ts";
22import {
23 presentThreadShell,
24 resolveThreadProviderStack,
25 resolveThreadWorkingStartedAt,
26} from "./models.ts";
27import { v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
28import { deriveLatestThreadRun, deriveThreadRuntime } from "./threadExecution.ts";
29import { derivePendingThreadRequests } from "./threadRequests.ts";
30
31const environmentId = EnvironmentId.make("environment-v2");
32
33describe("scoped entity keys", () => {
34 it("preserves an invalid thread key as structured error data", () => {
35 const key = "missing-thread-key-separator";
36 let error: unknown;
37
38 try {
39 parseThreadKey(key);
40 } catch (cause) {
41 error = cause;
42 }
43
44 expect(error).toEqual(new InvalidScopedThreadKeyError({ key }));
45 });
46
47 it("preserves malformed project reference collection input and its cause", () => {
48 const key = "not-json";
49 let error: unknown;
50
51 try {
52 parseProjectRefCollectionKey(key);
53 } catch (cause) {
54 error = cause;
55 }
56
57 expect(error).toBeInstanceOf(InvalidScopedProjectRefCollectionKeyError);
58 expect(error).toMatchObject({ key, cause: expect.anything() });
59 });
60
61 it("rejects invalid project reference collection shapes", () => {
62 const key = JSON.stringify([["environment-1"]]);
63
64 expect(() => parseProjectRefCollectionKey(key)).toThrowError(
65 InvalidScopedProjectRefCollectionKeyError,
66 );
67 });
68});
69
70describe("V2 client presentation", () => {
71 it("presents shell timestamps and status without constructing V1 state", () => {
72 const shell = presentThreadShell(environmentId, v2ThreadShell);
73 expect(shell.environmentId).toBe(environmentId);
74 expect(shell.createdAt).toBe("2026-06-20T00:00:00.000Z");
75 expect(shell.runtime).toBeNull();
76 expect(shell.source).toBe(v2ThreadShell);
77 });
78
79 it("preserves active ordering and both pull-request sources", () => {
80 const linkedPullRequest = {
81 projectId: v2ThreadShell.projectId,
82 repository: "pingdotgg/t3code",
83 number: 42,
84 url: "https://github.com/pingdotgg/t3code/pull/42",
85 };
86 const branchPullRequest = { ...linkedPullRequest, number: 43 };
87 const shell = presentThreadShell(environmentId, {
88 ...v2ThreadShell,
89 activeOrderKey: "m",
90 linkedPullRequest,
91 branchPullRequest,
92 });
93
94 expect(shell.activeOrderKey).toBe("m");
95 expect(shell.linkedPullRequest).toEqual(linkedPullRequest);
96 expect(shell.branchPullRequest).toEqual(branchPullRequest);
97 });
98
99 it("presents provider errors carried by failed thread shells", () => {
100 const runId = RunId.make("run-failed");
101 const shell = presentThreadShell(environmentId, {
102 ...v2ThreadShell,
103 latestRunId: runId,
104 status: "failed",
105 lastError: "provider process exited",
106 });
107
108 expect(shell.runtime).toMatchObject({
109 status: "failed",
110 lastError: "provider process exited",
111 });
112 });
113
114 it("preserves shell run timestamps for sidebar activity clocks", () => {
115 const runId = RunId.make("run-working-clock");
116 const requestedAt = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
117 const startedAt = DateTime.makeUnsafe("2026-06-20T01:00:02.000Z");
118 const shell = presentThreadShell(environmentId, {
119 ...v2ThreadShell,
120 latestRunId: runId,
121 latestRunRequestedAt: requestedAt,
122 latestRunStartedAt: startedAt,
123 latestRunCompletedAt: null,
124 activeRunId: runId,
125 status: "running",
126 updatedAt: DateTime.makeUnsafe("2026-06-20T01:04:45.000Z"),
127 });
128
129 expect(shell.latestRun).toMatchObject({
130 runId,
131 status: "running",
132 requestedAt: "2026-06-20T01:00:00.000Z",
133 startedAt: "2026-06-20T01:00:02.000Z",
134 completedAt: null,
135 });
136 });
137
138 it("parks presented runtime at idle when a settled shell has pending background tasks", () => {
139 const runId = RunId.make("run-completed");
140 const shell = presentThreadShell(environmentId, {
141 ...v2ThreadShell,
142 latestRunId: runId,
143 activeRunId: null,
144 status: "completed",
145 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
146 });
147
148 expect(shell.latestRun).toMatchObject({ runId, status: "completed" });
149 expect(shell.runtime).toMatchObject({
150 status: "idle",
151 activeRunId: null,
152 });
153 expect(shell.pendingBackgroundTasks).toEqual([
154 { taskId: "bg-1", description: "Watch build", kind: "monitor" },
155 ]);
156 });
157
158 it("parks a thread that never ran at idle while a pull request watch holds it", () => {
159 const watch = { taskId: "pull-request-watch:github.com/acme/app#1", kind: "monitor" as const };
160 const watched = presentThreadShell(environmentId, {
161 ...v2ThreadShell,
162 latestRunId: null,
163 activeProviderThreadId: null,
164 activeRunId: null,
165 status: "idle",
166 pendingBackgroundTasks: [watch],
167 });
168 expect(watched.runtime).toMatchObject({ status: "idle", activeRunId: null });
169 expect(
170 presentThreadShell(environmentId, {
171 ...v2ThreadShell,
172 latestRunId: null,
173 activeProviderThreadId: null,
174 status: "idle",
175 pendingBackgroundTasks: [],
176 }).runtime,
177 ).toBeNull();
178 });
179
180 it.each([
181 { kinds: ["command"], expected: "completed" },
182 { kinds: ["command", "subagent"], expected: "idle" },
183 { kinds: ["background_task"], expected: "idle" },
184 ] as const)("presents a completed shell with $kinds as $expected", ({ kinds, expected }) => {
185 const pendingBackgroundTasks = kinds.map((kind, index) => ({
186 taskId: `bg-${index}`,
187 kind,
188 }));
189 const shell = presentThreadShell(environmentId, {
190 ...v2ThreadShell,
191 latestRunId: RunId.make("run-completed"),
192 activeRunId: null,
193 status: "completed",
194 pendingBackgroundTasks,
195 });
196
197 expect(shell.latestRun?.status).toBe("completed");
198 expect(shell.runtime).toMatchObject({ status: expected, activeRunId: null });
199 expect(shell.pendingBackgroundTasks).toEqual(pendingBackgroundTasks);
200 });
201
202 it.each(["running", "waiting"] as const)(
203 "preserves shell %s while only commands remain in the roster",
204 (status) => {
205 const runId = RunId.make("run-command");
206 const shell = presentThreadShell(environmentId, {
207 ...v2ThreadShell,
208 latestRunId: runId,
209 activeRunId: runId,
210 status,
211 pendingBackgroundTasks: [{ taskId: "dev-server", kind: "command" }],
212 });
213
214 expect(shell.latestRun?.status).toBe(status);
215 expect(shell.runtime).toMatchObject({ status, activeRunId: runId });
216 expect(shell.pendingBackgroundTasks).toEqual([{ taskId: "dev-server", kind: "command" }]);
217 },
218 );
219
220 it("keeps a failed latest run failed while background tasks are still pending", () => {
221 const shell = presentThreadShell(environmentId, {
222 ...v2ThreadShell,
223 latestRunId: RunId.make("run-failed"),
224 activeRunId: null,
225 status: "failed",
226 lastError: "Provider turn failed",
227 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
228 });
229
230 // Sidebar and mobile list read runtime "idle" as Waiting before failure.
231 expect(shell.runtime).toMatchObject({ status: "failed", lastError: "Provider turn failed" });
232 expect(shell.pendingBackgroundTasks).toHaveLength(1);
233 });
234
235 it("stacks earlier provider owners behind the current one, newest history first to go", () => {
236 const codex = ProviderInstanceId.make("codex");
237 const claude = ProviderInstanceId.make("claude");
238 const cursor = ProviderInstanceId.make("cursor");
239 const grok = ProviderInstanceId.make("grok");
240 const shell = presentThreadShell(environmentId, {
241 ...v2ThreadShell,
242 providerInstanceId: grok,
243 modelSelection: { instanceId: grok, model: "grok-4" },
244 providerInstanceHistory: [codex, claude, cursor, grok],
245 });
246
247 // Three slots: the two most recent earlier owners, then the current one.
248 expect(resolveThreadProviderStack(shell)).toEqual([claude, cursor, grok]);
249 expect(
250 resolveThreadProviderStack({ ...shell, providerInstanceHistory: [codex, grok] }),
251 ).toEqual([codex, grok]);
252 expect(resolveThreadProviderStack({ ...shell, providerInstanceHistory: [] })).toEqual([grok]);
253 // Servers that predate the field decode to an empty history.
254 expect(
255 presentThreadShell(environmentId, { ...v2ThreadShell, providerInstanceHistory: undefined })
256 .providerInstanceHistory,
257 ).toEqual([]);
258 });
259
260 it("keeps terminal runtime completed when there are no pending background tasks", () => {
261 const runId = RunId.make("run-completed");
262 const shell = presentThreadShell(environmentId, {
263 ...v2ThreadShell,
264 latestRunId: runId,
265 activeRunId: null,
266 status: "completed",
267 pendingBackgroundTasks: [],
268 });
269
270 expect(shell.latestRun).toMatchObject({ runId, status: "completed" });
271 expect(shell.runtime).toMatchObject({
272 status: "completed",
273 activeRunId: null,
274 });
275 });
276
277 it("keeps runtime running when there is no background roster", () => {
278 const runId = RunId.make("run-running");
279 const shell = presentThreadShell(environmentId, {
280 ...v2ThreadShell,
281 latestRunId: runId,
282 activeRunId: runId,
283 status: "running",
284 pendingBackgroundTasks: [],
285 });
286
287 expect(shell.latestRun).toMatchObject({ runId, status: "running" });
288 expect(shell.runtime).toMatchObject({
289 status: "running",
290 activeRunId: runId,
291 });
292 });
293
294 it("keeps an older active run visible over a newer cancelled run", () => {
295 const activeRunId = RunId.make("run-active");
296 const cancelledRunId = RunId.make("run-cancelled");
297 const shell = presentThreadShell(environmentId, {
298 ...v2ThreadShell,
299 latestRunId: cancelledRunId,
300 latestRunStartedAt: null,
301 latestRunCompletedAt: DateTime.makeUnsafe("2026-06-20T01:05:00.000Z"),
302 activeRunId,
303 activityRunStatus: "running",
304 status: "cancelled",
305 pendingBackgroundTasks: [],
306 });
307
308 expect(shell.latestRun).toMatchObject({ runId: cancelledRunId, status: "cancelled" });
309 expect(shell.runtime).toMatchObject({
310 status: "running",
311 activeRunId,
312 });
313 });
314
315 it("keeps an older waiting run visible over a newer cancelled run", () => {
316 const shell = presentThreadShell(environmentId, {
317 ...v2ThreadShell,
318 latestRunId: RunId.make("run-cancelled"),
319 activeRunId: null,
320 activityRunStatus: "waiting",
321 status: "cancelled",
322 pendingBackgroundTasks: [],
323 });
324
325 expect(shell.runtime).toMatchObject({ status: "waiting", activeRunId: null });
326 });
327
328 it("keeps a post-settlement background roster ahead of activity status", () => {
329 const activeRunId = RunId.make("run-active");
330 const shell = presentThreadShell(environmentId, {
331 ...v2ThreadShell,
332 latestRunId: RunId.make("run-cancelled"),
333 activeRunId,
334 activityRunStatus: "running",
335 status: "cancelled",
336 pendingBackgroundTasks: [
337 { taskId: "bg-activity", description: "background work", kind: "background_task" },
338 ],
339 });
340
341 expect(shell.runtime).toMatchObject({ status: "idle", activeRunId });
342 });
343
344 it("parks runtime idle over stale shell running when the roster is nonempty", () => {
345 const runId = RunId.make("run-stale-running");
346 const shell = presentThreadShell(environmentId, {
347 ...v2ThreadShell,
348 latestRunId: runId,
349 activeRunId: runId,
350 // Stale: server already projected a post-settlement roster, but shell
351 // status still says running (packaged orchestrator-v2 bug).
352 status: "running",
353 pendingBackgroundTasks: [{ taskId: "bg-1", description: "Watch build", kind: "monitor" }],
354 });
355
356 expect(shell.latestRun).toMatchObject({ runId, status: "running" });
357 expect(shell.runtime).toMatchObject({
358 status: "idle",
359 activeRunId: runId,
360 });
361 expect(shell.pendingBackgroundTasks).toEqual([
362 { taskId: "bg-1", description: "Watch build", kind: "monitor" },
363 ]);
364 });
365
366 it("parks runtime idle over stale checkpoint waiting when the roster is nonempty", () => {
367 const runId = RunId.make("run-stale-waiting");
368 const shell = presentThreadShell(environmentId, {
369 ...v2ThreadShell,
370 latestRunId: runId,
371 activeRunId: runId,
372 // Stale: checkpoint-oriented waiting masks post-settlement background work.
373 status: "waiting",
374 pendingBackgroundTasks: [{ taskId: "bg-2", description: "Watch build", kind: "monitor" }],
375 });
376
377 expect(shell.latestRun).toMatchObject({ runId, status: "waiting" });
378 expect(shell.runtime).toMatchObject({
379 status: "idle",
380 activeRunId: runId,
381 });
382 expect(shell.pendingBackgroundTasks).toEqual([
383 { taskId: "bg-2", description: "Watch build", kind: "monitor" },
384 ]);
385 });
386
387 it("derives execution summaries without wrapping or copying the projection", () => {
388 const runId = RunId.make("run-1");
389 const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
390 const projection = {
391 ...v2Projection,
392 runs: [
393 {
394 id: runId,
395 threadId: v2Projection.thread.id,
396 ordinal: 1,
397 providerInstanceId: v2Projection.thread.providerInstanceId,
398 modelSelection: v2Projection.thread.modelSelection,
399 providerThreadId: null,
400 userMessageId: MessageId.make("message-user"),
401 rootNodeId: null,
402 activeAttemptId: null,
403 status: "running" as const,
404 requestedAt: now,
405 startedAt: now,
406 completedAt: null,
407 checkpointId: null,
408 contextHandoffId: null,
409 },
410 ],
411 messages: [
412 {
413 id: MessageId.make("message-user"),
414 threadId: v2Projection.thread.id,
415 runId,
416 nodeId: null,
417 role: "user" as const,
418 text: "Hello",
419 attachments: [],
420 streaming: false,
421 createdBy: "user" as const,
422 creationSource: "web" as const,
423 createdAt: now,
424 updatedAt: now,
425 },
426 ],
427 updatedAt: now,
428 };
429
430 expect(deriveLatestThreadRun(projection)).toMatchObject({
431 runId,
432 status: "running",
433 requestedAt: "2026-06-20T01:00:00.000Z",
434 assistantMessageId: null,
435 });
436 expect(deriveThreadRuntime(projection)).toMatchObject({
437 status: "running",
438 activeRunId: runId,
439 providerInstanceId: projection.thread.providerInstanceId,
440 });
441 for (const status of ["queued", "cancelled"] as const) {
442 const later = DateTime.add(now, { hours: 1 });
443 const latest = {
444 ...projection.runs[0]!,
445 id: RunId.make("newer-run"),
446 ordinal: 2,
447 status,
448 requestedAt: later,
449 startedAt: null,
450 completedAt: status === "cancelled" ? later : null,
451 };
452 const detail = { ...projection, runs: [...projection.runs, latest], updatedAt: later };
453 const shell = presentThreadShell(environmentId, {
454 ...v2ThreadShell,
455 latestRunId: latest.id,
456 latestRunStartedAt: null,
457 latestRunRequestedAt: later,
458 latestRunCompletedAt: latest.completedAt,
459 status,
460 activeRunId: runId,
461 activityRunStatus: "running",
462 activityRunStartedAt: now,
463 updatedAt: later,
464 });
465 expect(resolveThreadWorkingStartedAt(shell)).toBe(DateTime.formatIso(now));
466 expect(
467 resolveThreadWorkingStartedAt({
468 latestRun: deriveLatestThreadRun(detail),
469 runtime: deriveThreadRuntime(detail),
470 }),
471 ).toBe(resolveThreadWorkingStartedAt(shell));
472 const stopped = {
473 ...detail,
474 runs: detail.runs.map((run) => ({
475 ...run,
476 status: "completed" as const,
477 completedAt: later,
478 })),
479 };
480 expect(
481 resolveThreadWorkingStartedAt({
482 latestRun: deriveLatestThreadRun(stopped),
483 runtime: deriveThreadRuntime(stopped),
484 }),
485 ).toBeNull();
486 }
487 });
488
489 it("parks waiting runtime for a post-settlement roster without hiding active running work", () => {
490 const runId = RunId.make("run-background-presentation");
491 const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
492 const run = {
493 id: runId,
494 threadId: v2Projection.thread.id,
495 ordinal: 1,
496 providerInstanceId: v2Projection.thread.providerInstanceId,
497 modelSelection: v2Projection.thread.modelSelection,
498 providerThreadId: null,
499 userMessageId: MessageId.make("message-background-presentation"),
500 rootNodeId: null,
501 activeAttemptId: null,
502 status: "waiting" as const,
503 requestedAt: now,
504 startedAt: now,
505 completedAt: null,
506 checkpointId: null,
507 contextHandoffId: null,
508 };
509 const backgroundItem = {
510 id: TurnItemId.make("item-background-subagent"),
511 threadId: v2Projection.thread.id,
512 runId,
513 nodeId: null,
514 providerThreadId: null,
515 providerTurnId: null,
516 nativeItemRef: null,
517 parentItemId: null,
518 ordinal: 0,
519 status: "running" as const,
520 title: "Background review",
521 startedAt: now,
522 completedAt: null,
523 updatedAt: now,
524 type: "subagent" as const,
525 subagentId: NodeId.make("subagent-review"),
526 origin: "provider_native" as const,
527 driver: ProviderDriverKind.make("codex"),
528 providerInstanceId: v2Projection.thread.providerInstanceId,
529 childThreadId: null,
530 prompt: "Review the changes",
531 result: null,
532 };
533
534 expect(
535 deriveThreadRuntime({
536 ...v2Projection,
537 runs: [run],
538 turnItems: [backgroundItem],
539 }),
540 ).toMatchObject({
541 status: "idle",
542 activeRunId: null,
543 });
544 expect(
545 deriveThreadRuntime({
546 ...v2Projection,
547 runs: [{ ...run, status: "running" as const }],
548 turnItems: [backgroundItem],
549 }),
550 ).toMatchObject({
551 status: "running",
552 activeRunId: runId,
553 });
554 expect(
555 deriveThreadRuntime({
556 ...v2Projection,
557 runs: [{ ...run, status: "failed" as const, completedAt: now }],
558 turnItems: [backgroundItem],
559 }),
560 ).toMatchObject({ status: "failed", activeRunId: null });
561 const commandItem = {
562 ...backgroundItem,
563 id: TurnItemId.make("item-background-command"),
564 type: "command_execution" as const,
565 input: "npm run dev",
566 };
567 expect(
568 deriveThreadRuntime({ ...v2Projection, runs: [run], turnItems: [commandItem] }),
569 ).toMatchObject({ status: "waiting", activeRunId: null });
570
571 for (const [turnItems, status] of [
572 [[commandItem], "completed"],
573 [[backgroundItem], "idle"],
574 [[commandItem, backgroundItem], "idle"],
575 ] as const) {
576 expect(
577 deriveThreadRuntime({
578 ...v2Projection,
579 runs: [{ ...run, status: "completed", completedAt: now }],
580 turnItems,
581 }),
582 ).toMatchObject({ status, activeRunId: null });
583 }
584
585 for (const kind of ["monitor", "background_task"] as const) {
586 const providerThread = {
587 id: ProviderThreadId.make("provider-thread-background"),
588 driver: ProviderDriverKind.make("claudeCode"),
589 providerInstanceId: v2Projection.thread.providerInstanceId,
590 providerSessionId: null,
591 appThreadId: v2Projection.thread.id,
592 ownerNodeId: null,
593 nativeThreadRef: null,
594 nativeConversationHeadRef: null,
595 status: "idle" as const,
596 firstRunOrdinal: 1,
597 lastRunOrdinal: 1,
598 handoffIds: [],
599 forkedFrom: null,
600 pendingBackgroundTasks: [{ taskId: "background-task", kind }],
601 createdAt: now,
602 updatedAt: now,
603 };
604 for (const status of ["completed", "failed"] as const) {
605 expect(
606 deriveThreadRuntime({
607 ...v2Projection,
608 runs: [{ ...run, status, completedAt: now }],
609 providerThreads: [providerThread],
610 }),
611 ).toMatchObject({ status: status === "failed" ? "failed" : "idle", activeRunId: null });
612 }
613 }
614 });
615
616 it("joins pending request entities to their native turn-item display data", () => {
617 const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
618 const requestId = RuntimeRequestId.make("request-approval");
619 const item = {
620 id: TurnItemId.make("item-approval"),
621 threadId: v2Projection.thread.id,
622 runId: null,
623 nodeId: NodeId.make("node-root"),
624 providerThreadId: null,
625 providerTurnId: null,
626 nativeItemRef: null,
627 parentItemId: null,
628 ordinal: 0,
629 status: "completed" as const,
630 title: null,
631 startedAt: now,
632 completedAt: now,
633 updatedAt: now,
634 type: "approval_request" as const,
635 requestId,
636 requestKind: "command" as const,
637 prompt: "Allow command?",
638 };
639 const projection = {
640 ...v2Projection,
641 runtimeRequests: [
642 {
643 id: requestId,
644 nodeId: NodeId.make("node-root"),
645 providerTurnId: null,
646 nativeRequestRef: null,
647 kind: "command" as const,
648 status: "pending" as const,
649 responseCapability: {
650 type: "not_resumable" as const,
651 reason: "provider disconnected",
652 },
653 createdAt: now,
654 resolvedAt: null,
655 },
656 ],
657 turnItems: [item],
658 updatedAt: now,
659 };
660
661 expect(derivePendingThreadRequests(projection).approvals).toEqual([
662 {
663 requestId,
664 requestKind: "command",
665 createdAt: "2026-06-20T01:00:00.000Z",
666 detail: "Allow command?",
667 responseCapability: "not_resumable",
668 },
669 ]);
670 expect(derivePendingThreadRequests(projection).userInputs).toEqual([]);
671 });
672
673 it("preserves structured-question selection mode and defaults legacy payloads", () => {
674 const now = DateTime.makeUnsafe("2026-06-20T01:00:00.000Z");
675 const multiRequestId = RuntimeRequestId.make("request-multi-select");
676 const legacyRequestId = RuntimeRequestId.make("request-legacy-single-select");
677 const runtimeRequests = [multiRequestId, legacyRequestId].map((id) => ({
678 id,
679 nodeId: NodeId.make(`node-${id}`),
680 providerTurnId: null,
681 nativeRequestRef: null,
682 kind: "user_input" as const,
683 status: "pending" as const,
684 responseCapability: {
685 type: "live" as const,
686 providerSessionId: ProviderSessionId.make("provider-session-questions"),
687 },
688 createdAt: now,
689 resolvedAt: null,
690 }));
691 const common = {
692 threadId: v2Projection.thread.id,
693 runId: null,
694 providerThreadId: null,
695 providerTurnId: null,
696 nativeItemRef: null,
697 parentItemId: null,
698 ordinal: 0,
699 status: "completed" as const,
700 title: null,
701 startedAt: now,
702 completedAt: now,
703 updatedAt: now,
704 type: "user_input_request" as const,
705 };
706 const projection = {
707 ...v2Projection,
708 runtimeRequests,
709 turnItems: [
710 {
711 ...common,
712 id: TurnItemId.make("item-multi-select"),
713 nodeId: NodeId.make("node-request-multi-select"),
714 requestId: multiRequestId,
715 questions: [
716 {
717 id: "regions",
718 header: "Regions",
719 question: "Which regions?",
720 options: [
721 { label: "US", description: "United States" },
722 { label: "EU", description: "European Union" },
723 ],
724 multiSelect: true,
725 },
726 ],
727 },
728 {
729 ...common,
730 id: TurnItemId.make("item-legacy-single-select"),
731 nodeId: NodeId.make("node-request-legacy-single-select"),
732 requestId: legacyRequestId,
733 questions: [
734 {
735 id: "target",
736 header: "Target",
737 question: "Which target?",
738 options: [{ label: "Staging", description: "Staging environment" }],
739 },
740 ],
741 },
742 ],
743 updatedAt: now,
744 };
745
746 expect(
747 derivePendingThreadRequests(projection).userInputs.map((input) => ({
748 requestId: input.requestId,
749 multiSelect: input.questions[0]?.multiSelect,
750 })),
751 ).toEqual([
752 { requestId: multiRequestId, multiSelect: true },
753 { requestId: legacyRequestId, multiSelect: false },
754 ]);
755 });
756});