1import {
2 NodeId,
3 ProviderSessionId,
4 RuntimeRequestId,
5 TurnItemId,
6 type OrchestrationV2ThreadProjection,
7} from "@t3tools/contracts";
8import { describe, expect, it } from "vite-plus/test";
9import { v2Now, v2Projection } from "./orchestrationV2TestFixtures.ts";
10import {
11 createQuestionHistoryProjector,
12 derivePendingThreadRequests,
13 seedUserInputDraftAnswers,
14} from "./threadRequests.ts";
15
16const requestId = RuntimeRequestId.make("async-question");
17const nodeId = NodeId.make("async-question-node");
18
19describe("initial user input drafts", () => {
20 const questions = [
21 { id: "editor", initialAnswer: " Proposed commit\n\n" },
22 { id: "empty", initialAnswer: "" },
23 { id: "ordinary" },
24 { id: "choices", initialAnswer: "Ignore this", allowCustomAnswer: false },
25 ];
26
27 it("seeds editable answers and preserves edits and selections on later snapshots", () => {
28 const seeded = seedUserInputDraftAnswers(questions, {});
29 expect(seeded).toEqual({
30 editor: { customAnswer: " Proposed commit\n\n" },
31 empty: { customAnswer: "" },
32 });
33 const edits = {
34 ...seeded,
35 editor: { customAnswer: "" },
36 empty: { selectedOptionValues: ["empty"], customAnswer: "" },
37 };
38 expect(seedUserInputDraftAnswers(questions, edits)).toEqual(edits);
39 expect(
40 seedUserInputDraftAnswers(questions, { editor: { customAnswer: "Edited commit" } }).editor,
41 ).toEqual({ customAnswer: "Edited commit" });
42 expect(seedUserInputDraftAnswers(questions, {}).editor).toEqual({
43 customAnswer: " Proposed commit\n\n",
44 });
45 });
46});
47const projection: OrchestrationV2ThreadProjection = {
48 ...v2Projection,
49 runtimeRequests: [
50 {
51 id: requestId,
52 nodeId,
53 providerTurnId: null,
54 nativeRequestRef: null,
55 kind: "user_input",
56 status: "pending",
57 responseCapability: { type: "message" },
58 createdAt: v2Now,
59 resolvedAt: null,
60 },
61 ],
62 turnItems: [
63 {
64 id: TurnItemId.make("async-question-item"),
65 threadId: v2Projection.thread.id,
66 runId: null,
67 nodeId,
68 providerThreadId: null,
69 providerTurnId: null,
70 nativeItemRef: null,
71 parentItemId: null,
72 ordinal: 0,
73 status: "completed",
74 title: null,
75 startedAt: v2Now,
76 completedAt: v2Now,
77 updatedAt: v2Now,
78 type: "user_input_request",
79 requestId,
80 responseMode: "message",
81 questions: [
82 {
83 id: "next",
84 header: "Next",
85 question: "What should happen next?",
86 required: true,
87 allowCustomAnswer: false,
88 options: [{ label: "Continue", value: " continue ", description: "Resume work" }],
89 },
90 ],
91 },
92 ],
93};
94
95describe("pending v2 questions", () => {
96 it("keeps message responses available after the originating runtime exits", () => {
97 expect(projection.providerSessions).toEqual([]);
98 expect(derivePendingThreadRequests(projection).userInputs).toEqual([
99 {
100 requestId,
101 createdAt: "2026-06-20T00:00:00.000Z",
102 responseCapability: "message",
103 responseMode: "message",
104 dismissible: true,
105 questions: [
106 {
107 id: "next",
108 header: "Next",
109 question: "What should happen next?",
110 required: true,
111 allowCustomAnswer: false,
112 multiSelect: false,
113 options: [{ label: "Continue", value: " continue ", description: "Resume work" }],
114 },
115 ],
116 },
117 ]);
118 });
119
120 it("only marks asynchronous questions as dismissible", () => {
121 const live = {
122 ...projection,
123 runtimeRequests: projection.runtimeRequests.map((request) => ({
124 ...request,
125 responseCapability: {
126 type: "live" as const,
127 providerSessionId: ProviderSessionId.make("live-session"),
128 },
129 })),
130 turnItems: projection.turnItems.map((item) =>
131 item.type === "user_input_request" ? { ...item, responseMode: undefined } : item,
132 ),
133 };
134 expect(derivePendingThreadRequests(live).userInputs[0]?.dismissible).toBe(false);
135 });
136
137 it("removes answered requests from the composer while retaining their answers in projection data", () => {
138 const answered = {
139 ...projection,
140 runtimeRequests: projection.runtimeRequests.map((request) => ({
141 ...request,
142 status: "resolved" as const,
143 resolvedAt: v2Now,
144 answers: { next: " continue " },
145 })),
146 };
147 expect(derivePendingThreadRequests(answered).userInputs).toEqual([]);
148 expect(answered.runtimeRequests[0]?.answers).toEqual({ next: " continue " });
149 });
150});
151
152it("restores old text answers without mutating history or replacing unchanged rows", () => {
153 const project = createQuestionHistoryProjector();
154 const item = projection.turnItems[0]!;
155 const row = {
156 item,
157 position: 0,
158 sourceItemId: item.id,
159 sourceThreadId: item.threadId,
160 visibility: "local" as const,
161 };
162 const rows = [row];
163 const pending = { visibleTurnItems: rows, runtimeRequests: projection.runtimeRequests };
164 expect(project(pending)).toBe(rows);
165 const answered = {
166 ...pending,
167 runtimeRequests: [
168 {
169 ...projection.runtimeRequests[0]!,
170 status: "resolved" as const,
171 answers: { next: "Continue" },
172 },
173 ],
174 };
175 const result = project(answered);
176 expect(result[0]?.item).toMatchObject({
177 questionAnswer: {
178 answers: { next: "Continue" },
179 attachmentsByQuestionId: {},
180 questionTextById: { next: "What should happen next?" },
181 },
182 });
183 expect(row.item).not.toHaveProperty("questionAnswer");
184 expect(project(answered)).toBe(result);
185 expect(project({ ...answered, visibleTurnItems: [...rows] })[0]).toBe(result[0]);
186});