@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,1138 @@
1
+ 'use client';
2
+
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
4
+ import type {
5
+ McpAuthRequiredEvent,
6
+ ToolApprovalRequiredEvent,
7
+ ToolResponseRequiredEvent,
8
+ Turn,
9
+ TurnInputItem,
10
+ TurnStateDone,
11
+ } from './server/index.js';
12
+ import type { AgentChatServer } from './server/types.js';
13
+
14
+ import { ROOT_THREAD_ID } from './constants.js';
15
+ import {
16
+ buildEditedUserMessageContent,
17
+ buildSnapshotThroughTurn,
18
+ computeGroupRootBaseline,
19
+ extractTurnUserMessageContent,
20
+ prependOlderSessionHistory,
21
+ projectSessionMessages,
22
+ resolveGatewayBranchPreviousTurnIdForTurn,
23
+ rootModelMessageIdsSinceBaseline,
24
+ userMessageContentToText,
25
+ type UserMessageContent,
26
+ } from './convertTurnMessages.js';
27
+ import { extractTurnUserText } from './extractTurnUserText.js';
28
+ import { loadSessionSnapshot } from './loadSessionSnapshot.js';
29
+ import { MCP_AUTH_RESUME_RUN_CUSTOM_KEY } from './mcpAuth.js';
30
+ import { isMcpServerAuthInfoList } from './messageCustomMetadata.js';
31
+ import {
32
+ collectRequiredActionInputs,
33
+ findPausedAssistantMessage,
34
+ messageHasPendingRequiredActions,
35
+ type RequiredActionInput,
36
+ } from './requiredActionInputs.js';
37
+ import {
38
+ createEmptySessionSnapshot,
39
+ replaceSessionSnapshot,
40
+ type SessionSnapshot,
41
+ type SessionTurnRecord,
42
+ } from './sessionSnapshot.js';
43
+ import { resumeTurnStream, streamTurnContent } from './streamTurn.js';
44
+ import { TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY, type RespondToToolApprovalOptions } from './toolApproval.js';
45
+ import {
46
+ applyUserToolResponsesToFold,
47
+ TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY,
48
+ type RespondToToolResponseOptions,
49
+ } from './toolResponse.js';
50
+ import type { TurnStreamUpdate } from './turnStreamUpdate.js';
51
+
52
+ export interface UseTrueForgeAgentMessagesOptions {
53
+ server: AgentChatServer;
54
+ sessionId: string | undefined;
55
+ /** When true the thread is the currently selected (main) thread. */
56
+ isMain?: boolean | undefined;
57
+ /** URL-selected session may load before the thread list marks it as main. */
58
+ isInitialSession?: boolean | undefined;
59
+ onError?: ((error: unknown) => void) | undefined;
60
+ initializeSession?: () => Promise<{
61
+ remoteId: string;
62
+ externalId: string | undefined;
63
+ }>;
64
+ /** Maps a thread `remoteId` to the gateway session id used for turns. */
65
+ resolveConversationSessionId?: (remoteId: string) => Promise<string>;
66
+ /**
67
+ * Optional per-turn headers for createTurn. Invoked once per `sendTurn` after
68
+ * the session is resolved; return value is forwarded to `turn.execute`.
69
+ */
70
+ getTurnHeaders?: () => Promise<Record<string, string> | undefined>;
71
+ }
72
+
73
+ export type SendTurnOptions =
74
+ | {
75
+ userMessage: UserMessageContent;
76
+ previousTurnId?: string | null;
77
+ /** Invoked only when the user turn fails before the gateway registers it. */
78
+ onPreTurnFailure?: () => void;
79
+ /**
80
+ * When branching (edit/reset), the already-rewound history to send from.
81
+ * Applied atomically with `pendingUser` so a stale React snapshot cannot
82
+ * keep pre-branch turns while the new user message is appended.
83
+ */
84
+ branchFromSnapshot?: SessionSnapshot;
85
+ /** Original history restored when a branch fails before turn.created. */
86
+ branchRollbackSnapshot?: SessionSnapshot;
87
+ }
88
+ | { inputs: RequiredActionInput[] }
89
+ | { resumeMcpAuth: true };
90
+
91
+ function buildCompletedTurnState(
92
+ completedAt: string,
93
+ requiredActions: TurnStateDone['requiredActions'] = [],
94
+ ): TurnStateDone {
95
+ return {
96
+ status: 'done',
97
+ requiredActions,
98
+ completedAt,
99
+ };
100
+ }
101
+
102
+ /**
103
+ * Reconstructs the pending required actions a paused in-flight update carried,
104
+ * so the pause survives `commitActiveStream`'s synthetic "done" state.
105
+ *
106
+ * `commitActiveStream` fabricates a `TurnStateDone` for the just-finished
107
+ * stream, and the projection derives an assistant message's `requires-action`
108
+ * status from `turn.state.requiredActions` (see `findApprovalRequiredInTurn` /
109
+ * `findResponseRequiredInTurn` / `findMcpAuthRequired`). If we returned an empty
110
+ * list here, the committed turn would look "complete", the projected message
111
+ * would lose its `requires-action` status, and `findPausedAssistantMessage`
112
+ * (used by `trySendCollectedRequiredActions`) would never see it — so answering
113
+ * a tool approval or an `ask_user_question` would never send the resume turn.
114
+ *
115
+ * The paused update already carries the pause state: `status` is
116
+ * `requires-action` and `metadata.custom` holds the pending thread id(s) (and,
117
+ * for MCP, the server list). Only the thread id is read downstream, so an empty
118
+ * `toolCalls` list is sufficient here — the resume inputs are collected from the
119
+ * message content, not from these reconstructed actions.
120
+ */
121
+ export function requiredActionsFromActiveUpdate(update: TurnStreamUpdate): TurnStateDone['requiredActions'] {
122
+ const custom = update.metadata?.custom;
123
+ const requiredActions: TurnStateDone['requiredActions'] = [];
124
+ const createdAt = new Date().toISOString();
125
+
126
+ if (custom?.['pendingMcpAuth'] === true && isMcpServerAuthInfoList(custom['mcpServers'])) {
127
+ const mcpAuthRequired: McpAuthRequiredEvent = {
128
+ type: 'mcp.auth_required',
129
+ id: crypto.randomUUID(),
130
+ createdAt,
131
+ mcpServers: custom['mcpServers'],
132
+ };
133
+ requiredActions.push(mcpAuthRequired);
134
+ }
135
+
136
+ if (update.status?.type === 'requires-action') {
137
+ const approvalThreadId = custom?.[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY];
138
+ if (typeof approvalThreadId === 'string') {
139
+ const approvalRequired: ToolApprovalRequiredEvent = {
140
+ type: 'tool.approval_required',
141
+ id: crypto.randomUUID(),
142
+ createdAt,
143
+ threadId: approvalThreadId,
144
+ toolCalls: [],
145
+ };
146
+ requiredActions.push(approvalRequired);
147
+ }
148
+
149
+ const responseThreadId = custom?.[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY];
150
+ if (typeof responseThreadId === 'string') {
151
+ const responseRequired: ToolResponseRequiredEvent = {
152
+ type: 'tool.response_required',
153
+ id: crypto.randomUUID(),
154
+ createdAt,
155
+ threadId: responseThreadId,
156
+ toolCalls: [],
157
+ };
158
+ requiredActions.push(responseRequired);
159
+ }
160
+ }
161
+
162
+ return requiredActions;
163
+ }
164
+
165
+ function buildUserTurnInput(content: UserMessageContent): TurnInputItem {
166
+ return { type: 'user.message', content };
167
+ }
168
+
169
+ function appendTurnInputs(
170
+ base: TurnInputItem[] | undefined,
171
+ continuationInputs?: RequiredActionInput[],
172
+ ): TurnInputItem[] {
173
+ const existingInputs = base ?? [];
174
+ if (continuationInputs == null || continuationInputs.length === 0) {
175
+ return existingInputs;
176
+ }
177
+ return [...existingInputs, ...continuationInputs];
178
+ }
179
+
180
+ function cancelScheduledAnimationFrame(frame: number | null): void {
181
+ if (frame != null) {
182
+ cancelAnimationFrame(frame);
183
+ }
184
+ }
185
+
186
+ function commitActiveStream(snapshot: SessionSnapshot, continuationInputs?: RequiredActionInput[]): SessionSnapshot {
187
+ const active = snapshot.activeStream;
188
+ if (active?.streamComplete !== true) {
189
+ return snapshot;
190
+ }
191
+
192
+ const activeSandboxIdValue = active.update.metadata?.custom?.['sandboxId'];
193
+ const activeSandboxId = typeof activeSandboxIdValue === 'string' ? activeSandboxIdValue : undefined;
194
+
195
+ const completedState = buildCompletedTurnState(
196
+ new Date().toISOString(),
197
+ requiredActionsFromActiveUpdate(active.update),
198
+ );
199
+ const baseline = snapshot.groupRootBaseline ?? computeGroupRootBaseline(snapshot.turns);
200
+ const rootModelMessageIds = rootModelMessageIdsSinceBaseline(snapshot.fold, baseline);
201
+
202
+ const lastTurn = snapshot.turns.at(-1);
203
+ if (lastTurn?.id === active.turnId) {
204
+ return replaceSessionSnapshot(snapshot, {
205
+ turns: snapshot.turns.map(turn =>
206
+ turn.id === active.turnId
207
+ ? {
208
+ ...turn,
209
+ state: completedState,
210
+ input: appendTurnInputs(turn.input, continuationInputs),
211
+ rootModelMessageIds,
212
+ ...(activeSandboxId != null ? { sandboxId: activeSandboxId } : {}),
213
+ }
214
+ : turn,
215
+ ),
216
+ pendingUser: undefined,
217
+ // Custom stream adapters may yield projected content without fold events.
218
+ // Keep that completed projection until the next stream replaces it.
219
+ ...(rootModelMessageIds.length > 0 ? { activeStream: undefined } : {}),
220
+ });
221
+ }
222
+
223
+ const record: SessionTurnRecord = {
224
+ id: active.turnId,
225
+ createdAt: snapshot.pendingUser?.createdAt.toISOString() ?? new Date().toISOString(),
226
+ state: completedState,
227
+ input: appendTurnInputs(
228
+ snapshot.pendingUser ? [buildUserTurnInput(snapshot.pendingUser.content)] : [],
229
+ continuationInputs,
230
+ ),
231
+ ...(snapshot.pendingUser ? { userText: userMessageContentToText(snapshot.pendingUser.content) } : {}),
232
+ rootModelMessageIds,
233
+ ...(activeSandboxId != null ? { sandboxId: activeSandboxId } : {}),
234
+ };
235
+
236
+ return replaceSessionSnapshot(snapshot, {
237
+ turns: [...snapshot.turns, record],
238
+ pendingUser: undefined,
239
+ ...(rootModelMessageIds.length > 0 ? { activeStream: undefined } : {}),
240
+ });
241
+ }
242
+
243
+ /** Bounds the older-history page-ins a sandbox lookup may trigger. */
244
+ const MAX_SANDBOX_HISTORY_PAGE_INS = 20;
245
+
246
+ /**
247
+ * sandboxId in effect as of `turnId`. An artifact can only come from a sandbox
248
+ * created at or before its own turn, so scan records backward from that turn;
249
+ * an unknown turn (e.g. projected only from the active stream) scans the whole
250
+ * loaded window.
251
+ */
252
+ function findSandboxIdInSnapshot(snapshot: SessionSnapshot, turnId: string): string | undefined {
253
+ const active = snapshot.activeStream;
254
+ if (active?.turnId === turnId) {
255
+ const sandboxId = active.update.metadata?.custom?.['sandboxId'];
256
+ if (typeof sandboxId === 'string') {
257
+ return sandboxId;
258
+ }
259
+ }
260
+ const turns = snapshot.turns;
261
+ const turnIndex = turns.findIndex(turn => turn.id === turnId);
262
+ for (let i = turnIndex === -1 ? turns.length - 1 : turnIndex; i >= 0; i--) {
263
+ const sandboxId = turns[i]?.sandboxId;
264
+ if (sandboxId != null) {
265
+ return sandboxId;
266
+ }
267
+ }
268
+ return undefined;
269
+ }
270
+
271
+ async function resolveActiveSessionId(
272
+ remoteId: string,
273
+ resolveConversationSessionId?: (remoteId: string) => Promise<string>,
274
+ ): Promise<string> {
275
+ if (resolveConversationSessionId != null) {
276
+ return resolveConversationSessionId(remoteId);
277
+ }
278
+ return remoteId;
279
+ }
280
+
281
+ function resolveTurnInput(snapshot: SessionSnapshot, turnId: string): TurnInputItem[] | undefined {
282
+ const turnRecord = snapshot.turns.find(turn => turn.id === turnId);
283
+ if (turnRecord?.input != null) {
284
+ return turnRecord.input;
285
+ }
286
+ if (snapshot.pendingUser?.turnId === turnId) {
287
+ return [{ type: 'user.message', content: snapshot.pendingUser.content }];
288
+ }
289
+ return undefined;
290
+ }
291
+
292
+ export function useTrueForgeAgentMessages({
293
+ server,
294
+ sessionId,
295
+ isMain,
296
+ isInitialSession,
297
+ onError,
298
+ initializeSession,
299
+ resolveConversationSessionId,
300
+ getTurnHeaders,
301
+ }: UseTrueForgeAgentMessagesOptions) {
302
+ const [snapshot, setSnapshot] = useState<SessionSnapshot>(createEmptySessionSnapshot);
303
+ const [isRunning, setIsRunning] = useState(false);
304
+ // Existing sessions have history pending from the first render. Starting at
305
+ // false causes consumers to briefly render an empty thread before the load
306
+ // effect runs and flips this flag to true.
307
+ const [isLoading, setIsLoading] = useState(sessionId != null && (isMain !== false || isInitialSession === true));
308
+ const [isLoadingOlderHistory, setIsLoadingOlderHistory] = useState(false);
309
+ const [loadRetryTrigger, setLoadRetryTrigger] = useState(0);
310
+ const [resumeUnavailable, setResumeUnavailable] = useState(false);
311
+
312
+ const snapshotRef = useRef(snapshot);
313
+ snapshotRef.current = snapshot;
314
+ // Live session id — stale loadOlderHistory / resolveSandboxIdForTurn
315
+ // closures compare against this so a post-switch iteration cannot merge
316
+ // session A's pages onto session B's snapshot (generation alone is not
317
+ // enough: a late call captures B's generation while still closed over A).
318
+ const sessionIdRef = useRef(sessionId);
319
+ sessionIdRef.current = sessionId;
320
+ const loadOlderInflightRef = useRef<Promise<void> | null>(null);
321
+
322
+ const onErrorRef = useRef(onError);
323
+ onErrorRef.current = onError;
324
+ const resolveConversationSessionIdRef = useRef(resolveConversationSessionId);
325
+ resolveConversationSessionIdRef.current = resolveConversationSessionId;
326
+ const initializeSessionRef = useRef(initializeSession);
327
+ initializeSessionRef.current = initializeSession;
328
+ const getTurnHeadersRef = useRef(getTurnHeaders);
329
+ getTurnHeadersRef.current = getTurnHeaders;
330
+
331
+ const createdAtByMessageIdRef = useRef(new Map<string, Date>());
332
+ const abortControllerRef = useRef<AbortController | null>(null);
333
+ const activeRunRef = useRef<Promise<void> | null>(null);
334
+ // Mirrors `resumeUnavailable` for `cancel`, which reads it outside render.
335
+ const resumeUnavailableRef = useRef(false);
336
+ const runningTurnRef = useRef<Turn | undefined>(undefined);
337
+ const loadGenerationRef = useRef(0);
338
+ const streamGenerationRef = useRef(0);
339
+ const lazilyCreatedSessionIdRef = useRef<string | undefined>(undefined);
340
+ const initialLoadStartedForRef = useRef<string | undefined>(undefined);
341
+ const skipInitialPromotionLoadForRef = useRef<string | undefined>(undefined);
342
+
343
+ /**
344
+ * A turn is running that this server cannot stream. Nothing will deliver its
345
+ * result to this client, so the UI shows a waiting state until the run is
346
+ * cancelled or the session is reloaded.
347
+ */
348
+ const markResumeUnavailable = useCallback((value: boolean) => {
349
+ resumeUnavailableRef.current = value;
350
+ setResumeUnavailable(value);
351
+ }, []);
352
+
353
+ const projectOptions = useMemo(
354
+ () => ({
355
+ getCreatedAt: (messageId: string, fallback: Date, replace = false) => {
356
+ const cache = createdAtByMessageIdRef.current;
357
+ const existing = cache.get(messageId);
358
+ if (existing != null && (!replace || existing.getTime() === fallback.getTime())) {
359
+ return existing;
360
+ }
361
+ cache.set(messageId, fallback);
362
+ return fallback;
363
+ },
364
+ }),
365
+ [],
366
+ );
367
+
368
+ const messages = useMemo(() => projectSessionMessages(snapshot, projectOptions), [snapshot, projectOptions]);
369
+
370
+ const runStream = useCallback(
371
+ (
372
+ createStream: (signal: AbortSignal) => AsyncGenerator<TurnStreamUpdate>,
373
+ /**
374
+ * A mutable ref whose `.current` is the turn ID to use for
375
+ * `activeStream.turnId`. Callers that capture the gateway turn ID
376
+ * via `onTurnIdAvailable` update this ref in-place so that both the
377
+ * pending-update flush and `commitActiveStream` always see the real
378
+ * gateway ID rather than the locally-generated optimistic one.
379
+ */
380
+ turnIdRef: { current: string },
381
+ isContinuation: boolean,
382
+ ): Promise<void> => {
383
+ const streamGeneration = ++streamGenerationRef.current;
384
+ abortControllerRef.current?.abort();
385
+ const abortController = new AbortController();
386
+ abortControllerRef.current = abortController;
387
+ setIsRunning(true);
388
+ // This stream's `finally` owns the running flag from here on.
389
+ markResumeUnavailable(false);
390
+
391
+ const run = (async () => {
392
+ // Sub-agent turns can emit 100+ stream events per frame. Coalesce to one
393
+ // setSnapshot per animation frame so assistant-ui does not remount the whole
394
+ // message tree (UI hang). The buffer belongs to this stream only.
395
+ let pendingStreamUpdate: {
396
+ update: TurnStreamUpdate;
397
+ isContinuation: boolean;
398
+ } | null = null;
399
+ let streamUpdateRaf: number | null = null;
400
+
401
+ const flushPendingStreamUpdate = () => {
402
+ streamUpdateRaf = null;
403
+ const pending = pendingStreamUpdate;
404
+ pendingStreamUpdate = null;
405
+ if (pending == null || streamGeneration !== streamGenerationRef.current) {
406
+ return;
407
+ }
408
+ const { update, isContinuation: pendingIsContinuation } = pending;
409
+ setSnapshot(prev =>
410
+ replaceSessionSnapshot(prev, {
411
+ activeStream: {
412
+ // Read from the ref so we always use the latest ID,
413
+ // including any gateway ID that arrived after the RAf
414
+ // was scheduled.
415
+ turnId: turnIdRef.current,
416
+ update,
417
+ isContinuation: pendingIsContinuation,
418
+ },
419
+ }),
420
+ );
421
+ };
422
+
423
+ const applyStreamUpdate = (update: TurnStreamUpdate) => {
424
+ pendingStreamUpdate = { update, isContinuation };
425
+ streamUpdateRaf ??= requestAnimationFrame(flushPendingStreamUpdate);
426
+ };
427
+
428
+ try {
429
+ for await (const update of createStream(abortController.signal)) {
430
+ if (abortController.signal.aborted) {
431
+ return;
432
+ }
433
+ applyStreamUpdate(update);
434
+ }
435
+ } catch (error) {
436
+ if (error instanceof Error && error.name === 'AbortError') {
437
+ return;
438
+ }
439
+ onErrorRef.current?.(error);
440
+ throw error;
441
+ } finally {
442
+ cancelScheduledAnimationFrame(streamUpdateRaf);
443
+ if (streamGeneration === streamGenerationRef.current) {
444
+ flushPendingStreamUpdate();
445
+ if (abortControllerRef.current === abortController) {
446
+ abortControllerRef.current = null;
447
+ }
448
+ setIsRunning(false);
449
+ setSnapshot(prev => {
450
+ if (prev.activeStream == null) {
451
+ return prev;
452
+ }
453
+ const marked = replaceSessionSnapshot(prev, {
454
+ activeStream: {
455
+ ...prev.activeStream,
456
+ streamComplete: true,
457
+ },
458
+ requiredActions: {
459
+ approvals: new Map(),
460
+ toolResponses: new Map(),
461
+ },
462
+ });
463
+ return commitActiveStream(marked);
464
+ });
465
+ }
466
+ }
467
+ })();
468
+
469
+ activeRunRef.current = run;
470
+ void run
471
+ .catch(() => undefined)
472
+ .finally(() => {
473
+ if (activeRunRef.current === run) {
474
+ activeRunRef.current = null;
475
+ }
476
+ });
477
+ return run;
478
+ },
479
+ [onError],
480
+ );
481
+
482
+ const load = useCallback(async () => {
483
+ // Reading the retry counter intentionally makes retryLoad recreate this callback.
484
+ void loadRetryTrigger;
485
+ if (sessionId == null) {
486
+ createdAtByMessageIdRef.current = new Map();
487
+ setSnapshot(createEmptySessionSnapshot());
488
+ return;
489
+ }
490
+
491
+ // Allow the URL-selected session one early load before assistant-ui marks
492
+ // it main. Suppress only that first promotion; later selections still reload.
493
+ const isEarlyInitialLoad =
494
+ isMain === false && isInitialSession === true && initialLoadStartedForRef.current !== sessionId;
495
+ if (isMain === false) {
496
+ if (!isEarlyInitialLoad) {
497
+ return;
498
+ }
499
+ initialLoadStartedForRef.current = sessionId;
500
+ skipInitialPromotionLoadForRef.current = sessionId;
501
+ } else if (isMain === true && skipInitialPromotionLoadForRef.current === sessionId) {
502
+ skipInitialPromotionLoadForRef.current = undefined;
503
+ return;
504
+ }
505
+ if (isInitialSession === true) {
506
+ initialLoadStartedForRef.current = sessionId;
507
+ }
508
+
509
+ // When we are loading a *different* session the user has navigated away
510
+ // from the lazily-created one — clear the guard so navigating back to it
511
+ // later triggers a proper reload instead of silently skipping.
512
+ if (lazilyCreatedSessionIdRef.current != null && sessionId !== lazilyCreatedSessionIdRef.current) {
513
+ lazilyCreatedSessionIdRef.current = undefined;
514
+ }
515
+
516
+ if (sessionId === lazilyCreatedSessionIdRef.current) {
517
+ return;
518
+ }
519
+
520
+ const generation = ++loadGenerationRef.current;
521
+ ++streamGenerationRef.current;
522
+ setIsRunning(false);
523
+ markResumeUnavailable(false);
524
+ abortControllerRef.current?.abort();
525
+ loadOlderInflightRef.current = null;
526
+ createdAtByMessageIdRef.current = new Map();
527
+ setSnapshot(createEmptySessionSnapshot());
528
+ setIsLoading(true);
529
+ setIsLoadingOlderHistory(false);
530
+
531
+ try {
532
+ const conversationSessionId = await resolveActiveSessionId(sessionId, resolveConversationSessionIdRef.current);
533
+ const loadedSnapshot = await loadSessionSnapshot(server, conversationSessionId, snap => {
534
+ if (generation === loadGenerationRef.current) {
535
+ setSnapshot(snap);
536
+ }
537
+ });
538
+ if (generation !== loadGenerationRef.current) {
539
+ return;
540
+ }
541
+
542
+ createdAtByMessageIdRef.current = new Map();
543
+ setSnapshot(loadedSnapshot);
544
+ runningTurnRef.current = loadedSnapshot.runningTurn;
545
+
546
+ // History (and any seeded pendingUser for the in-flight turn) is
547
+ // ready — clear loading before resuming. Awaiting the subscribe
548
+ // stream here previously kept isLoading true for the entire
549
+ // backend run, so reconnect UIs stayed on shimmers even though
550
+ // subscribe was already live.
551
+ setIsLoading(false);
552
+
553
+ if (loadedSnapshot.runningTurn != null) {
554
+ const turn = loadedSnapshot.runningTurn;
555
+
556
+ // subscribeToTurn is optional, so a server can leave us without
557
+ // a reconnect path. The turn still runs on the backend: show the
558
+ // loaded history as running and let the host explain the gap.
559
+ if (server.subscribeToTurn == null) {
560
+ setIsRunning(true);
561
+ markResumeUnavailable(true);
562
+ return;
563
+ }
564
+
565
+ const isContinuation = extractTurnUserText(turn.input) === undefined;
566
+ // TODO: pass afterSequenceNumber once stream ingestion tracks sequence numbers.
567
+ // Use loadedSnapshot directly — snapshotRef.current still points at
568
+ // the empty snapshot cleared above until the setSnapshot(loadedSnapshot)
569
+ // call re-renders.
570
+ void runStream(
571
+ signal =>
572
+ resumeTurnStream(
573
+ server,
574
+ conversationSessionId,
575
+ turn.id,
576
+ loadedSnapshot.fold,
577
+ signal,
578
+ undefined,
579
+ loadedSnapshot.groupRootBaseline,
580
+ ),
581
+ { current: turn.id },
582
+ isContinuation,
583
+ ).catch(() => undefined);
584
+ }
585
+ } catch (error) {
586
+ if (generation === loadGenerationRef.current) {
587
+ if (isEarlyInitialLoad) {
588
+ // Allow retryLoad while still backgrounded (before isMain promotion).
589
+ initialLoadStartedForRef.current = undefined;
590
+ skipInitialPromotionLoadForRef.current = undefined;
591
+ }
592
+ onErrorRef.current?.(error);
593
+ }
594
+ throw error;
595
+ } finally {
596
+ if (generation === loadGenerationRef.current) {
597
+ setIsLoading(false);
598
+ }
599
+ }
600
+ }, [server, runStream, sessionId, loadRetryTrigger, isMain, isInitialSession]);
601
+
602
+ useEffect(() => {
603
+ void load().catch(() => undefined);
604
+ }, [load]);
605
+
606
+ const sendTurn = useCallback(
607
+ async (options: SendTurnOptions) => {
608
+ // A turn.created event means the gateway registered the user message.
609
+ // Errors after that point must keep the message in chat.
610
+ const gatewayTurnAccepted = { current: false };
611
+ let pendingUserWasSet = false;
612
+ let runStreamStarted = false;
613
+ let pendingUserTurnId: string | undefined;
614
+
615
+ try {
616
+ let activeSessionId = sessionId;
617
+ if (activeSessionId == null) {
618
+ if (initializeSessionRef.current == null) {
619
+ throw new Error('Cannot send a turn without an active session.');
620
+ }
621
+ const { remoteId } = await initializeSessionRef.current();
622
+ activeSessionId = remoteId;
623
+ lazilyCreatedSessionIdRef.current = remoteId;
624
+ }
625
+
626
+ const conversationSessionId = await resolveActiveSessionId(
627
+ activeSessionId,
628
+ resolveConversationSessionIdRef.current,
629
+ );
630
+ const turnHeaders = await getTurnHeadersRef.current?.();
631
+ const streamHeaders = turnHeaders != null ? { headers: turnHeaders } : {};
632
+ const isContinuation = 'inputs' in options || ('resumeMcpAuth' in options && options.resumeMcpAuth);
633
+ const continuationTurnId = snapshotRef.current.activeStream?.turnId;
634
+ const turnId = isContinuation
635
+ ? // A paused stream is usually already committed (commitActiveStream
636
+ // cleared activeStream), so continue under the committed turn's
637
+ // real id. Never mint a local id for a continuation — it leaks to
638
+ // the backend via `custom.turnId` (sandbox downloads, edit/retry)
639
+ // as a turn the gateway has never heard of.
640
+ (continuationTurnId ?? snapshotRef.current.turns.at(-1)?.id ?? crypto.randomUUID())
641
+ : crypto.randomUUID();
642
+ // First turns must send previousTurnId: "none".
643
+ const isFirstTurnInSession =
644
+ 'userMessage' in options &&
645
+ options.previousTurnId === undefined &&
646
+ snapshotRef.current.turns.length === 0 &&
647
+ snapshotRef.current.pendingUser == null &&
648
+ snapshotRef.current.activeStream == null;
649
+
650
+ // Mutable ref so runStream always reads the latest ID. The local
651
+ // placeholder (optimistic `generateId()` or the previous turn's id
652
+ // for continuations) is replaced with the gateway-assigned ID once
653
+ // the first SSE event arrives.
654
+ const turnIdRef = { current: turnId };
655
+
656
+ // Renames the placeholder ID to the gateway turn ID so that
657
+ // edit/retry and sandbox downloads can resolve the turn via the
658
+ // gateway. Wired into every stream branch — continuation turns
659
+ // (approval / ask-user / MCP-auth resumes) are new gateway turns
660
+ // too, and committing them under a local id corrupts the record.
661
+ const handleGatewayTurnId = (gatewayTurnId: string) => {
662
+ const oldId = turnIdRef.current;
663
+ // turn.created proves the gateway registered the message.
664
+ gatewayTurnAccepted.current = true;
665
+ if (gatewayTurnId === oldId) {
666
+ return;
667
+ }
668
+ turnIdRef.current = gatewayTurnId;
669
+ // Rename in the ref immediately so any synchronous read
670
+ // (e.g. commitActiveStream) sees the correct ID.
671
+ const renamePendingUser = (prev: SessionSnapshot): SessionSnapshot => {
672
+ if (prev.pendingUser?.turnId !== oldId) {
673
+ return prev;
674
+ }
675
+ return replaceSessionSnapshot(prev, {
676
+ pendingUser: {
677
+ ...prev.pendingUser,
678
+ turnId: gatewayTurnId,
679
+ },
680
+ });
681
+ };
682
+ snapshotRef.current = renamePendingUser(snapshotRef.current);
683
+ setSnapshot(renamePendingUser);
684
+ };
685
+
686
+ if ('inputs' in options) {
687
+ applyUserToolResponsesToFold(snapshotRef.current.fold, options.inputs);
688
+ }
689
+
690
+ const branchBase = 'userMessage' in options ? options.branchFromSnapshot : undefined;
691
+
692
+ let groupRootBaseline: readonly string[] | undefined;
693
+
694
+ if (branchBase != null && 'userMessage' in options) {
695
+ // Atomic apply: never merge pendingUser onto a stale React `prev`
696
+ // that still holds pre-branch turns (edit would show old + new).
697
+ const rootBucket = branchBase.fold.threads.get(ROOT_THREAD_ID);
698
+ groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])];
699
+ const nextSnapshot = replaceSessionSnapshot(branchBase, {
700
+ pendingUser: {
701
+ turnId,
702
+ content: options.userMessage,
703
+ createdAt: new Date(),
704
+ },
705
+ activeStream: undefined,
706
+ groupRootBaseline,
707
+ });
708
+ snapshotRef.current = nextSnapshot;
709
+ setSnapshot(nextSnapshot);
710
+ pendingUserWasSet = true;
711
+ pendingUserTurnId = turnId;
712
+ } else {
713
+ setSnapshot(prev => commitActiveStream(prev, 'inputs' in options ? options.inputs : undefined));
714
+
715
+ if ('userMessage' in options) {
716
+ const rootBucket = snapshotRef.current.fold.threads.get(ROOT_THREAD_ID);
717
+ groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])];
718
+ setSnapshot(prev => {
719
+ const next = replaceSessionSnapshot(prev, {
720
+ pendingUser: {
721
+ turnId,
722
+ content: options.userMessage,
723
+ createdAt: new Date(),
724
+ },
725
+ activeStream: undefined,
726
+ groupRootBaseline,
727
+ });
728
+ snapshotRef.current = next;
729
+ return next;
730
+ });
731
+ pendingUserWasSet = true;
732
+ pendingUserTurnId = turnId;
733
+ } else {
734
+ groupRootBaseline =
735
+ snapshotRef.current.groupRootBaseline ?? computeGroupRootBaseline(snapshotRef.current.turns);
736
+ }
737
+ }
738
+
739
+ runStreamStarted = true;
740
+ await runStream(
741
+ signal => {
742
+ if ('inputs' in options) {
743
+ return streamTurnContent(
744
+ server,
745
+ conversationSessionId,
746
+ snapshotRef.current.fold,
747
+ { inputs: options.inputs, ...streamHeaders },
748
+ signal,
749
+ groupRootBaseline,
750
+ handleGatewayTurnId,
751
+ );
752
+ }
753
+ if ('resumeMcpAuth' in options) {
754
+ return streamTurnContent(
755
+ server,
756
+ conversationSessionId,
757
+ snapshotRef.current.fold,
758
+ { resumeMcpAuth: true, ...streamHeaders },
759
+ signal,
760
+ groupRootBaseline,
761
+ handleGatewayTurnId,
762
+ );
763
+ }
764
+ return streamTurnContent(
765
+ server,
766
+ conversationSessionId,
767
+ snapshotRef.current.fold,
768
+ {
769
+ userMessage: options.userMessage,
770
+ ...(options.previousTurnId !== undefined
771
+ ? { previousTurnId: options.previousTurnId ?? 'none' }
772
+ : isFirstTurnInSession
773
+ ? { previousTurnId: 'none' }
774
+ : {}),
775
+ ...streamHeaders,
776
+ },
777
+ signal,
778
+ groupRootBaseline,
779
+ handleGatewayTurnId,
780
+ );
781
+ },
782
+ turnIdRef,
783
+ isContinuation,
784
+ );
785
+ } catch (error) {
786
+ if ('userMessage' in options && !gatewayTurnAccepted.current) {
787
+ const branchRollbackSnapshot = options.branchRollbackSnapshot;
788
+ const canRestoreBranch =
789
+ branchRollbackSnapshot != null &&
790
+ (snapshotRef.current === options.branchFromSnapshot ||
791
+ snapshotRef.current.pendingUser?.turnId === pendingUserTurnId);
792
+ if (canRestoreBranch) {
793
+ snapshotRef.current = branchRollbackSnapshot;
794
+ setSnapshot(branchRollbackSnapshot);
795
+ } else if (pendingUserWasSet) {
796
+ const clearPendingUser = (previous: SessionSnapshot): SessionSnapshot => {
797
+ if (previous.pendingUser?.turnId !== pendingUserTurnId) {
798
+ return previous;
799
+ }
800
+ return replaceSessionSnapshot(previous, {
801
+ pendingUser: undefined,
802
+ });
803
+ };
804
+ snapshotRef.current = clearPendingUser(snapshotRef.current);
805
+ setSnapshot(clearPendingUser);
806
+ }
807
+ options.onPreTurnFailure?.();
808
+ }
809
+ if (!runStreamStarted) {
810
+ onErrorRef.current?.(error);
811
+ }
812
+ throw error;
813
+ }
814
+ },
815
+ [server, runStream, sessionId],
816
+ );
817
+
818
+ const cancel = useCallback(async () => {
819
+ // A turn can start before the thread list publishes `remoteId`, and that
820
+ // run still has a backend session to stop.
821
+ const activeSessionId = sessionId ?? lazilyCreatedSessionIdRef.current;
822
+ if (activeSessionId == null) {
823
+ abortControllerRef.current?.abort();
824
+ return;
825
+ }
826
+ const conversationSessionId = await resolveActiveSessionId(
827
+ activeSessionId,
828
+ resolveConversationSessionIdRef.current,
829
+ );
830
+ // Request cancellation but keep consuming the stream. After cancel(),
831
+ // the backend gracefully closes the SSE stream: it emits a terminal
832
+ // turn.done event and then ends the stream, which lets the active run
833
+ // drain to completion on its own instead of being torn down mid-flight.
834
+ await server.cancelSession({ sessionId: conversationSessionId }).catch(() => undefined);
835
+ // Wait for the in-flight stream to finish draining. No explicit
836
+ // reconcile is needed here — the cancelled turn is terminal and local
837
+ // state reconciles against the event log on the next session load.
838
+ await activeRunRef.current?.catch(() => undefined);
839
+ // Nothing drained when the load could not attach a stream, so clear the
840
+ // running flag here or the composer stays blocked until a reload.
841
+ if (resumeUnavailableRef.current) {
842
+ markResumeUnavailable(false);
843
+ setIsRunning(false);
844
+ }
845
+ }, [server, sessionId, markResumeUnavailable]);
846
+
847
+ const isRunningRef = useRef(isRunning);
848
+ isRunningRef.current = isRunning;
849
+
850
+ const trySendCollectedRequiredActions = useCallback(
851
+ (nextSnapshot: SessionSnapshot) => {
852
+ if (isRunningRef.current) {
853
+ return;
854
+ }
855
+ const projected = projectSessionMessages(nextSnapshot, projectOptions);
856
+ const paused = findPausedAssistantMessage(projected);
857
+ if (paused == null || messageHasPendingRequiredActions(paused)) {
858
+ return;
859
+ }
860
+ const inputs = collectRequiredActionInputs(paused);
861
+ if (inputs.length > 0) {
862
+ // sendTurn/runStream already report via onError; swallow to avoid duplicates.
863
+ void sendTurn({ inputs }).catch(() => undefined);
864
+ }
865
+ },
866
+ [projectOptions, sendTurn],
867
+ );
868
+
869
+ const respondToToolApproval = useCallback(
870
+ (response: RespondToToolApprovalOptions) => {
871
+ const prev = snapshotRef.current;
872
+ const approvals = new Map(prev.requiredActions.approvals);
873
+ approvals.set(response.approvalId, {
874
+ approved: response.approved,
875
+ ...(response.reason != null ? { reason: response.reason } : {}),
876
+ });
877
+ const nextSnapshot = replaceSessionSnapshot(prev, {
878
+ requiredActions: {
879
+ ...prev.requiredActions,
880
+ approvals,
881
+ },
882
+ });
883
+ setSnapshot(nextSnapshot);
884
+ trySendCollectedRequiredActions(nextSnapshot);
885
+ },
886
+ [trySendCollectedRequiredActions],
887
+ );
888
+
889
+ const respondToToolResponse = useCallback(
890
+ (response: RespondToToolResponseOptions) => {
891
+ const prev = snapshotRef.current;
892
+ const toolResponses = new Map(prev.requiredActions.toolResponses);
893
+ toolResponses.set(response.toolCallId, { content: response.content });
894
+ const nextSnapshot = replaceSessionSnapshot(prev, {
895
+ requiredActions: {
896
+ ...prev.requiredActions,
897
+ toolResponses,
898
+ },
899
+ });
900
+ setSnapshot(nextSnapshot);
901
+ trySendCollectedRequiredActions(nextSnapshot);
902
+ },
903
+ [trySendCollectedRequiredActions],
904
+ );
905
+
906
+ const resumeRun = useCallback(async () => {
907
+ const turn = runningTurnRef.current;
908
+ if (turn == null) {
909
+ return;
910
+ }
911
+ if (server.subscribeToTurn == null) {
912
+ markResumeUnavailable(true);
913
+ return;
914
+ }
915
+ // TODO: pass afterSequenceNumber once stream ingestion tracks sequence numbers.
916
+ await runStream(
917
+ signal =>
918
+ resumeTurnStream(
919
+ server,
920
+ turn.sessionId,
921
+ turn.id,
922
+ snapshotRef.current.fold,
923
+ signal,
924
+ undefined,
925
+ snapshotRef.current.groupRootBaseline,
926
+ ),
927
+ { current: turn.id },
928
+ true,
929
+ );
930
+ }, [runStream, server]);
931
+
932
+ const branchFromTurn = useCallback(
933
+ async (turnId: string, userMessage: UserMessageContent) => {
934
+ let committed: SessionSnapshot;
935
+ let previousTurnId: string;
936
+ let rewound: SessionSnapshot;
937
+ try {
938
+ const activeSessionId = sessionId;
939
+ if (activeSessionId == null) {
940
+ throw new Error('Cannot branch from a turn without an active session.');
941
+ }
942
+
943
+ committed = commitActiveStream(snapshotRef.current);
944
+ setSnapshot(committed);
945
+
946
+ await cancel();
947
+
948
+ const conversationSessionId = await resolveActiveSessionId(
949
+ activeSessionId,
950
+ resolveConversationSessionIdRef.current,
951
+ );
952
+ previousTurnId = await resolveGatewayBranchPreviousTurnIdForTurn(server, conversationSessionId, turnId);
953
+ // Rewind to the exact parent used for the new branch. Using the
954
+ // previous item from listTurns could select an abandoned branch.
955
+ rewound = await buildSnapshotThroughTurn(
956
+ server,
957
+ conversationSessionId,
958
+ previousTurnId === 'none' ? null : previousTurnId,
959
+ );
960
+ createdAtByMessageIdRef.current = new Map();
961
+ // Keep the ref aligned before awaiting sendTurn so any intermediate
962
+ // reads (and the atomic pendingUser apply) see the rewound history.
963
+ snapshotRef.current = rewound;
964
+ setSnapshot(rewound);
965
+ } catch (error) {
966
+ // Setup failures never reach sendTurn/runStream reporting.
967
+ onErrorRef.current?.(error);
968
+ throw error;
969
+ }
970
+
971
+ // sendTurn/runStream own error reporting for the turn itself.
972
+ await sendTurn({
973
+ userMessage,
974
+ previousTurnId,
975
+ branchFromSnapshot: rewound,
976
+ branchRollbackSnapshot: committed,
977
+ });
978
+ },
979
+ [cancel, server, sendTurn, sessionId],
980
+ );
981
+
982
+ const resetFromTurn = useCallback(
983
+ async (turnId: string) => {
984
+ const committed = commitActiveStream(snapshotRef.current);
985
+ const originalInput = resolveTurnInput(committed, turnId);
986
+ if (originalInput == null) {
987
+ const error = new Error(`Turn ${turnId} not found in session snapshot`);
988
+ onErrorRef.current?.(error);
989
+ throw error;
990
+ }
991
+ const userMessage = extractTurnUserMessageContent(originalInput);
992
+ await branchFromTurn(turnId, userMessage);
993
+ },
994
+ [branchFromTurn],
995
+ );
996
+
997
+ const editFromTurn = useCallback(
998
+ async (turnId: string, editedText: string) => {
999
+ const committed = commitActiveStream(snapshotRef.current);
1000
+ const originalInput = resolveTurnInput(committed, turnId);
1001
+ if (originalInput == null) {
1002
+ const error = new Error(`Turn ${turnId} not found in session snapshot`);
1003
+ onErrorRef.current?.(error);
1004
+ throw error;
1005
+ }
1006
+ const userMessage = buildEditedUserMessageContent(editedText, originalInput);
1007
+ await branchFromTurn(turnId, userMessage);
1008
+ },
1009
+ [branchFromTurn],
1010
+ );
1011
+
1012
+ const retryLoad = useCallback(() => {
1013
+ setLoadRetryTrigger(n => n + 1);
1014
+ }, []);
1015
+
1016
+ const hasOlderHistory = snapshot.historyPagination?.hasOlder === true;
1017
+
1018
+ const loadOlderHistory = useCallback(async () => {
1019
+ if (sessionId == null || isMain === false) {
1020
+ return;
1021
+ }
1022
+ const requestedSessionId = sessionId;
1023
+ // Stale closure from a prior session — do not touch the live snapshot.
1024
+ if (sessionIdRef.current !== requestedSessionId) {
1025
+ return;
1026
+ }
1027
+ if (loadOlderInflightRef.current != null) {
1028
+ return loadOlderInflightRef.current;
1029
+ }
1030
+
1031
+ const current = snapshotRef.current;
1032
+ if (current.historyPagination?.hasOlder !== true) {
1033
+ return;
1034
+ }
1035
+ if (current.historyPagination.olderPageToken == null) {
1036
+ return;
1037
+ }
1038
+
1039
+ const generation = loadGenerationRef.current;
1040
+ setIsLoadingOlderHistory(true);
1041
+
1042
+ const run = (async () => {
1043
+ const stillCurrent = () =>
1044
+ generation === loadGenerationRef.current && sessionIdRef.current === requestedSessionId;
1045
+ try {
1046
+ const conversationSessionId = await resolveActiveSessionId(
1047
+ requestedSessionId,
1048
+ resolveConversationSessionIdRef.current,
1049
+ );
1050
+ if (!stillCurrent()) {
1051
+ return;
1052
+ }
1053
+ const next = await prependOlderSessionHistory(server, conversationSessionId, snapshotRef.current);
1054
+ if (!stillCurrent()) {
1055
+ return;
1056
+ }
1057
+ // Keep the ref in sync before the next render so awaiting
1058
+ // callers (e.g. resolveSandboxIdForTurn) see the merged history.
1059
+ snapshotRef.current = next;
1060
+ setSnapshot(next);
1061
+ } catch (error) {
1062
+ if (stillCurrent()) {
1063
+ onErrorRef.current?.(error);
1064
+ }
1065
+ throw error;
1066
+ } finally {
1067
+ if (stillCurrent()) {
1068
+ setIsLoadingOlderHistory(false);
1069
+ }
1070
+ loadOlderInflightRef.current = null;
1071
+ }
1072
+ })();
1073
+
1074
+ loadOlderInflightRef.current = run;
1075
+ return run;
1076
+ }, [server, isMain, sessionId]);
1077
+
1078
+ /**
1079
+ * Resolves the sandbox that was current as of `turnId`, paging in older
1080
+ * history when the `sandbox.created` reference predates the loaded window
1081
+ * (deriving it from loaded messages alone caused spurious "No sandbox is
1082
+ * available yet" failures on long sessions).
1083
+ */
1084
+ const resolveSandboxIdForTurn = useCallback(
1085
+ async (turnId: string): Promise<string | undefined> => {
1086
+ const generation = loadGenerationRef.current;
1087
+ const requestedSessionId = sessionId;
1088
+ const stillCurrent = () =>
1089
+ generation === loadGenerationRef.current && sessionIdRef.current === requestedSessionId;
1090
+
1091
+ if (!stillCurrent()) {
1092
+ return undefined;
1093
+ }
1094
+
1095
+ let sandboxId = findSandboxIdInSnapshot(snapshotRef.current, turnId);
1096
+ // ponytail: bounded linear page-in — the gateway has no direct
1097
+ // session→sandbox lookup; a backend lookup route is the upgrade path.
1098
+ for (
1099
+ let i = 0;
1100
+ sandboxId == null &&
1101
+ stillCurrent() &&
1102
+ snapshotRef.current.historyPagination?.hasOlder === true &&
1103
+ i < MAX_SANDBOX_HISTORY_PAGE_INS;
1104
+ i++
1105
+ ) {
1106
+ await loadOlderHistory();
1107
+ if (!stillCurrent()) {
1108
+ return undefined;
1109
+ }
1110
+ sandboxId = findSandboxIdInSnapshot(snapshotRef.current, turnId);
1111
+ }
1112
+ return stillCurrent() ? sandboxId : undefined;
1113
+ },
1114
+ [loadOlderHistory, sessionId],
1115
+ );
1116
+
1117
+ return {
1118
+ messages,
1119
+ isRunning,
1120
+ resumeUnavailable,
1121
+ isLoading,
1122
+ isLoadingOlderHistory,
1123
+ hasOlderHistory,
1124
+ loadOlderHistory,
1125
+ resolveSandboxIdForTurn,
1126
+ retryLoad,
1127
+ sendTurn,
1128
+ cancel,
1129
+ respondToToolApproval,
1130
+ respondToToolResponse,
1131
+ resumeRun,
1132
+ branchFromTurn,
1133
+ resetFromTurn,
1134
+ editFromTurn,
1135
+ };
1136
+ }
1137
+
1138
+ export { findPausedAssistantMessage, MCP_AUTH_RESUME_RUN_CUSTOM_KEY };