@truefoundry/trueforge-assistant-ui-runtime 0.0.0 → 0.2.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/LICENSE +201 -0
  3. package/README.md +146 -4
  4. package/dist/chunk-2SQK6TIO.js +104 -0
  5. package/dist/chunk-2SQK6TIO.js.map +1 -0
  6. package/dist/index.d.ts +378 -0
  7. package/dist/index.js +4391 -0
  8. package/dist/index.js.map +1 -0
  9. package/dist/server/index.d.ts +1210 -0
  10. package/dist/server/index.js +9 -0
  11. package/dist/server/index.js.map +1 -0
  12. package/package.json +79 -16
  13. package/src/askUserQuestion.ts +38 -0
  14. package/src/attachmentAdapter.ts +63 -0
  15. package/src/collectPending.ts +167 -0
  16. package/src/constants.ts +2 -0
  17. package/src/convertTurnMessages.ts +1679 -0
  18. package/src/createSubAgent.ts +11 -0
  19. package/src/draft/agentSpec.ts +34 -0
  20. package/src/draft/draftSessionBridge.ts +28 -0
  21. package/src/draft/trueforgeDraftThreadListAdapter.ts +73 -0
  22. package/src/draft/useDraftAgentSpec.ts +289 -0
  23. package/src/extractTurnUserText.ts +23 -0
  24. package/src/foldPeerThreads.ts +553 -0
  25. package/src/hooks.ts +176 -0
  26. package/src/index.ts +227 -0
  27. package/src/lastUserMessageText.ts +19 -0
  28. package/src/listPages.ts +19 -0
  29. package/src/loadSessionSnapshot.ts +34 -0
  30. package/src/mcpAuth.ts +35 -0
  31. package/src/messageCustomMetadata.ts +50 -0
  32. package/src/modelMessageContent.ts +149 -0
  33. package/src/modelMessageImageContent.ts +154 -0
  34. package/src/requiredActionInputs.ts +38 -0
  35. package/src/sandboxDownload.ts +33 -0
  36. package/src/server/eventUtils.ts +125 -0
  37. package/src/server/events.ts +232 -0
  38. package/src/server/index.ts +178 -0
  39. package/src/server/types.ts +1191 -0
  40. package/src/sessionListStartTimestamp.ts +6 -0
  41. package/src/sessionSnapshot.ts +146 -0
  42. package/src/sessionThreadMetadata.ts +36 -0
  43. package/src/sessions.ts +17 -0
  44. package/src/streamTurn.ts +118 -0
  45. package/src/toolApproval.ts +413 -0
  46. package/src/toolResponse.ts +346 -0
  47. package/src/trueforgeExtras.ts +223 -0
  48. package/src/trueforgeOwnedSessionsThreadListAdapter.ts +71 -0
  49. package/src/trueforgeThreadListAdapter.ts +69 -0
  50. package/src/turnEventHelpers.ts +71 -0
  51. package/src/turnStreamUpdate.ts +11 -0
  52. package/src/types.ts +84 -0
  53. package/src/useTrueForgeAgentMessages.ts +1138 -0
  54. package/src/useTrueForgeAgentRuntime.ts +308 -0
  55. package/index.js +0 -6
@@ -0,0 +1,1679 @@
1
+ import type {
2
+ AppendMessage,
3
+ CompleteAttachment,
4
+ ExportedMessageRepositoryItem,
5
+ MessageStatus,
6
+ ThreadMessage,
7
+ ThreadUserMessagePart,
8
+ } from '@assistant-ui/core';
9
+ import type {
10
+ McpAuthRequiredEvent,
11
+ Turn,
12
+ TurnCreatedEvent,
13
+ TurnEvent,
14
+ TurnInputItem,
15
+ TurnStreamData,
16
+ } from './server/index.js';
17
+ import type { AgentChatServer } from './server/types.js';
18
+
19
+ import { ROOT_THREAD_ID } from './constants.js';
20
+ import { extractTurnUserText } from './extractTurnUserText.js';
21
+ import {
22
+ buildRootAssistantContent,
23
+ buildRootAssistantContentForIds,
24
+ findFirstPendingApprovalThreadId,
25
+ findFirstPendingResponseThreadId,
26
+ ingestStreamEvent,
27
+ ingestTurnEvent,
28
+ PeerThreadFoldState,
29
+ type ThreadBucket,
30
+ } from './foldPeerThreads.js';
31
+ import { drainListPages } from './listPages.js';
32
+ import { buildMcpAuthTextParts, mcpAuthAssistantStatus, mcpAuthMessageCustom } from './mcpAuth.js';
33
+ import type { AssistantContentPart } from './modelMessageContent.js';
34
+ import { extractImageUrlFromUserContentItem, imageUrlToAttachment } from './modelMessageImageContent.js';
35
+ import {
36
+ createEmptySessionSnapshot,
37
+ replaceSessionSnapshot,
38
+ sessionEventsToSessionRecord,
39
+ turnToSessionRecord,
40
+ type GatewaySessionEventItem,
41
+ type ProjectSessionMessagesOptions,
42
+ type RequiredActionsOverlay,
43
+ type SessionHistoryPagination,
44
+ type SessionSnapshot,
45
+ type SessionTurnRecord,
46
+ } from './sessionSnapshot.js';
47
+ import {
48
+ applyApprovalDecisionsToContent,
49
+ collectApprovalDecisionsFromTurnInput,
50
+ collectSubsequentApprovalDecisions,
51
+ messageHasPendingApprovals,
52
+ TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY,
53
+ toolApprovalMessageCustom,
54
+ toolApprovalStatus,
55
+ } from './toolApproval.js';
56
+ import {
57
+ applyStagedResponsesToContent,
58
+ applyUserToolResponsesToFold,
59
+ collectSubsequentToolResponses,
60
+ collectToolResponsesFromTurnInput,
61
+ messageHasPendingResponses,
62
+ TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY,
63
+ toolResponseMessageCustom,
64
+ toolResponseStatus,
65
+ } from './toolResponse.js';
66
+ import { appendMcpAuthToTurnContent, appendToolApprovalToTurnContent } from './turnEventHelpers.js';
67
+ import type { TurnStreamUpdate } from './turnStreamUpdate.js';
68
+
69
+ /**
70
+ * Turn / event → assistant-ui normalization
71
+ *
72
+ * This module is the projection layer: it turns gateway session state into
73
+ * `ThreadMessage[]` for `@assistant-ui/core`. The hook in
74
+ * `useTrueForgeAgentMessages.ts` owns the mutable `SessionSnapshot`; this
75
+ * file defines how that snapshot is built and rendered.
76
+ *
77
+ * ## Data model
78
+ *
79
+ * Gateway side:
80
+ * - A **session** has ordered **turns** (each with `input`, `state`, `listEvents`).
81
+ * - A turn's **input** is either a user message (`user.message`) or a continuation
82
+ * (`user.tool_response`, `user.tool_approval`, …).
83
+ * - **Events** (`model.message`, `tool.*`, `thread.created`, …) arrive on a
84
+ * **thread id**; the root conversation uses `ROOT_THREAD_ID` (`"main"`).
85
+ *
86
+ * Local side (`SessionSnapshot` in `sessionSnapshot.ts`):
87
+ * - `fold` — accumulated events across all threads (see `foldPeerThreads.ts`).
88
+ * - `turns` — committed turn records, each optionally storing
89
+ * `rootModelMessageIds` (root-thread `model.message` ids ingested with that turn).
90
+ * - `pendingUser` / `activeStream` — optimistic UI while a turn is in flight.
91
+ * - `groupRootBaseline` — root `model.message` ids that existed before the active
92
+ * turn group started; used to scope live streaming to the current group only.
93
+ * - `requiredActions` — locally staged approval/response decisions before resume.
94
+ *
95
+ * Output:
96
+ * - Alternating **user** / **assistant** `ThreadMessage` pairs.
97
+ * - One assistant message per **turn group** (user turn + its continuation turns).
98
+ *
99
+ * ## Turn groups
100
+ *
101
+ * A **turn group** starts at a turn whose input contains `user.message`. Later
102
+ * turns with only continuation inputs (tool response, tool approval, MCP resume)
103
+ * belong to the same group and are folded into the same assistant message.
104
+ *
105
+ * Root content for a group is the union of `rootModelMessageIds` on every turn
106
+ * in that group. Prior groups must not leak in: scope with
107
+ * `rootModelMessageIdsSinceBaseline(fold, baseline)` where
108
+ * `baseline = groupRootBaseline ?? computeGroupRootBaseline(turns)`.
109
+ *
110
+ * ## Pipeline
111
+ *
112
+ * 1. **Ingest** — `ingestStreamEvent` / `ingestTurnEvent` append events into
113
+ * per-thread buckets in `PeerThreadFoldState`. Deltas merge in place.
114
+ *
115
+ * 2. **Fold to content** — `buildRootAssistantContentForIds` walks scoped
116
+ * `model.message` ids and emits assistant-ui parts (reasoning, text, tool-call).
117
+ * Sub-agent child threads are attached under `create_sub_agent` tool-calls as
118
+ * nested `messages` (see `attachSubAgentMessages` in `foldPeerThreads.ts`).
119
+ *
120
+ * 3. **History projection** — `projectHistoryTurns` walks committed `turns`:
121
+ * - User turn → push user message; push assistant if the group has content.
122
+ * - Continuation turn → merge assistant content into the last assistant message
123
+ * in the group (same `*-assistant` id as the user turn that opened the group).
124
+ * - Apply answers from later continuation turns onto earlier tool-calls via
125
+ * `collectSubsequentApprovalDecisions` / `collectSubsequentToolResponses`.
126
+ * - A paused group's assistant message keeps `requires-action` until a later
127
+ * continuation turn resolves all its approvals/responses, then downgrades
128
+ * to the turn-state status (`complete`, `error`, `cancelled`).
129
+ *
130
+ * 4. **Live projection** — `projectSessionMessages` = history + `pendingUser` +
131
+ * `activeStream`. While streaming, `streamTurnEvents` yields content scoped to
132
+ * `groupRootBaseline`. When `streamComplete`, `projectActiveStreamUpdate`
133
+ * rebuilds from the fold (same baseline scoping as streaming).
134
+ *
135
+ * 5. **Staged overlay** — Before the SDK resume turn is sent, user decisions sit
136
+ * in `requiredActions` and are merged onto messages by
137
+ * `applyRequiredActionsOverlayToMessages` so the UI shows interrupt + result
138
+ * together. Resume is batched: all pending approvals and ask-user answers in
139
+ * a paused message must be resolved before `sendTurn({ inputs })`.
140
+ *
141
+ * ## Required actions (assistant-ui mapping)
142
+ *
143
+ * | Gateway event / input | assistant-ui representation |
144
+ * |-----------------------------|------------------------------------------------------|
145
+ * | `tool.approval_required` | `tool-call` with `approval: { id }`, status |
146
+ * | | `requires-action` / `tool-calls`, custom thread id |
147
+ * | `user.tool_approval` | closes `tool.approval_required`: sets `approval.approved` |
148
+ * | | (+ optional reason) on tool-call |
149
+ * | `tool.response_required` | `tool-call` with `interrupt: { type: "human", … }` |
150
+ * | (ask_user_question) | payload parsed from tool args (`askUserQuestion.ts`) |
151
+ * | `user.tool_response` | closes `tool.response_required`: sets `result` on |
152
+ * | | tool-call and clears the pending `interrupt` |
153
+ * | `mcp.auth_required` | Appended auth link text parts; status `interrupt` |
154
+ *
155
+ * Sub-agent threads can have their own pending approval/response; metadata
156
+ * `toolApprovalThreadId` / `toolResponseThreadId` on nested assistant messages
157
+ * scopes resume inputs to the correct thread.
158
+ *
159
+ * A sub-agent stays attached under the `create_sub_agent` tool-call that spawned
160
+ * it, which lives in the root `model.message` of the turn that opened the group.
161
+ * The child thread keeps producing events across several turns (spawn turn, then
162
+ * continuation turns that answer its required actions), and all of them render
163
+ * under that single tool-call in the group's one assistant message.
164
+ *
165
+ * INVARIANT — while a sub-agent is active the parent agent is paused awaiting a
166
+ * required action, so every following turn until it finishes is a continuation
167
+ * (`user.tool_response` / `user.tool_approval` / MCP resume), NOT a `user.message`.
168
+ * If a sub-agent is spawned/active in turn 1, turn 2 cannot carry `user.message`
169
+ * input. A `user.message` there would open a new turn group (`userText` boundary)
170
+ * and split the still-running sub-agent's events away from its tool-call. The
171
+ * gateway enforces this; the projection relies on it for correct nesting.
172
+ *
173
+ * ## Assumptions
174
+ *
175
+ * - Root-thread model messages use `threadId === ROOT_THREAD_ID`.
176
+ * - Turn order in `snapshot.turns` matches gateway chronological order.
177
+ * - `rootModelMessageIds` on committed turns is accurate (set in
178
+ * `commitActiveStream` when a stream completes).
179
+ * - Continuation turns never carry `user.message`; that boundary defines groups.
180
+ * In particular, while a sub-agent (or any tool) is mid-flight awaiting a
181
+ * required action, the next turn is always a continuation, never a new
182
+ * `user.message`.
183
+ * - Tool-call identity is stable via `toolCallId` across events, fold state, and
184
+ * assistant-ui parts.
185
+ * - Reload (`buildSnapshotFromSessionEvents`) and live paths must produce the same
186
+ * per-group scoping; history uses cumulative `groupRootIds` per turn index,
187
+ * live uses `groupRootBaseline`.
188
+ */
189
+
190
+ const TURN_EVENTS_PAGE_SIZE = 25;
191
+ const SESSION_EVENTS_PAGE_SIZE = 100;
192
+ /** Cap how many event pages initial load / load-older may chain for a group boundary. */
193
+ const MAX_HISTORY_BOUNDARY_PAGES = 10;
194
+
195
+ interface FetchSessionEventsOptions {
196
+ /**
197
+ * Newest turn in the listing window (initial load only). Lists that turn and
198
+ * its ancestors. Omit to use the session last turn. Running-turn events are
199
+ * excluded by the API — subscribe to that turn for live events.
200
+ */
201
+ lastTurnId?: string;
202
+ }
203
+
204
+ interface SessionEventsPageResult {
205
+ /** Newest-first items from this request. */
206
+ itemsNewestFirst: GatewaySessionEventItem[];
207
+ olderPageToken?: string;
208
+ hasOlder: boolean;
209
+ }
210
+
211
+ /**
212
+ * Drops a leading incomplete turn (events before the first `turn.created`) that
213
+ * appears when a page boundary splits a turn.
214
+ */
215
+ function trimIncompleteLeadingEvents(itemsAsc: GatewaySessionEventItem[]): GatewaySessionEventItem[] {
216
+ const start = itemsAsc.findIndex(item => item.event.type === 'turn.created');
217
+ if (start === -1) {
218
+ return [];
219
+ }
220
+ return itemsAsc.slice(start);
221
+ }
222
+
223
+ /**
224
+ * Whether the oldest complete turn in a chronological window opens a user group.
225
+ * `incomplete` means we still lack a finished oldest turn (need an older page).
226
+ */
227
+ function oldestCompleteTurnGroupState(
228
+ itemsAsc: GatewaySessionEventItem[],
229
+ ): 'user-group' | 'continuation' | 'incomplete' {
230
+ const trimmed = trimIncompleteLeadingEvents(itemsAsc);
231
+ if (trimmed.length === 0) {
232
+ return 'incomplete';
233
+ }
234
+ const created = trimmed[0];
235
+ if (created?.event.type !== 'turn.created') {
236
+ return 'incomplete';
237
+ }
238
+ const hasDone = trimmed.some(item => item.turnId === created.turnId && item.event.type === 'turn.done');
239
+ if (!hasDone) {
240
+ return 'incomplete';
241
+ }
242
+ return extractTurnUserText(created.event.input) != null ? 'user-group' : 'continuation';
243
+ }
244
+
245
+ async function fetchSessionEventsPage(
246
+ server: AgentChatServer,
247
+ sessionId: string,
248
+ options?: FetchSessionEventsOptions & { pageToken?: string },
249
+ ): Promise<SessionEventsPageResult> {
250
+ const page = await server.listEvents({
251
+ sessionId,
252
+ limit: SESSION_EVENTS_PAGE_SIZE,
253
+ ...(options?.lastTurnId != null ? { lastTurnId: options.lastTurnId } : {}),
254
+ ...(options?.pageToken != null ? { pageToken: options.pageToken } : {}),
255
+ });
256
+ const olderPageToken = page.nextPageToken;
257
+ const hasOlder = olderPageToken != null && olderPageToken !== '';
258
+ return {
259
+ itemsNewestFirst: page.data,
260
+ ...(olderPageToken != null && olderPageToken !== '' ? { olderPageToken } : {}),
261
+ hasOlder,
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Fetches pages until the chronological window starts on a complete user-message
267
+ * turn group (or history is exhausted).
268
+ */
269
+ async function fetchSessionEventsWindow(
270
+ server: AgentChatServer,
271
+ sessionId: string,
272
+ options?: FetchSessionEventsOptions & { pageToken?: string },
273
+ ): Promise<{
274
+ itemsAsc: GatewaySessionEventItem[];
275
+ olderPageToken?: string;
276
+ hasOlder: boolean;
277
+ }> {
278
+ let itemsNewestFirst: GatewaySessionEventItem[] = [];
279
+ let pageToken = options?.pageToken;
280
+ let olderPageToken: string | undefined;
281
+ let hasOlder = false;
282
+
283
+ for (let pageCount = 0; pageCount < MAX_HISTORY_BOUNDARY_PAGES; pageCount++) {
284
+ const page = await fetchSessionEventsPage(server, sessionId, {
285
+ ...options,
286
+ ...(pageToken != null ? { pageToken } : {}),
287
+ });
288
+ itemsNewestFirst = [...itemsNewestFirst, ...page.itemsNewestFirst];
289
+ olderPageToken = page.olderPageToken;
290
+ hasOlder = page.hasOlder;
291
+
292
+ const itemsAsc = trimIncompleteLeadingEvents([...itemsNewestFirst].reverse());
293
+ const groupState = oldestCompleteTurnGroupState(itemsAsc);
294
+ if (groupState === 'user-group' || !hasOlder) {
295
+ return {
296
+ itemsAsc,
297
+ ...(olderPageToken != null ? { olderPageToken } : {}),
298
+ hasOlder,
299
+ };
300
+ }
301
+
302
+ if (olderPageToken == null) {
303
+ return { itemsAsc, hasOlder: false };
304
+ }
305
+ pageToken = olderPageToken;
306
+ }
307
+
308
+ const itemsAsc = trimIncompleteLeadingEvents([...itemsNewestFirst].reverse());
309
+ return {
310
+ itemsAsc,
311
+ ...(olderPageToken != null ? { olderPageToken } : {}),
312
+ hasOlder,
313
+ };
314
+ }
315
+
316
+ /**
317
+ * Fetches session-level events via `server.listEvents()`. The API returns pages
318
+ * in desc order (newest first); the collected array is reversed before returning
319
+ * so callers receive events in chronological (asc) order.
320
+ *
321
+ * Used by rewind/edit paths that need the full ancestor window.
322
+ */
323
+ async function fetchAllSessionEvents(
324
+ server: AgentChatServer,
325
+ sessionId: string,
326
+ options?: FetchSessionEventsOptions,
327
+ ): Promise<GatewaySessionEventItem[]> {
328
+ const items = await drainListPages(pageToken =>
329
+ server.listEvents({
330
+ sessionId,
331
+ limit: SESSION_EVENTS_PAGE_SIZE,
332
+ ...(options?.lastTurnId != null ? { lastTurnId: options.lastTurnId } : {}),
333
+ ...(pageToken != null ? { pageToken } : {}),
334
+ }),
335
+ );
336
+ items.reverse();
337
+ return items;
338
+ }
339
+
340
+ function cloneThreadBucket(bucket: ThreadBucket): ThreadBucket {
341
+ return {
342
+ events: new Map(bucket.events),
343
+ modelMessageIds: [...bucket.modelMessageIds],
344
+ toolResults: new Map(bucket.toolResults),
345
+ pendingApprovals: new Map(bucket.pendingApprovals),
346
+ approvalDecisions: new Map(bucket.approvalDecisions),
347
+ pendingResponses: new Map(bucket.pendingResponses),
348
+ done: bucket.done,
349
+ ...(bucket.title != null ? { title: bucket.title } : {}),
350
+ ...(bucket.agentInfo != null ? { agentInfo: bucket.agentInfo } : {}),
351
+ };
352
+ }
353
+
354
+ /** Prepends older fold state ahead of the currently loaded fold (scroll-up). */
355
+ export function prependFoldState(older: PeerThreadFoldState, newer: PeerThreadFoldState): PeerThreadFoldState {
356
+ const result = new PeerThreadFoldState();
357
+ const threadIds = new Set([...older.threads.keys(), ...newer.threads.keys()]);
358
+ for (const threadId of threadIds) {
359
+ const olderBucket = older.threads.get(threadId);
360
+ const newerBucket = newer.threads.get(threadId);
361
+ if (olderBucket == null && newerBucket != null) {
362
+ result.threads.set(threadId, cloneThreadBucket(newerBucket));
363
+ continue;
364
+ }
365
+ if (newerBucket == null && olderBucket != null) {
366
+ result.threads.set(threadId, cloneThreadBucket(olderBucket));
367
+ continue;
368
+ }
369
+ if (olderBucket == null || newerBucket == null) {
370
+ continue;
371
+ }
372
+ result.threads.set(threadId, {
373
+ events: new Map([...olderBucket.events, ...newerBucket.events]),
374
+ modelMessageIds: [...olderBucket.modelMessageIds, ...newerBucket.modelMessageIds],
375
+ toolResults: new Map([...olderBucket.toolResults, ...newerBucket.toolResults]),
376
+ pendingApprovals: new Map([...olderBucket.pendingApprovals, ...newerBucket.pendingApprovals]),
377
+ approvalDecisions: new Map([...olderBucket.approvalDecisions, ...newerBucket.approvalDecisions]),
378
+ pendingResponses: new Map([...olderBucket.pendingResponses, ...newerBucket.pendingResponses]),
379
+ done: newerBucket.done || olderBucket.done,
380
+ ...(newerBucket.title != null || olderBucket.title != null
381
+ ? { title: newerBucket.title ?? olderBucket.title }
382
+ : {}),
383
+ ...(newerBucket.agentInfo != null || olderBucket.agentInfo != null
384
+ ? { agentInfo: newerBucket.agentInfo ?? olderBucket.agentInfo }
385
+ : {}),
386
+ });
387
+ }
388
+ for (const [threadId, link] of older.threadParents) {
389
+ result.threadParents.set(threadId, link);
390
+ }
391
+ for (const [threadId, link] of newer.threadParents) {
392
+ result.threadParents.set(threadId, link);
393
+ }
394
+ return result;
395
+ }
396
+
397
+ /**
398
+ * Ingests a chronologically ordered list of session-level event items into the
399
+ * snapshot. `turn.created` marks the start of a turn (provides user input),
400
+ * content events are folded as usual, and `turn.done` finalises the turn record
401
+ * and pushes it to `snapshot.turns`.
402
+ *
403
+ * `onTurnComplete` is called after each `turn.done` with the partially-built
404
+ * snapshot so callers can update UI progressively. The fold state is correct at
405
+ * that point for every turn up to and including the just-completed one.
406
+ */
407
+ function ingestSessionEventsIntoSnapshot(
408
+ snapshot: SessionSnapshot,
409
+ items: GatewaySessionEventItem[],
410
+ onTurnComplete?: (snap: SessionSnapshot) => void,
411
+ ): void {
412
+ let currentTurnId: string | null = null;
413
+ let currentCreatedEvent: TurnCreatedEvent | null = null;
414
+ let currentContentEvents: TurnEvent[] = [];
415
+ let beforeCount = 0;
416
+ // Session-scoped: sandbox.created fires when a sandbox is (re)created and the
417
+ // sandbox is reused by later turns, so carry the latest one forward.
418
+ let sessionSandboxId: string | undefined;
419
+
420
+ for (const item of items) {
421
+ const { turnId, event } = item;
422
+
423
+ if (event.type === 'turn.created') {
424
+ currentTurnId = turnId;
425
+ currentCreatedEvent = event;
426
+ currentContentEvents = [];
427
+ beforeCount = snapshot.fold.threads.get(ROOT_THREAD_ID)?.modelMessageIds.length ?? 0;
428
+ } else if (event.type === 'turn.done') {
429
+ if (currentTurnId == null || currentCreatedEvent == null) {
430
+ continue;
431
+ }
432
+
433
+ const afterBucket = snapshot.fold.threads.get(ROOT_THREAD_ID);
434
+ const rootModelMessageIds = (afterBucket?.modelMessageIds ?? []).slice(beforeCount);
435
+
436
+ const sandboxEvent = currentContentEvents.find(
437
+ (ev): ev is Extract<TurnEvent, { type: 'sandbox.created' }> => ev.type === 'sandbox.created',
438
+ );
439
+ sessionSandboxId = sandboxEvent?.sandboxId ?? sessionSandboxId;
440
+
441
+ applyUserToolResponsesToFold(snapshot.fold, currentCreatedEvent.input ?? []);
442
+ snapshot.turns.push(
443
+ sessionEventsToSessionRecord(currentTurnId, currentCreatedEvent, event, rootModelMessageIds, sessionSandboxId),
444
+ );
445
+
446
+ // Pass a new object reference so React's Object.is check in
447
+ // useState sees a changed value and schedules a re-render.
448
+ // The snapshot is mutated in place throughout this loop, so
449
+ // passing `snapshot` directly would make every tick look identical
450
+ // to React after the first setSnapshot call.
451
+ onTurnComplete?.(replaceSessionSnapshot(snapshot, {}));
452
+
453
+ currentTurnId = null;
454
+ currentCreatedEvent = null;
455
+ currentContentEvents = [];
456
+ } else {
457
+ if (currentTurnId != null) {
458
+ ingestTurnEvent(snapshot.fold, event);
459
+ currentContentEvents.push(event);
460
+ }
461
+ }
462
+ }
463
+ }
464
+
465
+ function attachRunningTurn(snapshot: SessionSnapshot, runningTurn: Turn | undefined): SessionSnapshot {
466
+ if (runningTurn == null) {
467
+ return snapshot;
468
+ }
469
+ const pendingUserText = extractTurnUserText(runningTurn.input);
470
+ return replaceSessionSnapshot(snapshot, {
471
+ runningTurn,
472
+ unstable_resume: true,
473
+ groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
474
+ ...(pendingUserText !== undefined
475
+ ? {
476
+ pendingUser: {
477
+ turnId: runningTurn.id,
478
+ content: extractTurnUserMessageContent(runningTurn.input),
479
+ createdAt: new Date(runningTurn.createdAt),
480
+ },
481
+ }
482
+ : {}),
483
+ });
484
+ }
485
+
486
+ /**
487
+ * Last `turn.created` in ASC event order with no following `turn.done` — the
488
+ * open tip of the active branch in this window.
489
+ */
490
+ function findOpenTurnCreated(
491
+ itemsAsc: readonly GatewaySessionEventItem[],
492
+ ): { turnId: string; event: TurnCreatedEvent } | undefined {
493
+ let open: { turnId: string; event: TurnCreatedEvent } | undefined;
494
+ for (const item of itemsAsc) {
495
+ if (item.event.type === 'turn.created') {
496
+ open = { turnId: item.turnId, event: item.event };
497
+ } else if (item.event.type === 'turn.done') {
498
+ open = undefined;
499
+ }
500
+ }
501
+ return open;
502
+ }
503
+
504
+ function turnFromCreatedEvent(options: { sessionId: string; turnId: string; event: TurnCreatedEvent }): Turn {
505
+ const { sessionId, turnId, event } = options;
506
+ return {
507
+ id: turnId,
508
+ sessionId,
509
+ state: { status: 'running' },
510
+ createdAt: event.createdAt,
511
+ ...(event.input != null ? { input: event.input } : {}),
512
+ ...(event.previousTurnId === undefined ? {} : { previousTurnId: event.previousTurnId }),
513
+ };
514
+ }
515
+
516
+ interface SessionTip {
517
+ /** Turn to resume and subscribe to; absent once the tip has finished. */
518
+ runningTurn?: Turn;
519
+ /**
520
+ * Tip input that event ingestion could not apply, because it only folds a
521
+ * turn's input once that turn's `turn.done` arrives. Answered ask-user
522
+ * prompts and approvals live here, so this must be folded even when the tip
523
+ * is no longer running.
524
+ */
525
+ continuationInput: readonly TurnInputItem[];
526
+ }
527
+
528
+ /**
529
+ * Resolves the tip turn of the active branch for resume/subscribe.
530
+ *
531
+ * Prefer an open tip from the events window (works when listTurns is
532
+ * oldest-first and when listEvents includes the running turn). Fall back to
533
+ * `listTurns({ limit: 1 })` for hosts that omit the running turn from
534
+ * listEvents and put the tip first.
535
+ */
536
+ async function resolveSessionTip(options: {
537
+ server: AgentChatServer;
538
+ sessionId: string;
539
+ itemsAsc: readonly GatewaySessionEventItem[];
540
+ }): Promise<SessionTip> {
541
+ const { server, sessionId, itemsAsc } = options;
542
+ const open = findOpenTurnCreated(itemsAsc);
543
+ if (open != null) {
544
+ const continuationInput = open.event.input ?? [];
545
+ if (typeof server.getTurn === 'function') {
546
+ try {
547
+ const turn = await server.getTurn({
548
+ sessionId,
549
+ turnId: open.turnId,
550
+ });
551
+ if (turn.state.status === 'running') {
552
+ return {
553
+ runningTurn: turn,
554
+ continuationInput: turn.input ?? continuationInput,
555
+ };
556
+ }
557
+ // Tip finished between listEvents and getTurn: nothing to
558
+ // resume, but its answers still belong in the fold.
559
+ return { continuationInput: turn.input ?? continuationInput };
560
+ } catch {
561
+ // getTurn failed; synthesize from the open turn.created below.
562
+ }
563
+ }
564
+ return {
565
+ runningTurn: turnFromCreatedEvent({
566
+ sessionId,
567
+ turnId: open.turnId,
568
+ event: open.event,
569
+ }),
570
+ continuationInput,
571
+ };
572
+ }
573
+
574
+ // Hosts that exclude the running turn from listEvents still surface it as
575
+ // the first row of listTurns when that API is tip-first.
576
+ const turnsPage = await server.listTurns({ sessionId, limit: 1 });
577
+ const tip = turnsPage.data[0];
578
+ return tip?.state.status === 'running'
579
+ ? { runningTurn: tip, continuationInput: tip.input ?? [] }
580
+ : { continuationInput: [] };
581
+ }
582
+
583
+ /**
584
+ * Builds a session snapshot using the session-level `listEvents` API.
585
+ *
586
+ * Loads the newest event page (extending only when a page boundary splits a
587
+ * turn group), then leaves older pages for `prependOlderSessionHistory`.
588
+ * Detects a currently-running tip from an open `turn.created` in that window
589
+ * when present; otherwise falls back to `listTurns({ limit: 1 })` for hosts
590
+ * that omit the running turn from listEvents.
591
+ *
592
+ * `onProgress` is called after each complete turn is ingested so callers can
593
+ * update the UI progressively while the processing loop runs.
594
+ */
595
+ export async function buildSnapshotFromSessionEvents(
596
+ server: AgentChatServer,
597
+ sessionId: string,
598
+ onProgress?: (snap: SessionSnapshot) => void,
599
+ ): Promise<SessionSnapshot> {
600
+ const window = await fetchSessionEventsWindow(server, sessionId);
601
+ const historyPagination: SessionHistoryPagination = {
602
+ hasOlder: window.hasOlder,
603
+ ...(window.olderPageToken != null ? { olderPageToken: window.olderPageToken } : {}),
604
+ };
605
+
606
+ const snapshot = createEmptySessionSnapshot();
607
+ ingestSessionEventsIntoSnapshot(snapshot, window.itemsAsc, onProgress);
608
+
609
+ const withHistory = replaceSessionSnapshot(snapshot, {
610
+ historyEvents: window.itemsAsc,
611
+ historyPagination,
612
+ });
613
+
614
+ const tip = await resolveSessionTip({
615
+ server,
616
+ sessionId,
617
+ itemsAsc: window.itemsAsc,
618
+ });
619
+ // The tip has no turn.done in this window, so ingestion never folded its
620
+ // input. Apply it here so answered approvals / ask-user prompts are not
621
+ // restored as pending after a refresh.
622
+ applyUserToolResponsesToFold(withHistory.fold, tip.continuationInput);
623
+ return attachRunningTurn(withHistory, tip.runningTurn);
624
+ }
625
+
626
+ /**
627
+ * Fetches the next older `listEvents` window and prepends it onto `snapshot`
628
+ * without tearing down live stream / pending UI state.
629
+ */
630
+ export async function prependOlderSessionHistory(
631
+ server: AgentChatServer,
632
+ sessionId: string,
633
+ snapshot: SessionSnapshot,
634
+ ): Promise<SessionSnapshot> {
635
+ const pagination = snapshot.historyPagination;
636
+ if (pagination?.hasOlder !== true || pagination.olderPageToken == null) {
637
+ return snapshot;
638
+ }
639
+
640
+ const window = await fetchSessionEventsWindow(server, sessionId, {
641
+ pageToken: pagination.olderPageToken,
642
+ });
643
+ if (window.itemsAsc.length === 0) {
644
+ return replaceSessionSnapshot(snapshot, {
645
+ historyPagination: { hasOlder: false },
646
+ });
647
+ }
648
+
649
+ const olderSnap = createEmptySessionSnapshot();
650
+ ingestSessionEventsIntoSnapshot(olderSnap, window.itemsAsc);
651
+
652
+ const existingIds = new Set(snapshot.turns.map(turn => turn.id));
653
+ const olderTurns = olderSnap.turns.filter(turn => !existingIds.has(turn.id));
654
+ const mergedFold = prependFoldState(olderSnap.fold, snapshot.fold);
655
+ const historyEvents = [...window.itemsAsc, ...(snapshot.historyEvents ?? [])];
656
+ const olderRootIds = olderTurns.flatMap(turn => turn.rootModelMessageIds ?? []);
657
+
658
+ // Forward-propagate sandbox identity revealed by older pages onto
659
+ // already-loaded newer turns that reused the sandbox without emitting
660
+ // sandbox.created. A turn's own sandboxId (a mid-session re-create) wins.
661
+ let knownSandboxId: string | undefined;
662
+ const mergedTurns = [...olderTurns, ...snapshot.turns].map(turn => {
663
+ if (turn.sandboxId != null) {
664
+ knownSandboxId = turn.sandboxId;
665
+ return turn;
666
+ }
667
+ if (knownSandboxId != null) {
668
+ return { ...turn, sandboxId: knownSandboxId };
669
+ }
670
+ return turn;
671
+ });
672
+
673
+ return replaceSessionSnapshot(snapshot, {
674
+ fold: mergedFold,
675
+ turns: mergedTurns,
676
+ historyEvents,
677
+ historyPagination: {
678
+ hasOlder: window.hasOlder,
679
+ ...(window.olderPageToken != null ? { olderPageToken: window.olderPageToken } : {}),
680
+ },
681
+ ...(snapshot.groupRootBaseline != null
682
+ ? {
683
+ groupRootBaseline: [...olderRootIds, ...snapshot.groupRootBaseline],
684
+ }
685
+ : {}),
686
+ });
687
+ }
688
+
689
+ export interface ConvertTurnsResult {
690
+ messages: ThreadMessage[];
691
+ foldState: PeerThreadFoldState;
692
+ runningTurn?: Turn;
693
+ unstable_resume?: boolean;
694
+ }
695
+
696
+ function assistantStatusFromTurnState(state: Turn['state']): MessageStatus {
697
+ switch (state.status) {
698
+ case 'done':
699
+ return { type: 'complete', reason: 'stop' };
700
+ case 'error':
701
+ return { type: 'incomplete', reason: 'error', error: state.message };
702
+ case 'cancelled':
703
+ return { type: 'incomplete', reason: 'cancelled' };
704
+ case 'running':
705
+ return { type: 'running' };
706
+ default:
707
+ return { type: 'complete', reason: 'unknown' };
708
+ }
709
+ }
710
+
711
+ function resolveCreatedAt(
712
+ messageId: string,
713
+ fallback: Date,
714
+ options?: ProjectSessionMessagesOptions,
715
+ replace = false,
716
+ ): Date {
717
+ return options?.getCreatedAt?.(messageId, fallback, replace) ?? fallback;
718
+ }
719
+
720
+ function parseDataUriMime(data: string): string {
721
+ if (!data.startsWith('data:')) {
722
+ return 'application/octet-stream';
723
+ }
724
+ const match = /^data:([^;,]+)/.exec(data);
725
+ return match?.[1] ?? 'application/octet-stream';
726
+ }
727
+
728
+ function fileContentToAttachment(file: FileContent, attachmentId: string): CompleteAttachment {
729
+ const mimeType = parseDataUriMime(file.data);
730
+ if (mimeType.startsWith('image/')) {
731
+ return {
732
+ id: attachmentId,
733
+ type: 'image',
734
+ name: file.name,
735
+ contentType: mimeType,
736
+ status: { type: 'complete' },
737
+ content: [{ type: 'image', image: file.data, filename: file.name }],
738
+ };
739
+ }
740
+ return {
741
+ id: attachmentId,
742
+ type: 'file',
743
+ name: file.name,
744
+ contentType: mimeType,
745
+ status: { type: 'complete' },
746
+ content: [
747
+ {
748
+ type: 'file',
749
+ mimeType,
750
+ filename: file.name,
751
+ data: file.data,
752
+ },
753
+ ],
754
+ };
755
+ }
756
+
757
+ /** Projects gateway turn input onto an assistant-ui user message (text + attachments). */
758
+ export function buildUserMessageFromTurnInput(
759
+ turnId: string,
760
+ input: Turn['input'],
761
+ createdAt: string | Date,
762
+ options?: ProjectSessionMessagesOptions,
763
+ ): ThreadMessage {
764
+ const fallback = createdAt instanceof Date ? createdAt : new Date(createdAt);
765
+ const id = `${turnId}-user`;
766
+ const content: ThreadUserMessagePart[] = [];
767
+ const attachments: CompleteAttachment[] = [];
768
+
769
+ for (const item of input ?? []) {
770
+ if (item.type !== 'user.message') {
771
+ continue;
772
+ }
773
+ const messageContent = item.content;
774
+ if (typeof messageContent === 'string') {
775
+ content.push({ type: 'text', text: messageContent });
776
+ continue;
777
+ }
778
+ for (const part of messageContent) {
779
+ const imageUrl = extractImageUrlFromUserContentItem(part);
780
+ if (imageUrl != null) {
781
+ attachments.push(imageUrlToAttachment(imageUrl, `${turnId}-file-${String(attachments.length)}`));
782
+ continue;
783
+ }
784
+ if (part.type === 'text') {
785
+ content.push({ type: 'text', text: part.text });
786
+ } else {
787
+ attachments.push(fileContentToAttachment(part, `${turnId}-file-${String(attachments.length)}`));
788
+ }
789
+ }
790
+ }
791
+
792
+ return {
793
+ id,
794
+ role: 'user',
795
+ content: content.length > 0 ? content : [{ type: 'text', text: '' }],
796
+ attachments,
797
+ createdAt: resolveCreatedAt(id, fallback, options),
798
+ metadata: { custom: {} },
799
+ };
800
+ }
801
+
802
+ function buildAssistantMessage(
803
+ turnId: string,
804
+ content: AssistantContentPart[],
805
+ createdAt: string | Date,
806
+ status: MessageStatus,
807
+ custom: Record<string, unknown> = {},
808
+ options?: ProjectSessionMessagesOptions,
809
+ replaceCreatedAt = false,
810
+ ): ThreadMessage {
811
+ const fallback = createdAt instanceof Date ? createdAt : new Date(createdAt);
812
+ const id = `${turnId}-assistant`;
813
+ return {
814
+ id,
815
+ role: 'assistant',
816
+ content,
817
+ status,
818
+ createdAt: resolveCreatedAt(id, fallback, options, replaceCreatedAt),
819
+ metadata: {
820
+ unstable_state: null,
821
+ unstable_annotations: [],
822
+ unstable_data: [],
823
+ steps: [],
824
+ custom,
825
+ },
826
+ };
827
+ }
828
+
829
+ async function ingestTurnEventsIntoFold(
830
+ server: AgentChatServer,
831
+ sessionId: string,
832
+ turnId: string,
833
+ foldState: PeerThreadFoldState,
834
+ ): Promise<void> {
835
+ if (server.listTurnEvents == null) {
836
+ return;
837
+ }
838
+ const listTurnEvents = server.listTurnEvents.bind(server);
839
+ const events = await drainListPages(pageToken =>
840
+ listTurnEvents({
841
+ sessionId,
842
+ turnId,
843
+ order: 'asc',
844
+ limit: TURN_EVENTS_PAGE_SIZE,
845
+ ...(pageToken != null ? { pageToken } : {}),
846
+ }),
847
+ );
848
+ for (const event of events) {
849
+ ingestTurnEvent(foldState, event);
850
+ }
851
+ }
852
+
853
+ async function fetchTurnEvents(server: AgentChatServer, sessionId: string, turnId: string): Promise<TurnEvent[]> {
854
+ if (server.listTurnEvents == null) {
855
+ return [];
856
+ }
857
+ const listTurnEvents = server.listTurnEvents.bind(server);
858
+ const events = await drainListPages(pageToken =>
859
+ listTurnEvents({
860
+ sessionId,
861
+ turnId,
862
+ order: 'asc',
863
+ limit: TURN_EVENTS_PAGE_SIZE,
864
+ ...(pageToken != null ? { pageToken } : {}),
865
+ }),
866
+ );
867
+ return events;
868
+ }
869
+
870
+ function ingestCollectedEventsIntoFold(foldState: PeerThreadFoldState, events: TurnEvent[]): void {
871
+ for (const event of events) {
872
+ ingestTurnEvent(foldState, event);
873
+ }
874
+ }
875
+
876
+ async function fetchAllTurnEventsWithConcurrency(
877
+ server: AgentChatServer,
878
+ sessionId: string,
879
+ turns: Turn[],
880
+ concurrency: number,
881
+ ): Promise<TurnEvent[][]> {
882
+ const results = Array.from({ length: turns.length }, (): TurnEvent[] => []);
883
+ const pool = new Set<Promise<void>>();
884
+ for (let i = 0; i < turns.length; i++) {
885
+ const idx = i;
886
+ const turn = turns[idx];
887
+ if (turn == null) {
888
+ continue;
889
+ }
890
+ const p: Promise<void> = fetchTurnEvents(server, sessionId, turn.id).then(events => {
891
+ results[idx] = events;
892
+ pool.delete(p);
893
+ });
894
+ pool.add(p);
895
+ if (pool.size >= concurrency) {
896
+ await Promise.race(pool);
897
+ }
898
+ }
899
+ await Promise.all(pool);
900
+ return results;
901
+ }
902
+
903
+ export function rootModelMessageIdsSinceBaseline(
904
+ foldState: PeerThreadFoldState,
905
+ baseline: readonly string[],
906
+ ): string[] {
907
+ const bucket = foldState.threads.get(ROOT_THREAD_ID);
908
+ if (bucket == null) {
909
+ return [];
910
+ }
911
+ if (baseline.length === 0) {
912
+ return [...bucket.modelMessageIds];
913
+ }
914
+ const baselineSet = new Set(baseline);
915
+ return bucket.modelMessageIds.filter(id => !baselineSet.has(id));
916
+ }
917
+
918
+ export function computeGroupRootBaseline(turns: SessionTurnRecord[]): string[] {
919
+ let groupStartIndex = turns.length - 1;
920
+ while (groupStartIndex >= 0 && turns[groupStartIndex]?.userText == null) {
921
+ groupStartIndex--;
922
+ }
923
+ const baseline: string[] = [];
924
+ for (let i = 0; i < groupStartIndex; i++) {
925
+ baseline.push(...(turns[i]?.rootModelMessageIds ?? []));
926
+ }
927
+ return baseline;
928
+ }
929
+
930
+ function buildTurnUpdateFromFold(
931
+ foldState: PeerThreadFoldState,
932
+ turn: Pick<Turn, 'state'>,
933
+ rootModelMessageIds: readonly string[],
934
+ ): TurnStreamUpdate {
935
+ const content = buildRootAssistantContentForIds(foldState, rootModelMessageIds);
936
+ let update: TurnStreamUpdate = { content };
937
+ update = appendMcpAuthToTurnContent(update.content, turn);
938
+ update = appendToolApprovalToTurnContent(update, turn);
939
+ return update;
940
+ }
941
+
942
+ function contentHasPendingRequiredActions(content: readonly AssistantContentPart[]): boolean {
943
+ const message: ThreadMessage = {
944
+ id: 'pending-check',
945
+ role: 'assistant',
946
+ content,
947
+ status: { type: 'complete', reason: 'stop' },
948
+ createdAt: new Date(),
949
+ metadata: {
950
+ unstable_state: null,
951
+ unstable_annotations: [],
952
+ unstable_data: [],
953
+ steps: [],
954
+ custom: {},
955
+ },
956
+ };
957
+ return messageHasPendingApprovals(message) || messageHasPendingResponses(message);
958
+ }
959
+
960
+ function resolveProjectedRequiredActionState(
961
+ turnUpdate: TurnStreamUpdate,
962
+ content: readonly AssistantContentPart[],
963
+ record: Pick<Turn, 'state'>,
964
+ ): { status: MessageStatus; custom: Record<string, unknown> } {
965
+ let status = turnUpdate.status ?? assistantStatusFromTurnState(record.state);
966
+ let custom = { ...(turnUpdate.metadata?.custom ?? {}) };
967
+
968
+ if (
969
+ status.type === 'requires-action' &&
970
+ status.reason === 'tool-calls' &&
971
+ !contentHasPendingRequiredActions(content)
972
+ ) {
973
+ status = assistantStatusFromTurnState(record.state);
974
+ custom = Object.fromEntries(
975
+ Object.entries(custom).filter(
976
+ ([key]) => key !== TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY && key !== TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY,
977
+ ),
978
+ );
979
+ }
980
+
981
+ return { status, custom };
982
+ }
983
+
984
+ function projectActiveStreamUpdate(snapshot: SessionSnapshot): TurnStreamUpdate {
985
+ const activeStream = snapshot.activeStream;
986
+ if (activeStream == null) {
987
+ throw new Error('projectActiveStreamUpdate requires an active stream');
988
+ }
989
+
990
+ const hasStagedOverlay =
991
+ snapshot.requiredActions.approvals.size > 0 || snapshot.requiredActions.toolResponses.size > 0;
992
+
993
+ // While the user has staged a response locally, keep the paused stream update
994
+ // so required-action collection can pair interrupt + result before resume.
995
+ if (hasStagedOverlay) {
996
+ return activeStream.update;
997
+ }
998
+
999
+ const baseline = snapshot.groupRootBaseline ?? computeGroupRootBaseline(snapshot.turns);
1000
+ const rootModelMessageIds = rootModelMessageIdsSinceBaseline(snapshot.fold, baseline);
1001
+
1002
+ const foldContent = buildRootAssistantContentForIds(snapshot.fold, rootModelMessageIds);
1003
+ const content = foldContent.length > 0 ? foldContent : activeStream.update.content;
1004
+
1005
+ const turnRecord = snapshot.turns.find(turn => turn.id === activeStream.turnId);
1006
+ const turnLike = turnRecord ?? snapshot.runningTurn;
1007
+
1008
+ const rebuilt =
1009
+ turnLike != null ? buildTurnUpdateFromFold(snapshot.fold, turnLike, rootModelMessageIds) : { content };
1010
+
1011
+ const metadata = rebuilt.metadata ?? activeStream.update.metadata;
1012
+ const status = rebuilt.status ?? activeStream.update.status;
1013
+ return {
1014
+ ...activeStream.update,
1015
+ content,
1016
+ ...(metadata == null ? {} : { metadata }),
1017
+ ...(status == null ? {} : { status }),
1018
+ };
1019
+ }
1020
+
1021
+ function applyRequiredActionsOverlayToMessages(
1022
+ messages: ThreadMessage[],
1023
+ overlay: RequiredActionsOverlay,
1024
+ ): ThreadMessage[] {
1025
+ if (overlay.approvals.size === 0 && overlay.toolResponses.size === 0) {
1026
+ return messages;
1027
+ }
1028
+
1029
+ return messages.map(message => {
1030
+ if (message.role !== 'assistant') {
1031
+ return message;
1032
+ }
1033
+
1034
+ let { content } = message;
1035
+ if (overlay.approvals.size > 0) {
1036
+ content = applyApprovalDecisionsToContent(content, overlay.approvals);
1037
+ }
1038
+ if (overlay.toolResponses.size > 0) {
1039
+ content = applyStagedResponsesToContent(content, overlay.toolResponses);
1040
+ }
1041
+
1042
+ if (content === message.content) {
1043
+ return message;
1044
+ }
1045
+
1046
+ return { ...message, content: [...content] };
1047
+ });
1048
+ }
1049
+
1050
+ function projectHistoryTurns(snapshot: SessionSnapshot, options?: ProjectSessionMessagesOptions): ThreadMessage[] {
1051
+ const messages: ThreadMessage[] = [];
1052
+ let lastAssistantIndex: number | undefined;
1053
+ let groupRootIds: string[] = [];
1054
+ let sandboxId: string | undefined;
1055
+
1056
+ for (let turnIndex = 0; turnIndex < snapshot.turns.length; turnIndex++) {
1057
+ const record = snapshot.turns[turnIndex];
1058
+ if (record == null) {
1059
+ continue;
1060
+ }
1061
+ const turnRootIds = record.rootModelMessageIds ?? [];
1062
+ sandboxId = record.sandboxId ?? sandboxId;
1063
+
1064
+ if (record.userText !== undefined) {
1065
+ groupRootIds = [...turnRootIds];
1066
+ } else {
1067
+ groupRootIds = [...groupRootIds, ...turnRootIds];
1068
+ }
1069
+
1070
+ const turnUpdate = buildTurnUpdateFromFold(snapshot.fold, record, groupRootIds);
1071
+ let content = turnUpdate.content;
1072
+
1073
+ const subsequentDecisions = collectSubsequentApprovalDecisions(snapshot.turns, turnIndex);
1074
+ if (subsequentDecisions.size > 0) {
1075
+ content = applyApprovalDecisionsToContent(content, subsequentDecisions);
1076
+ }
1077
+
1078
+ const subsequentResponses = collectSubsequentToolResponses(snapshot.turns, turnIndex);
1079
+ if (subsequentResponses.size > 0) {
1080
+ content = applyStagedResponsesToContent(content, subsequentResponses);
1081
+ }
1082
+
1083
+ const currentResponses = collectToolResponsesFromTurnInput(record.input);
1084
+ if (currentResponses.size > 0) {
1085
+ content = applyStagedResponsesToContent(content, currentResponses);
1086
+ }
1087
+
1088
+ const currentDecisions = collectApprovalDecisionsFromTurnInput(record.input);
1089
+ if (currentDecisions.size > 0) {
1090
+ content = applyApprovalDecisionsToContent(content, currentDecisions);
1091
+ }
1092
+
1093
+ const { status, custom: baseCustom } = resolveProjectedRequiredActionState(turnUpdate, content, record);
1094
+ // Continuation turns fold into the previous assistant message and overwrite this custom,
1095
+ // so a folded message reports the latest turn that contributed to it — the one whose
1096
+ // sandbox wrote the newest artifacts.
1097
+ const custom = {
1098
+ ...baseCustom,
1099
+ turnId: record.id,
1100
+ ...(sandboxId != null ? { sandboxId } : {}),
1101
+ };
1102
+ const assistantCreatedAt = record.state.status === 'running' ? record.createdAt : record.state.completedAt;
1103
+ const replaceAssistantCreatedAt = record.state.status !== 'running';
1104
+
1105
+ if (record.userText !== undefined) {
1106
+ messages.push(buildUserMessageFromTurnInput(record.id, record.input, record.createdAt, options));
1107
+
1108
+ if (record.state.status === 'running') {
1109
+ if (content.length > 0) {
1110
+ messages.push(
1111
+ buildAssistantMessage(
1112
+ record.id,
1113
+ content,
1114
+ assistantCreatedAt,
1115
+ status,
1116
+ custom,
1117
+ options,
1118
+ replaceAssistantCreatedAt,
1119
+ ),
1120
+ );
1121
+ }
1122
+ break;
1123
+ }
1124
+
1125
+ if (content.length > 0) {
1126
+ messages.push(
1127
+ buildAssistantMessage(
1128
+ record.id,
1129
+ content,
1130
+ assistantCreatedAt,
1131
+ status,
1132
+ custom,
1133
+ options,
1134
+ replaceAssistantCreatedAt,
1135
+ ),
1136
+ );
1137
+ lastAssistantIndex = messages.length - 1;
1138
+ }
1139
+ } else if (content.length > 0 && lastAssistantIndex != null) {
1140
+ if (record.state.status === 'running') {
1141
+ break;
1142
+ }
1143
+ const existing = messages[lastAssistantIndex];
1144
+ if (existing?.role !== 'assistant') {
1145
+ continue;
1146
+ }
1147
+ messages[lastAssistantIndex] = {
1148
+ ...existing,
1149
+ content,
1150
+ status,
1151
+ createdAt: resolveCreatedAt(existing.id, new Date(assistantCreatedAt), options, true),
1152
+ metadata: {
1153
+ ...existing.metadata,
1154
+ custom,
1155
+ },
1156
+ };
1157
+ } else if (record.state.status === 'running') {
1158
+ break;
1159
+ }
1160
+ }
1161
+
1162
+ return messages;
1163
+ }
1164
+
1165
+ export function projectSessionMessages(
1166
+ snapshot: SessionSnapshot,
1167
+ options?: ProjectSessionMessagesOptions,
1168
+ ): ThreadMessage[] {
1169
+ let messages = projectHistoryTurns(snapshot, options);
1170
+
1171
+ if (snapshot.pendingUser != null) {
1172
+ messages.push(
1173
+ buildUserMessageFromTurnInput(
1174
+ snapshot.pendingUser.turnId,
1175
+ [{ type: 'user.message', content: snapshot.pendingUser.content }],
1176
+ snapshot.pendingUser.createdAt,
1177
+ options,
1178
+ ),
1179
+ );
1180
+ }
1181
+
1182
+ if (snapshot.activeStream != null) {
1183
+ const { turnId, update, isContinuation, streamComplete } = snapshot.activeStream;
1184
+ const resolvedUpdate = streamComplete === true ? projectActiveStreamUpdate(snapshot) : update;
1185
+
1186
+ const last = messages.at(-1);
1187
+ const existingAssistant = isContinuation && last?.role === 'assistant' ? last : undefined;
1188
+ let assistantMessage = turnStreamUpdateToAssistantMessage(turnId, resolvedUpdate, existingAssistant, options);
1189
+ if (streamComplete === true && resolvedUpdate.status == null) {
1190
+ assistantMessage = {
1191
+ ...assistantMessage,
1192
+ status: { type: 'complete', reason: 'stop' },
1193
+ };
1194
+ }
1195
+
1196
+ if (isContinuation && last?.role === 'assistant') {
1197
+ messages = [...messages.slice(0, -1), assistantMessage];
1198
+ } else {
1199
+ messages = [...messages, assistantMessage];
1200
+ }
1201
+ }
1202
+
1203
+ return applyRequiredActionsOverlayToMessages(messages, snapshot.requiredActions);
1204
+ }
1205
+
1206
+ const DEFAULT_LIST_EVENTS_CONCURRENCY = 5;
1207
+
1208
+ function ingestTurnsIntoSnapshot(
1209
+ snapshot: SessionSnapshot,
1210
+ turns: Turn[],
1211
+ eventArrays: TurnEvent[][],
1212
+ ): Turn | undefined {
1213
+ let runningTurn: Turn | undefined;
1214
+ // Session-scoped: sandbox.created fires when a sandbox is (re)created and the
1215
+ // sandbox is reused by later turns, so carry the latest one forward.
1216
+ let sessionSandboxId: string | undefined;
1217
+
1218
+ for (let i = 0; i < turns.length; i++) {
1219
+ const turn = turns[i];
1220
+ if (turn == null) {
1221
+ continue;
1222
+ }
1223
+ const rootBucket = snapshot.fold.threads.get(ROOT_THREAD_ID);
1224
+ const beforeCount = rootBucket?.modelMessageIds.length ?? 0;
1225
+
1226
+ ingestCollectedEventsIntoFold(snapshot.fold, eventArrays[i] ?? []);
1227
+ applyUserToolResponsesToFold(snapshot.fold, turn.input ?? []);
1228
+
1229
+ const afterBucket = snapshot.fold.threads.get(ROOT_THREAD_ID);
1230
+ const rootModelMessageIds = (afterBucket?.modelMessageIds ?? []).slice(beforeCount);
1231
+
1232
+ const sandboxEvent = (eventArrays[i] ?? []).find(
1233
+ (event): event is Extract<TurnEvent, { type: 'sandbox.created' }> => event.type === 'sandbox.created',
1234
+ );
1235
+ sessionSandboxId = sandboxEvent?.sandboxId ?? sessionSandboxId;
1236
+
1237
+ snapshot.turns.push({
1238
+ ...turnToSessionRecord(turn),
1239
+ rootModelMessageIds,
1240
+ ...(sessionSandboxId != null ? { sandboxId: sessionSandboxId } : {}),
1241
+ });
1242
+
1243
+ if (turn.state.status === 'running') {
1244
+ runningTurn = turn;
1245
+ break;
1246
+ }
1247
+ }
1248
+
1249
+ return runningTurn;
1250
+ }
1251
+
1252
+ export async function buildSnapshotFromSession(
1253
+ server: AgentChatServer,
1254
+ sessionId: string,
1255
+ concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
1256
+ ): Promise<SessionSnapshot> {
1257
+ // Completed history comes from session-level listEvents. The session API
1258
+ // excludes the running turn — hydrate that turn via listTurnEvents so
1259
+ // convertTurnsToThreadMessages still surfaces in-flight content.
1260
+ const snapshot = await buildSnapshotFromSessionEvents(server, sessionId);
1261
+ if (snapshot.runningTurn == null) {
1262
+ return snapshot;
1263
+ }
1264
+
1265
+ const turn = snapshot.runningTurn;
1266
+ const eventArrays = await fetchAllTurnEventsWithConcurrency(server, sessionId, [turn], concurrency);
1267
+ ingestTurnsIntoSnapshot(snapshot, [turn], eventArrays);
1268
+
1269
+ // Hydrated in-flight content lives in `turns` / `fold` now — drop the
1270
+ // reconnect seed so projection does not duplicate the user bubble.
1271
+ const snapshotWithoutPendingUser = { ...snapshot };
1272
+ delete snapshotWithoutPendingUser.pendingUser;
1273
+ return replaceSessionSnapshot(snapshotWithoutPendingUser, {
1274
+ runningTurn: turn,
1275
+ unstable_resume: true,
1276
+ groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
1277
+ });
1278
+ }
1279
+
1280
+ /**
1281
+ * Rebuilds the conversation through `anchorTurnId`, including that turn.
1282
+ * The server follows parent links from the anchor, so turns from abandoned
1283
+ * branches are excluded. A null anchor represents an empty conversation.
1284
+ */
1285
+ export async function buildSnapshotThroughTurn(
1286
+ server: AgentChatServer,
1287
+ sessionId: string,
1288
+ anchorTurnId: string | null,
1289
+ ): Promise<SessionSnapshot> {
1290
+ if (anchorTurnId == null) {
1291
+ return createEmptySessionSnapshot();
1292
+ }
1293
+ const items = await fetchAllSessionEvents(server, sessionId, {
1294
+ lastTurnId: anchorTurnId,
1295
+ });
1296
+ const snapshot = createEmptySessionSnapshot();
1297
+ ingestSessionEventsIntoSnapshot(snapshot, items);
1298
+ return snapshot;
1299
+ }
1300
+
1301
+ /**
1302
+ * Resolves `previousTurnId` for edit/retry of `turnId` from the turn's own
1303
+ * parent pointer (`"none"` for roots). Independent of listTurns order.
1304
+ */
1305
+ export async function resolveGatewayBranchPreviousTurnIdForTurn(
1306
+ server: AgentChatServer,
1307
+ sessionId: string,
1308
+ turnId: string,
1309
+ ): Promise<string> {
1310
+ const turn = await server.getTurn({ sessionId, turnId });
1311
+ return turn.previousTurnId ?? 'none';
1312
+ }
1313
+
1314
+ export async function buildTurnAssistantContent(
1315
+ server: AgentChatServer,
1316
+ sessionId: string,
1317
+ turn: Pick<Turn, 'id' | 'state'>,
1318
+ foldState?: PeerThreadFoldState,
1319
+ ): Promise<AssistantContentPart[]> {
1320
+ const state = foldState ?? new PeerThreadFoldState();
1321
+ const beforeCount = state.threads.get(ROOT_THREAD_ID)?.modelMessageIds.length ?? 0;
1322
+ await ingestTurnEventsIntoFold(server, sessionId, turn.id, state);
1323
+ const afterIds = state.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? [];
1324
+ const rootModelMessageIds = afterIds.slice(beforeCount);
1325
+ return buildTurnUpdateFromFold(state, turn, rootModelMessageIds).content;
1326
+ }
1327
+
1328
+ export async function convertTurnsToThreadMessages(
1329
+ server: AgentChatServer,
1330
+ sessionId: string,
1331
+ ): Promise<ConvertTurnsResult> {
1332
+ const snapshot = await buildSnapshotFromSession(server, sessionId);
1333
+ const messages = projectSessionMessages(snapshot);
1334
+
1335
+ return {
1336
+ messages,
1337
+ foldState: snapshot.fold,
1338
+ ...(snapshot.runningTurn != null
1339
+ ? {
1340
+ runningTurn: snapshot.runningTurn,
1341
+ unstable_resume: true,
1342
+ }
1343
+ : {}),
1344
+ };
1345
+ }
1346
+
1347
+ export function getTurnMessageContent(message: AppendMessage): string {
1348
+ const parts: string[] = [];
1349
+ for (const part of message.content) {
1350
+ if (part.type === 'text') {
1351
+ parts.push(part.text);
1352
+ }
1353
+ }
1354
+ const text = parts.join('\n').trim();
1355
+ if (!text) {
1356
+ throw new Error('User message must contain text content.');
1357
+ }
1358
+ return text;
1359
+ }
1360
+
1361
+ /**
1362
+ * Gateway `user.message` content (string for text-only, or text/file parts).
1363
+ * Derived from SDK `TurnInputItem` so it tracks API changes.
1364
+ */
1365
+ export type UserMessageContent = Extract<TurnInputItem, { type: 'user.message' }>['content'];
1366
+
1367
+ type UserMessageContentItem = Exclude<UserMessageContent, string>[number];
1368
+ type FileContent = Extract<UserMessageContentItem, { type: 'file' }>;
1369
+ type TextContent = Extract<UserMessageContentItem, { type: 'text' }>;
1370
+
1371
+ function toFileDataUri(data: string, mimeType: string): string {
1372
+ // FileContent expects a data URI (`data:<mime>;base64,<payload>`).
1373
+ // Attachment adapters typically already produce one via FileReader.readAsDataURL;
1374
+ // only wrap bare base64 payloads.
1375
+ if (data.startsWith('data:')) {
1376
+ return data;
1377
+ }
1378
+ return `data:${mimeType};base64,${data}`;
1379
+ }
1380
+
1381
+ function toFileContent(name: string, data: string, mimeType: string): FileContent {
1382
+ return {
1383
+ type: 'file',
1384
+ name,
1385
+ data: toFileDataUri(data, mimeType),
1386
+ };
1387
+ }
1388
+
1389
+ /**
1390
+ * Builds the gateway turn input content from a composer message, forwarding
1391
+ * attachments as SDK `FileContent` parts. Mirrors how assistant-ui surfaces
1392
+ * attachment content on `message.attachments[].content`.
1393
+ */
1394
+ export function buildUserMessageContent(message: AppendMessage): UserMessageContent {
1395
+ const inputParts = [
1396
+ ...message.content,
1397
+ ...(message.attachments?.flatMap(attachment =>
1398
+ attachment.content.map(part => ({
1399
+ ...part,
1400
+ filename: attachment.name,
1401
+ })),
1402
+ ) ?? []),
1403
+ ];
1404
+
1405
+ const items: UserMessageContentItem[] = [];
1406
+ for (const part of inputParts) {
1407
+ switch (part.type) {
1408
+ case 'text':
1409
+ if (part.text.trim().length > 0) {
1410
+ const textPart: TextContent = { type: 'text', text: part.text };
1411
+ items.push(textPart);
1412
+ }
1413
+ break;
1414
+ case 'image':
1415
+ items.push(toFileContent(part.filename ?? 'image', part.image, 'image/png'));
1416
+ break;
1417
+ case 'file':
1418
+ items.push(toFileContent(part.filename ?? 'file', part.data, part.mimeType));
1419
+ break;
1420
+ default:
1421
+ break;
1422
+ }
1423
+ }
1424
+
1425
+ const hasFile = items.some((item): item is FileContent => item.type === 'file');
1426
+ if (!hasFile) {
1427
+ // Text-only: keep the string form and the non-empty invariant.
1428
+ return getTurnMessageContent(message);
1429
+ }
1430
+ return items;
1431
+ }
1432
+
1433
+ /** Derives display text from gateway user-message content (drops file parts). */
1434
+ export function userMessageContentToText(content: UserMessageContent): string {
1435
+ if (typeof content === 'string') {
1436
+ return content;
1437
+ }
1438
+ return content
1439
+ .filter((item): item is TextContent => item.type === 'text')
1440
+ .map(item => item.text)
1441
+ .join('\n')
1442
+ .trim();
1443
+ }
1444
+
1445
+ /** Strips the `-user` suffix from a projected user message id to recover the turn id. */
1446
+ export function parseTurnIdFromMessageId(messageId: string): string {
1447
+ return messageId.replace(/-user$/, '');
1448
+ }
1449
+
1450
+ /** Parent turn id for branching before `turnId`; `null` when editing the first turn. */
1451
+ export function resolveBranchPreviousTurnId(turns: readonly SessionTurnRecord[], turnId: string): string | null {
1452
+ const turnIndex = turns.findIndex(turn => turn.id === turnId);
1453
+ if (turnIndex <= 0) {
1454
+ return null;
1455
+ }
1456
+ return turns[turnIndex - 1]?.id ?? null;
1457
+ }
1458
+
1459
+ /** Extracts edited text from an assistant-ui append message (text parts only). */
1460
+ export function extractEditedText(message: AppendMessage): string {
1461
+ return getTurnMessageContent(message);
1462
+ }
1463
+
1464
+ /** Original gateway user-message content from a turn record (for reset). */
1465
+ export function extractTurnUserMessageContent(input: TurnInputItem[] | undefined): UserMessageContent {
1466
+ for (const item of input ?? []) {
1467
+ if (item.type === 'user.message') {
1468
+ return item.content;
1469
+ }
1470
+ }
1471
+ return '';
1472
+ }
1473
+
1474
+ /**
1475
+ * Builds resubmit content for a text-only edit: replaces text with `editedText`
1476
+ * while preserving original file parts from the turn record.
1477
+ */
1478
+ export function buildEditedUserMessageContent(
1479
+ editedText: string,
1480
+ originalInput: TurnInputItem[] | undefined,
1481
+ ): UserMessageContent {
1482
+ const fileParts: FileContent[] = [];
1483
+ for (const item of originalInput ?? []) {
1484
+ if (item.type !== 'user.message') {
1485
+ continue;
1486
+ }
1487
+ const content = item.content;
1488
+ if (typeof content === 'string') {
1489
+ continue;
1490
+ }
1491
+ for (const part of content) {
1492
+ if (part.type === 'file') {
1493
+ fileParts.push(part);
1494
+ }
1495
+ }
1496
+ }
1497
+
1498
+ if (fileParts.length === 0) {
1499
+ return editedText;
1500
+ }
1501
+
1502
+ const items: UserMessageContentItem[] = [];
1503
+ if (editedText.trim().length > 0) {
1504
+ items.push({ type: 'text', text: editedText });
1505
+ }
1506
+ items.push(...fileParts);
1507
+ return items;
1508
+ }
1509
+
1510
+ function buildMcpAuthUpdate(
1511
+ pendingMcpAuth: McpAuthRequiredEvent,
1512
+ foldState: PeerThreadFoldState,
1513
+ groupRootBaseline?: readonly string[],
1514
+ ): TurnStreamUpdate {
1515
+ const base =
1516
+ groupRootBaseline != null
1517
+ ? buildRootAssistantContentForIds(foldState, rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline))
1518
+ : buildRootAssistantContent(foldState);
1519
+ return {
1520
+ content: [...base, ...buildMcpAuthTextParts()],
1521
+ status: mcpAuthAssistantStatus(),
1522
+ metadata: { custom: mcpAuthMessageCustom(pendingMcpAuth.mcpServers) },
1523
+ };
1524
+ }
1525
+
1526
+ export async function* streamTurnEvents(
1527
+ stream: AsyncIterable<TurnStreamData>,
1528
+ foldState: PeerThreadFoldState,
1529
+ groupRootBaseline?: readonly string[],
1530
+ onTurnIdAvailable?: (turnId: string) => void,
1531
+ ): AsyncGenerator<TurnStreamUpdate> {
1532
+ let pendingMcpAuth: McpAuthRequiredEvent | undefined;
1533
+ let sandboxId: string | undefined;
1534
+ let sandboxIdYielded = false;
1535
+
1536
+ const withSandbox = (update: TurnStreamUpdate): TurnStreamUpdate => {
1537
+ if (sandboxId == null) {
1538
+ return update;
1539
+ }
1540
+ return {
1541
+ ...update,
1542
+ metadata: { ...update.metadata, custom: { ...update.metadata?.custom, sandboxId } },
1543
+ };
1544
+ };
1545
+
1546
+ const yieldContent = (): AssistantContentPart[] | undefined => {
1547
+ const ids =
1548
+ groupRootBaseline != null
1549
+ ? rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline)
1550
+ : (foldState.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? []);
1551
+ const content = buildRootAssistantContentForIds(foldState, ids);
1552
+ return content.length > 0 ? content : undefined;
1553
+ };
1554
+
1555
+ for await (const data of stream) {
1556
+ const event = data.event;
1557
+
1558
+ if (event.type === 'turn.created') {
1559
+ onTurnIdAvailable?.(event.turnId);
1560
+ continue;
1561
+ }
1562
+
1563
+ if (event.type === 'sandbox.created') {
1564
+ sandboxId = event.sandboxId;
1565
+ continue;
1566
+ }
1567
+
1568
+ if (event.type === 'mcp.auth_required') {
1569
+ pendingMcpAuth = event;
1570
+ continue;
1571
+ }
1572
+
1573
+ if (event.type === 'turn.done') {
1574
+ if (event.state.status === 'error') {
1575
+ throw new Error(event.state.message);
1576
+ }
1577
+ // The turn is logically complete once `turn.done` is observed. The
1578
+ // resumed-turn transport (`subscribeToTurn`) is a reconnectable live
1579
+ // tail and is not guaranteed to close its SSE body right after this
1580
+ // event, so we must stop consuming explicitly rather than waiting
1581
+ // for the underlying stream to end — otherwise `isRunning` never
1582
+ // clears and the composer's cancel/spinner button gets stuck.
1583
+ break;
1584
+ }
1585
+
1586
+ if (!ingestStreamEvent(foldState, event)) {
1587
+ continue;
1588
+ }
1589
+
1590
+ const content = yieldContent();
1591
+ if (content != null) {
1592
+ if (sandboxId != null) {
1593
+ sandboxIdYielded = true;
1594
+ }
1595
+ yield withSandbox({ content });
1596
+ }
1597
+ }
1598
+
1599
+ if (pendingMcpAuth != null) {
1600
+ yield withSandbox(buildMcpAuthUpdate(pendingMcpAuth, foldState, groupRootBaseline));
1601
+ return;
1602
+ }
1603
+
1604
+ const approvalThreadId = findFirstPendingApprovalThreadId(foldState);
1605
+ const responseThreadId = findFirstPendingResponseThreadId(foldState);
1606
+ if (approvalThreadId != null || responseThreadId != null) {
1607
+ const custom: Record<string, unknown> = {};
1608
+ if (approvalThreadId != null) {
1609
+ Object.assign(
1610
+ custom,
1611
+ toolApprovalMessageCustom(approvalThreadId === ROOT_THREAD_ID ? ROOT_THREAD_ID : approvalThreadId),
1612
+ );
1613
+ }
1614
+ if (responseThreadId != null) {
1615
+ Object.assign(
1616
+ custom,
1617
+ toolResponseMessageCustom(responseThreadId === ROOT_THREAD_ID ? ROOT_THREAD_ID : responseThreadId),
1618
+ );
1619
+ }
1620
+ const ids =
1621
+ groupRootBaseline != null
1622
+ ? rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline)
1623
+ : (foldState.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? []);
1624
+ yield withSandbox({
1625
+ content: buildRootAssistantContentForIds(foldState, ids),
1626
+ status: approvalThreadId != null ? toolApprovalStatus() : toolResponseStatus(),
1627
+ metadata: { custom },
1628
+ });
1629
+ return;
1630
+ }
1631
+
1632
+ if (sandboxId != null && !sandboxIdYielded) {
1633
+ const ids =
1634
+ groupRootBaseline != null
1635
+ ? rootModelMessageIdsSinceBaseline(foldState, groupRootBaseline)
1636
+ : (foldState.threads.get(ROOT_THREAD_ID)?.modelMessageIds ?? []);
1637
+ yield withSandbox({ content: buildRootAssistantContentForIds(foldState, ids) });
1638
+ }
1639
+ }
1640
+
1641
+ export function turnStreamUpdateToAssistantMessage(
1642
+ turnId: string,
1643
+ update: TurnStreamUpdate,
1644
+ existing?: ThreadMessage,
1645
+ options?: ProjectSessionMessagesOptions,
1646
+ ): ThreadMessage {
1647
+ const id = existing?.role === 'assistant' ? existing.id : `${turnId}-assistant`;
1648
+ const fallbackCreatedAt = existing?.createdAt ?? new Date();
1649
+ return {
1650
+ id,
1651
+ role: 'assistant',
1652
+ content: update.content,
1653
+ status: update.status ?? { type: 'running' },
1654
+ createdAt: resolveCreatedAt(id, fallbackCreatedAt, options),
1655
+ metadata: {
1656
+ unstable_state: null,
1657
+ unstable_annotations: [],
1658
+ unstable_data: [],
1659
+ steps: [],
1660
+ custom: {
1661
+ ...(existing?.role === 'assistant' ? existing.metadata.custom : {}),
1662
+ ...update.metadata?.custom,
1663
+ turnId,
1664
+ },
1665
+ },
1666
+ };
1667
+ }
1668
+
1669
+ export function repositoryItemsFromMessages(messages: readonly ThreadMessage[]): ExportedMessageRepositoryItem[] {
1670
+ const items: ExportedMessageRepositoryItem[] = [];
1671
+ let parentId: string | null = null;
1672
+ for (const message of messages) {
1673
+ items.push({ parentId, message });
1674
+ parentId = message.id;
1675
+ }
1676
+ return items;
1677
+ }
1678
+
1679
+ export type { ProjectSessionMessagesOptions, SessionSnapshot } from './sessionSnapshot.js';