Skip to content
Source
Sign inSign up

threadRequests.test.ts

760 lines25.0 KB

1import { presentThreadShell } from "./models.ts";
2import { threadKey } from "./entities.ts";
3import {
4 EnvironmentId,
5 EventId,
6 MessageId,
7 ThreadId,
8 type ScopedThreadRef,
9 NodeId,
10 ProviderDriverKind,
11 ProviderSessionId,
12 ProviderTurnId,
13 RuntimeRequestId,
14 TurnItemId,
15 type OrchestrationV2TurnItem,
16 type OrchestrationV2ThreadProjection,
17} from "@t3tools/contracts";
18import { describe, expect, it, vi } from "vite-plus/test";
19import * as DateTime from "effect/DateTime";
20import { projectTurnItemForWire } from "@t3tools/shared/wireProjection";
21import { Atom, AtomRegistry, AsyncResult } from "effect/reactivity";
22import * as Cause from "effect/Cause";
23import { applyOrchestrationV2ProjectionEvent } from "./orchestrationV2Projection.ts";
24import { v2Now, v2Projection, v2ThreadShell } from "./orchestrationV2TestFixtures.ts";
25import {
26 createQuestionHistoryProjector,
27 createChildThreadApprovalsAtom,
28 type PendingThreadRequests,
29 derivePendingThreadRequests,
30 declinePendingApprovals,
31 sendApprovalFeedback,
32 makeThreadRequestsResolvedAnnouncer,
33 onThreadRequestsResolved,
34 pruneResolvedUserInputDrafts,
35 resolvedQuestionDraftKeys,
36 seedUserInputDraftAnswers,
37 userInputDraftKey,
38 type ApprovalResponder,
39 type ThreadRequestsResolved,
40 type ThreadPendingApproval,
41} from "./threadRequests.ts";
42​
43const requestId = RuntimeRequestId.make("async-question");
44const nodeId = NodeId.make("async-question-node");
45​
46describe("declining approvals before sending", () => {
47 const approval: ThreadPendingApproval = {
48 requestId,
49 requestKind: "command",
50 createdAt: "2026-10-19T00:00:00Z",
51 responseCapability: "live",
52 };
53 const second: ThreadPendingApproval = {
54 ...approval,
55 requestId: RuntimeRequestId.make("second-approval"),
56 };
57​
58 it("declines every pending approval before the send continues", async () => {
59 let resolve!: (value: Awaited<ReturnType<ApprovalResponder>>) => void;
60 const firstResponse = new Promise<Awaited<ReturnType<ApprovalResponder>>>((done) => {
61 resolve = done;
62 });
63 const respond = vi.fn<ApprovalResponder>(async (id) =>
64 id === requestId ? firstResponse : AsyncResult.success(undefined),
65 );
66 const send = vi.fn();
67 const submission = (async () => {
68 if (await declinePendingApprovals([approval, second], respond)) send();
69 })();
70 expect(respond).toHaveBeenCalledWith(requestId, "decline");
71 expect(send).not.toHaveBeenCalled();
72 resolve(AsyncResult.success(undefined));
73 await submission;
74 expect(respond).toHaveBeenCalledWith(second.requestId, "decline");
75 expect(send).toHaveBeenCalledOnce();
76 });
77​
78 it.each([AsyncResult.failure(Cause.fail("decline failed")), undefined])(
79 "does not send when a decline is unsuccessful: %s",
80 async (response) => {
81 const send = vi.fn();
82 if (await declinePendingApprovals([approval], async () => response)) send();
83 expect(send).not.toHaveBeenCalled();
84 },
85 );
86​
87 it("skips approvals the provider can no longer answer", async () => {
88 const respond = vi.fn(async () => AsyncResult.success(undefined));
89 expect(
90 await declinePendingApprovals(
91 [{ ...approval, responseCapability: "not_resumable" }],
92 respond,
93 ),
94 ).toBe(true);
95 expect(respond).not.toHaveBeenCalled();
96 });
97​
98 it("sends feedback only to the displayed request, leaving other approvals pending", async () => {
99 const respond = vi.fn<ApprovalResponder>(async () => AsyncResult.success(undefined));
100 const pending = derivePendingThreadRequests({
101 runtimeRequests: [
102 {
103 ...projection.runtimeRequests[0]!,
104 kind: "command",
105 responseCapability: {
106 type: "live",
107 providerSessionId: ProviderSessionId.make("claude-session"),
108 supportsFeedback: true,
109 },
110 },
111 { ...projection.runtimeRequests[0]!, id: second.requestId, kind: "command" },
112 ],
113 turnItems: [],
114 });
115 expect(await sendApprovalFeedback(pending.approvals[0]!, " Safer please. ", respond)).toBe(
116 true,
117 );
118 expect(respond.mock.calls).toEqual([[approval.requestId, "decline", "Safer please."]]);
119 });
120​
121 it("keeps feedback unsent when unsupported, stale, empty or rejected", async () => {
122 const respond = vi.fn<ApprovalResponder>(async () => AsyncResult.failure(Cause.fail("failed")));
123 expect(await sendApprovalFeedback(approval, "Safer please.", respond)).toBe(false);
124 expect(
125 await sendApprovalFeedback(
126 { ...approval, supportsFeedback: true, responseCapability: "not_resumable" },
127 "Safer please.",
128 respond,
129 ),
130 ).toBe(false);
131 expect(await sendApprovalFeedback({ ...approval, supportsFeedback: true }, " ", respond)).toBe(
132 false,
133 );
134 expect(respond).not.toHaveBeenCalled();
135 expect(
136 await sendApprovalFeedback({ ...approval, supportsFeedback: true }, "Safer please.", respond),
137 ).toBe(false);
138 });
139});
140​
141describe("pruning resolved user input drafts", () => {
142 const answered = userInputDraftKey("env", "thread", "answered");
143 const pending = userInputDraftKey("env", "thread", "pending");
144 const otherThread = userInputDraftKey("env", "thread-2", "answered");
145 const otherEnvironment = userInputDraftKey("env-2", "thread", "answered");
146 const drafts = {
147 [answered]: 1,
148 [pending]: 2,
149 [otherThread]: 3,
150 [otherEnvironment]: 4,
151 };
152​
153 it("drops only the thread's requests that are no longer pending", () => {
154 expect(pruneResolvedUserInputDrafts(drafts, "env", "thread", ["pending"])).toEqual({
155 [pending]: 2,
156 [otherThread]: 3,
157 [otherEnvironment]: 4,
158 });
159 });
160​
161 it("returns the same record when nothing resolved", () => {
162 expect(pruneResolvedUserInputDrafts(drafts, "env", "thread", ["answered", "pending"])).toBe(
163 drafts,
164 );
165 });
166});
167​
168describe("resolved question draft keys", () => {
169 const prefix = "env:question-thread-";
170 const key = (request: string, question: string) =>
171 `${prefix}${encodeURIComponent(JSON.stringify([request, question]))}`;
172​
173 it("returns the thread's keys for requests that are no longer pending", () => {
174 const keys = [
175 key("pending", "first"),
176 key("pending", "second"),
177 key("pending-2", "first"),
178 key("answered", "first"),
179 "env:question-other-thread-x",
180 ];
181 expect(resolvedQuestionDraftKeys(keys, prefix, [RuntimeRequestId.make("pending")])).toEqual([
182 key("pending-2", "first"),
183 key("answered", "first"),
184 ]);
185 });
186});
187​
188describe("announcing resolved thread requests", () => {
189 const environmentId = EnvironmentId.make("env");
190 const threadId = v2Projection.thread.id;
191 const first = RuntimeRequestId.make("first");
192 const second = RuntimeRequestId.make("second");
193​
194 function record() {
195 const events: ThreadRequestsResolved[] = [];
196 const unsubscribe = onThreadRequestsResolved((event) => events.push(event));
197 return { events, [Symbol.dispose]: unsubscribe };
198 }
199​
200 it("announces the first projection, then only when a request leaves pending", () => {
201 using recorder = record();
202 const announce = makeThreadRequestsResolvedAnnouncer(environmentId, threadId);
203​
204 const pending = (id: RuntimeRequestId) => ({ id, status: "pending" as const });
205 announce([pending(first)]);
206 announce([pending(first), pending(second)]);
207 announce([pending(first), pending(second)]);
208 announce([{ id: first, status: "cancelled" }, pending(second)]);
209 announce([
210 { id: first, status: "cancelled" },
211 { id: second, status: "resolved" },
212 ]);
213​
214 expect(recorder.events).toEqual([
215 { environmentId, threadId, pendingRequestIds: [first], unansweredRequestIds: [] },
216 { environmentId, threadId, pendingRequestIds: [second], unansweredRequestIds: [first] },
217 { environmentId, threadId, pendingRequestIds: [], unansweredRequestIds: [first] },
218 ]);
219 });
220});
221​
222describe("initial user input drafts", () => {
223 const questions = [
224 { id: "editor", initialAnswer: " Proposed commit\n\n" },
225 { id: "empty", initialAnswer: "" },
226 { id: "ordinary" },
227 { id: "choices", initialAnswer: "Ignore this", allowCustomAnswer: false },
228 ];
229​
230 it("seeds editable answers and preserves edits and selections on later snapshots", () => {
231 const seeded = seedUserInputDraftAnswers(questions, {});
232 expect(seeded).toEqual({
233 editor: { customAnswer: " Proposed commit\n\n" },
234 empty: { customAnswer: "" },
235 });
236 const edits = {
237 ...seeded,
238 editor: { customAnswer: "" },
239 empty: { selectedOptionValues: ["empty"], customAnswer: "" },
240 };
241 expect(seedUserInputDraftAnswers(questions, edits)).toEqual(edits);
242 expect(
243 seedUserInputDraftAnswers(questions, { editor: { customAnswer: "Edited commit" } }).editor,
244 ).toEqual({ customAnswer: "Edited commit" });
245 expect(seedUserInputDraftAnswers(questions, {}).editor).toEqual({
246 customAnswer: " Proposed commit\n\n",
247 });
248 });
249​
250 it("seeds a question with the id __proto__ as its own draft", () => {
251 const seeded = seedUserInputDraftAnswers(
252 [
253 { id: "__proto__", initialAnswer: "Proposed" },
254 { id: "name", initialAnswer: "Control" },
255 ],
256 {},
257 );
258​
259 expect(JSON.stringify(seeded)).toBe(
260 '{"__proto__":{"customAnswer":"Proposed"},"name":{"customAnswer":"Control"}}',
261 );
262 });
263});
264const projection: OrchestrationV2ThreadProjection = {
265 ...v2Projection,
266 runtimeRequests: [
267 {
268 id: requestId,
269 nodeId,
270 providerTurnId: null,
271 nativeRequestRef: null,
272 kind: "user_input",
273 status: "pending",
274 responseCapability: { type: "message" },
275 createdAt: v2Now,
276 resolvedAt: null,
277 },
278 ],
279 turnItems: [
280 {
281 id: TurnItemId.make("async-question-item"),
282 threadId: v2Projection.thread.id,
283 runId: null,
284 nodeId,
285 providerThreadId: null,
286 providerTurnId: null,
287 nativeItemRef: null,
288 parentItemId: null,
289 ordinal: 0,
290 status: "completed",
291 title: null,
292 startedAt: v2Now,
293 completedAt: v2Now,
294 updatedAt: v2Now,
295 type: "user_input_request",
296 requestId,
297 responseMode: "message",
298 questions: [
299 {
300 id: "next",
301 header: "Next",
302 question: "What should happen next?",
303 required: true,
304 allowCustomAnswer: false,
305 options: [{ label: "Continue", value: " continue ", description: "Resume work" }],
306 },
307 ],
308 },
309 ],
310};
311​
312describe("pending v2 questions", () => {
313 it("keeps message responses available after the originating runtime exits", () => {
314 expect(projection.providerSessions).toEqual([]);
315 expect(derivePendingThreadRequests(projection).userInputs).toEqual([
316 {
317 requestId,
318 createdAt: "2026-06-20T00:00:00.000Z",
319 responseCapability: "message",
320 responseMode: "message",
321 dismissible: true,
322 questions: [
323 {
324 id: "next",
325 header: "Next",
326 question: "What should happen next?",
327 required: true,
328 allowCustomAnswer: false,
329 multiSelect: false,
330 options: [{ label: "Continue", value: " continue ", description: "Resume work" }],
331 },
332 ],
333 },
334 ]);
335 });
336​
337 it("only marks asynchronous questions as dismissible", () => {
338 const live = {
339 ...projection,
340 runtimeRequests: projection.runtimeRequests.map((request) => ({
341 ...request,
342 responseCapability: {
343 type: "live" as const,
344 providerSessionId: ProviderSessionId.make("live-session"),
345 },
346 })),
347 turnItems: projection.turnItems.map((item) =>
348 item.type === "user_input_request" ? { ...item, responseMode: undefined } : item,
349 ),
350 };
351 expect(derivePendingThreadRequests(live).userInputs[0]?.dismissible).toBe(false);
352 });
353});
354​
355it("restores old text answers without mutating history or replacing unchanged rows", () => {
356 const project = createQuestionHistoryProjector();
357 const item = projection.turnItems[0]!;
358 const row = {
359 item,
360 position: 0,
361 sourceItemId: item.id,
362 sourceThreadId: item.threadId,
363 visibility: "local" as const,
364 };
365 const rows = [row];
366 const pending = { visibleTurnItems: rows, runtimeRequests: projection.runtimeRequests };
367 expect(project(pending)).toBe(rows);
368 const answered = {
369 ...pending,
370 runtimeRequests: [
371 {
372 ...projection.runtimeRequests[0]!,
373 status: "resolved" as const,
374 answers: { next: "Continue" },
375 },
376 ],
377 };
378 const result = project(answered);
379 expect(result[0]?.item).toMatchObject({
380 questionAnswer: {
381 answers: { next: "Continue" },
382 attachmentsByQuestionId: {},
383 questionTextById: { next: "What should happen next?" },
384 },
385 });
386 expect(row.item).not.toHaveProperty("questionAnswer");
387 expect(project(answered)).toBe(result);
388 expect(project({ ...answered, visibleTurnItems: [...rows] })[0]).toBe(result[0]);
389});
390​
391type QuestionItem = Extract<
392 OrchestrationV2ThreadProjection["turnItems"][number],
393 { readonly type: "user_input_request" }
394>;
395​
396describe("question history through exact row replacements", () => {
397 const baseItem = {
398 threadId: v2Projection.thread.id,
399 runId: null,
400 nodeId,
401 providerThreadId: null,
402 providerTurnId: null,
403 nativeItemRef: null,
404 parentItemId: null,
405 status: "running" as const,
406 title: null,
407 startedAt: v2Now,
408 completedAt: null,
409 updatedAt: v2Now,
410 };
411 const assistantItem = (ordinal: number, text = "Hel") => ({
412 ...baseItem,
413 id: TurnItemId.make(`assistant-${ordinal}`),
414 ordinal,
415 type: "assistant_message" as const,
416 messageId: MessageId.make(`message-${ordinal}`),
417 text,
418 streaming: true,
419 });
420 const localRow = (
421 item: OrchestrationV2ThreadProjection["turnItems"][number],
422 position: number,
423 ) => ({
424 item,
425 position,
426 sourceItemId: item.id,
427 sourceThreadId: item.threadId,
428 visibility: "local" as const,
429 });
430 const updated = (
431 current: OrchestrationV2ThreadProjection,
432 item: OrchestrationV2ThreadProjection["turnItems"][number],
433 ) =>
434 applyOrchestrationV2ProjectionEvent(current, {
435 id: EventId.make(`event-${item.id}`),
436 type: "turn-item.updated",
437 threadId: current.thread.id,
438 occurredAt: v2Now,
439 payload: item,
440 })!;
441​
442 it("reads only the replaced row and leaves the request index alone", () => {
443 const size = 1_000;
444 const counts = { items: 0, requestIds: 0 };
445 const items = Array.from({ length: size }, (_, index) => assistantItem(index));
446 const countedRows = items.map((item, index) => {
447 const row = localRow(item, index);
448 return Object.defineProperty(row, "item", {
449 enumerable: true,
450 get: () => {
451 counts.items += 1;
452 return item;
453 },
454 });
455 });
456 const countedRequests = Array.from({ length: 400 }, (_, index) => {
457 const request = {
458 ...projection.runtimeRequests[0]!,
459 id: RuntimeRequestId.make(`request-${index}`),
460 };
461 return Object.defineProperty(request, "id", {
462 enumerable: true,
463 get: () => {
464 counts.requestIds += 1;
465 return RuntimeRequestId.make(`request-${index}`);
466 },
467 });
468 });
469 const initial: OrchestrationV2ThreadProjection = {
470 ...v2Projection,
471 turnItems: items,
472 visibleTurnItems: countedRows,
473 runtimeRequests: countedRequests,
474 };
475 const project = createQuestionHistoryProjector();
476 const scan = (current: OrchestrationV2ThreadProjection) => {
477 counts.items = 0;
478 counts.requestIds = 0;
479 const result = project(current);
480 return { result, ...counts };
481 };
482 expect(scan(initial)).toMatchObject({ items: size, requestIds: 400 });
483​
484 const adjacent = updated(initial, assistantItem(size - 1, "Hello"));
485 const hinted = scan(adjacent);
486 expect(hinted.result).toBe(adjacent.visibleTurnItems);
487 expect(hinted).toMatchObject({ items: 0, requestIds: 0 });
488​
489 // A skipped delta has no hint for this exact pair, so it scans again.
490 const skipped = updated(
491 updated(adjacent, assistantItem(size - 1, "Hello t")),
492 assistantItem(size - 1, "Hello there"),
493 );
494 const fallback = scan(skipped);
495 expect(fallback.result).toBe(skipped.visibleTurnItems);
496 expect(fallback).toMatchObject({ items: size - 1, requestIds: 0 });
497 });
498​
499 it("matches a full projection through streamed rows, request changes and answers", () => {
500 const question = projection.turnItems[0] as QuestionItem;
501 const resolved = (answers: Record<string, string>) => ({
502 ...projection.runtimeRequests[0]!,
503 status: "resolved" as const,
504 answers,
505 });
506 const explicitRequestId = RuntimeRequestId.make("explicit-question");
507 const explicitAnswer = {
508 requestId: explicitRequestId,
509 answers: { next: "Explicit" },
510 attachmentsByQuestionId: {},
511 questionTextById: { next: "What should happen next?" },
512 };
513 const unanswered: QuestionItem = {
514 ...question,
515 id: TurnItemId.make("explicit-question-item"),
516 ordinal: 1,
517 requestId: explicitRequestId,
518 };
519 const explicit: QuestionItem = { ...unanswered, questionAnswer: explicitAnswer };
520 const explicitRequest = {
521 ...resolved({ next: "From request" }),
522 id: explicitRequestId,
523 };
524 const stream = assistantItem(2);
525 const inherited = {
526 ...localRow({ ...question, id: TurnItemId.make("inherited-question-item") }, 3),
527 visibility: "inherited" as const,
528 sourceThreadId: ThreadId.make("parent-thread"),
529 };
530 const turnItems = [question, explicit, stream];
531 let current: OrchestrationV2ThreadProjection = {
532 ...v2Projection,
533 turnItems,
534 visibleTurnItems: [...turnItems.map(localRow), inherited],
535 runtimeRequests: [projection.runtimeRequests[0]!, explicitRequest],
536 };
537 const project = createQuestionHistoryProjector();
538 const step = (next: OrchestrationV2ThreadProjection) => {
539 current = next;
540 const result = project(next);
541 expect(result).toEqual(createQuestionHistoryProjector()(next));
542 return result;
543 };
544 const answerOf = (rows: ReadonlyArray<{ readonly item: unknown }>, index: number) =>
545 (rows[index]!.item as { readonly questionAnswer?: { readonly answers: unknown } })
546 .questionAnswer?.answers;
547​
548 expect(step(current)).toBe(current.visibleTurnItems);
549 expect(step(updated(current, assistantItem(2, "Hello")))).toBe(current.visibleTurnItems);
550​
551 // The last duplicate request wins, and every row asking it gets the answer.
552 const answered = step({
553 ...current,
554 runtimeRequests: [
555 projection.runtimeRequests[0]!,
556 resolved({ next: "Continue" }),
557 explicitRequest,
558 ],
559 });
560 expect(answerOf(answered, 0)).toEqual({ next: "Continue" });
561 expect(answerOf(answered, 1)).toEqual({ next: "Explicit" });
562 expect(answerOf(answered, 3)).toEqual({ next: "Continue" });
563​
564 const whileOverlaid = step(updated(current, assistantItem(2, "Hello there")));
565 expect(whileOverlaid[0]).toBe(answered[0]);
566 expect(whileOverlaid[3]).toBe(answered[3]);
567​
568 const changedAnswer = step({
569 ...current,
570 runtimeRequests: [resolved({ next: "Stop" }), explicitRequest],
571 });
572 expect(answerOf(changedAnswer, 0)).toEqual({ next: "Stop" });
573​
574 const reopened = step({
575 ...current,
576 runtimeRequests: [projection.runtimeRequests[0]!, explicitRequest],
577 });
578 expect(reopened).toBe(current.visibleTurnItems);
579​
580 // Dropping an explicit answer falls back to its resolved request.
581 const restored = step(updated(current, unanswered));
582 expect(answerOf(restored, 1)).toEqual({ next: "From request" });
583​
584 const retitled: QuestionItem = {
585 ...unanswered,
586 questions: [{ ...question.questions[0]!, question: "Now what?" }],
587 };
588 const retitledRows = step(updated(current, retitled));
589 expect(retitledRows[1]!.item).toMatchObject({
590 questionAnswer: { questionTextById: { next: "Now what?" } },
591 });
592 });
593});
594​
595it("loads only child approvals and retains their destination across environments", () => {
596 const local = EnvironmentId.make("local");
597 const remote = EnvironmentId.make("remote");
598 const parent = { environmentId: local, threadId: ThreadId.make("parent") };
599 const childId = ThreadId.make("child");
600 const children = Atom.make(
601 [local, remote].map((environmentId) => ({
602 ...presentThreadShell(environmentId, v2ThreadShell),
603 id: childId,
604 title: environmentId,
605 hasPendingApprovals: true,
606 })),
607 );
608 const details = Atom.family((key: string) =>
609 Atom.make<PendingThreadRequests | null>({
610 approvals: [
611 {
612 requestId: RuntimeRequestId.make(key),
613 requestKind: "command",
614 createdAt: "2026-10-19T00:00:00Z",
615 responseCapability: "live",
616 },
617 ],
618 userInputs: [],
619 }),
620 );
621 const readDetails = vi.fn((ref: ScopedThreadRef) => details(threadKey(ref)));
622 const approvalsAtom = createChildThreadApprovalsAtom({
623 childThreadInputsAtom: () => children,
624 pendingRequestsAtom: readDetails,
625 });
626 const registry = AtomRegistry.make();
627 const unmount = registry.mount(approvalsAtom(parent));
628 const pending = registry.get(approvalsAtom(parent));
629 expect(pending.map((entry) => entry.thread)).toEqual(
630 [local, remote].map((environmentId) => ({ environmentId, threadId: childId })),
631 );
632 expect(pending.map((entry) => entry.title)).toEqual(["local", "remote"]);
633 registry.set(details(threadKey({ environmentId: remote, threadId: childId })), {
634 approvals: [],
635 userInputs: [],
636 });
637 expect(registry.get(approvalsAtom(parent))).toEqual([pending[0]]);
638 readDetails.mockClear();
639 registry.set(
640 children,
641 registry
642 .get(children)
643 .map((child) => ({ ...child, hasPendingApprovals: false, hasPendingUserInput: true })),
644 );
645 expect(registry.get(approvalsAtom(parent))).toEqual([]);
646 expect(readDetails).not.toHaveBeenCalled();
647 unmount();
648 registry.dispose();
649});
650​
651describe("pending v2 approvals", () => {
652 it.each([false, true])("lists approval files and their detail source (wire: %s)", (wire) => {
653 const approvalId = RuntimeRequestId.make("file-approval");
654 const approvalNode = NodeId.make("file-approval-node");
655 const nativeItemRef = {
656 driver: ProviderDriverKind.make("codex"),
657 nativeId: "item-7",
658 strength: "strong" as const,
659 };
660 const base = {
661 threadId: v2Projection.thread.id,
662 runId: null,
663 nodeId,
664 providerThreadId: null,
665 providerTurnId: null,
666 parentItemId: null,
667 ordinal: 0,
668 status: "completed" as const,
669 title: null,
670 startedAt: v2Now,
671 completedAt: v2Now,
672 updatedAt: v2Now,
673 };
674 const approvals = derivePendingThreadRequests({
675 runtimeRequests: [
676 {
677 ...projection.runtimeRequests[0]!,
678 id: approvalId,
679 nodeId: approvalNode,
680 kind: "file-change",
681 responseCapability: {
682 type: "live",
683 providerSessionId: ProviderSessionId.make("live-session"),
684 },
685 },
686 ],
687 turnItems: [
688 (wire ? projectTurnItemForWire : (item: OrchestrationV2TurnItem) => item)({
689 ...base,
690 id: TurnItemId.make("file-change-item"),
691 nativeItemRef,
692 type: "file_change",
693 fileName: "src/app.ts",
694 additions: 4,
695 deletions: 1,
696 diffStr: "@@ -1 +1 @@\n-a\n+b",
697 }),
698 {
699 ...base,
700 id: TurnItemId.make("multi-file-item"),
701 nativeItemRef,
702 type: "file_change",
703 fileName: "src/one.ts",
704 changes: [
705 { path: "src/one.ts", operation: "update" },
706 { path: "src/two.ts", operation: "add" },
707 ],
708 },
709 {
710 ...base,
711 id: TurnItemId.make("other-file-change-item"),
712 nativeItemRef: { ...nativeItemRef, nativeId: "item-8" },
713 type: "file_change",
714 fileName: "src/other.ts",
715 },
716 {
717 ...base,
718 id: TurnItemId.make("previous-turn-file-change"),
719 providerTurnId: ProviderTurnId.make("previous-turn"),
720 nativeItemRef,
721 type: "file_change",
722 fileName: "src/previous.ts",
723 },
724 {
725 ...base,
726 id: TurnItemId.make("file-approval-item"),
727 nodeId: approvalNode,
728 nativeItemRef,
729 type: "approval_request",
730 requestId: approvalId,
731 requestKind: "file-change",
732 prompt: "update src/app.ts",
733 },
734 ],
735 }).approvals;
736 expect(approvals).toHaveLength(1);
737 expect(approvals[0]?.files).toEqual([
738 {
739 path: "src/app.ts",
740 source: {
741 threadId: v2Projection.thread.id,
742 itemId: TurnItemId.make("file-change-item"),
743 revision: DateTime.formatIso(v2Now),
744 },
745 additions: 4,
746 deletions: 1,
747 ...(wire ? {} : { diff: "@@ -1 +1 @@\n-a\n+b" }),
748 },
749 ...["src/one.ts", "src/two.ts"].map((path, index) => ({
750 path,
751 operation: index === 0 ? "update" : "add",
752 source: {
753 threadId: v2Projection.thread.id,
754 itemId: TurnItemId.make("multi-file-item"),
755 revision: DateTime.formatIso(v2Now),
756 },
757 })),
758 ]);
759 });
760});