1import type {
2 OrchestrationV2ThreadProjection,
3 OrchestrationV2RuntimeRequest,
4 OrchestrationV2UserInputQuestion,
5 ProviderApprovalOption,
6 ProviderRequestKind,
7 RuntimeRequestId,
8} from "@t3tools/contracts";
9import * as DateTime from "effect/DateTime";
10
11export interface ThreadPendingApproval {
12 readonly requestId: RuntimeRequestId;
13 readonly requestKind: ProviderRequestKind;
14 readonly createdAt: string;
15 readonly detail?: string;
16 /** App requesting access for mcp-elicitation approvals (#8058). */
17 readonly appName?: string;
18 /** Approval choices advertised by the provider (#8058); defaults apply when absent. */
19 readonly options?: ReadonlyArray<ProviderApprovalOption>;
20 readonly responseCapability: "live" | "not_resumable";
21}
22
23export interface ThreadUserInputQuestion extends Omit<
24 OrchestrationV2UserInputQuestion,
25 "multiSelect"
26> {
27 readonly multiSelect: boolean;
28}
29
30export interface ThreadPendingUserInput {
31 readonly requestId: RuntimeRequestId;
32 readonly createdAt: string;
33 readonly questions: ReadonlyArray<ThreadUserInputQuestion>;
34 readonly responseCapability: OrchestrationV2RuntimeRequest["responseCapability"]["type"];
35 readonly responseMode?: "message";
36 readonly dismissible: boolean;
37}
38
39export interface PendingThreadRequests {
40 readonly approvals: ReadonlyArray<ThreadPendingApproval>;
41 readonly userInputs: ReadonlyArray<ThreadPendingUserInput>;
42}
43
44/** Seed each question once. Existing edits, cleared answers, and option selections win. */
45export function seedUserInputDraftAnswers<Draft extends { readonly customAnswer?: string }>(
46 questions: ReadonlyArray<
47 Pick<OrchestrationV2UserInputQuestion, "id" | "allowCustomAnswer" | "initialAnswer">
48 >,
49 drafts: Record<string, Draft>,
50): Record<string, Draft | { customAnswer: string }> {
51 let seeded: Record<string, Draft | { customAnswer: string }> = drafts;
52 for (const question of questions) {
53 if (
54 question.initialAnswer === undefined ||
55 question.allowCustomAnswer === false ||
56 seeded[question.id] !== undefined
57 )
58 continue;
59 if (seeded === drafts) seeded = { ...drafts };
60 seeded[question.id] = { customAnswer: question.initialAnswer };
61 }
62 return seeded;
63}
64
65/** Joins pending request entities to the request items that carry display data. */
66export function derivePendingThreadRequests(
67 projection: Pick<OrchestrationV2ThreadProjection, "runtimeRequests" | "turnItems">,
68): PendingThreadRequests {
69 const approvals: ThreadPendingApproval[] = [];
70 const userInputs: ThreadPendingUserInput[] = [];
71
72 for (const request of projection.runtimeRequests) {
73 if (request.status !== "pending") continue;
74 const responseCapability = request.responseCapability.type;
75 if (request.kind === "user_input") {
76 const item = projection.turnItems.findLast(
77 (candidate) =>
78 candidate.type === "user_input_request" && candidate.requestId === request.id,
79 );
80 if (item === undefined || item.type !== "user_input_request") continue;
81 userInputs.push({
82 requestId: request.id,
83 createdAt: DateTime.formatIso(request.createdAt),
84 questions: item.questions.map((question) => ({
85 ...question,
86 multiSelect: question.multiSelect ?? false,
87 })),
88 responseCapability,
89 dismissible: item.responseMode === "message" || responseCapability === "message",
90 ...(item.responseMode === "message" || responseCapability === "message"
91 ? { responseMode: "message" as const }
92 : {}),
93 });
94 continue;
95 }
96
97 if (request.kind === "auth_refresh" || request.kind === "dynamic_tool_call") continue;
98 const item = projection.turnItems.findLast(
99 (candidate) => candidate.type === "approval_request" && candidate.requestId === request.id,
100 );
101 approvals.push({
102 requestId: request.id,
103 requestKind: request.kind,
104 createdAt: DateTime.formatIso(request.createdAt),
105 ...(item?.type === "approval_request" && item.prompt ? { detail: item.prompt } : {}),
106 ...(item?.type === "approval_request" && item.appName ? { appName: item.appName } : {}),
107 ...(item?.type === "approval_request" && item.options !== undefined
108 ? { options: item.options }
109 : {}),
110 responseCapability: responseCapability === "live" ? "live" : "not_resumable",
111 });
112 }
113
114 return { approvals, userInputs };
115}
116
117/** Older V2 answers were saved on requests without updating their timeline items. */
118export function createQuestionHistoryProjector() {
119 type Rows = OrchestrationV2ThreadProjection["visibleTurnItems"];
120 type Requests = OrchestrationV2ThreadProjection["runtimeRequests"];
121 let previousRows: Rows | undefined;
122 let previousRequests: Requests | undefined;
123 let previousResult: Rows | undefined;
124 const cache = new WeakMap<Rows[number], { request: Requests[number]; row: Rows[number] }>();
125 return (
126 projection: Pick<OrchestrationV2ThreadProjection, "visibleTurnItems" | "runtimeRequests">,
127 ): Rows => {
128 const { visibleTurnItems: rows, runtimeRequests: requests } = projection;
129 if (rows === previousRows && requests === previousRequests && previousResult)
130 return previousResult;
131 const byId = new Map(requests.map((request) => [request.id, request]));
132 let result: Rows[number][] | undefined;
133 for (const [index, row] of rows.entries()) {
134 const item = row.item;
135 const request =
136 item.type === "user_input_request" && !item.questionAnswer
137 ? byId.get(item.requestId)
138 : undefined;
139 let next = row;
140 if (
141 item.type === "user_input_request" &&
142 request?.status === "resolved" &&
143 request.answers !== undefined
144 ) {
145 const cached = cache.get(row);
146 next =
147 cached?.request === request
148 ? cached.row
149 : {
150 ...row,
151 item: {
152 ...item,
153 questionAnswer: {
154 requestId: request.id,
155 answers: request.answers,
156 attachmentsByQuestionId: {},
157 questionTextById: Object.fromEntries(
158 item.questions.map((question) => [question.id, question.question]),
159 ),
160 },
161 },
162 };
163 cache.set(row, { request, row: next });
164 }
165 if (next !== row) result ??= rows.slice(0, index);
166 result?.push(next);
167 }
168 previousRows = rows;
169 previousRequests = requests;
170 previousResult = result ?? rows;
171 return previousResult;
172 };
173}