@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.
- package/CHANGELOG.md +9 -0
- package/LICENSE +201 -0
- package/README.md +146 -4
- package/dist/chunk-2SQK6TIO.js +104 -0
- package/dist/chunk-2SQK6TIO.js.map +1 -0
- package/dist/index.d.ts +378 -0
- package/dist/index.js +4391 -0
- package/dist/index.js.map +1 -0
- package/dist/server/index.d.ts +1210 -0
- package/dist/server/index.js +9 -0
- package/dist/server/index.js.map +1 -0
- package/package.json +79 -16
- package/src/askUserQuestion.ts +38 -0
- package/src/attachmentAdapter.ts +63 -0
- package/src/collectPending.ts +167 -0
- package/src/constants.ts +2 -0
- package/src/convertTurnMessages.ts +1679 -0
- package/src/createSubAgent.ts +11 -0
- package/src/draft/agentSpec.ts +34 -0
- package/src/draft/draftSessionBridge.ts +28 -0
- package/src/draft/trueforgeDraftThreadListAdapter.ts +73 -0
- package/src/draft/useDraftAgentSpec.ts +289 -0
- package/src/extractTurnUserText.ts +23 -0
- package/src/foldPeerThreads.ts +553 -0
- package/src/hooks.ts +176 -0
- package/src/index.ts +227 -0
- package/src/lastUserMessageText.ts +19 -0
- package/src/listPages.ts +19 -0
- package/src/loadSessionSnapshot.ts +34 -0
- package/src/mcpAuth.ts +35 -0
- package/src/messageCustomMetadata.ts +50 -0
- package/src/modelMessageContent.ts +149 -0
- package/src/modelMessageImageContent.ts +154 -0
- package/src/requiredActionInputs.ts +38 -0
- package/src/sandboxDownload.ts +33 -0
- package/src/server/eventUtils.ts +125 -0
- package/src/server/events.ts +232 -0
- package/src/server/index.ts +178 -0
- package/src/server/types.ts +1191 -0
- package/src/sessionListStartTimestamp.ts +6 -0
- package/src/sessionSnapshot.ts +146 -0
- package/src/sessionThreadMetadata.ts +36 -0
- package/src/sessions.ts +17 -0
- package/src/streamTurn.ts +118 -0
- package/src/toolApproval.ts +413 -0
- package/src/toolResponse.ts +346 -0
- package/src/trueforgeExtras.ts +223 -0
- package/src/trueforgeOwnedSessionsThreadListAdapter.ts +71 -0
- package/src/trueforgeThreadListAdapter.ts +69 -0
- package/src/turnEventHelpers.ts +71 -0
- package/src/turnStreamUpdate.ts +11 -0
- package/src/types.ts +84 -0
- package/src/useTrueForgeAgentMessages.ts +1138 -0
- package/src/useTrueForgeAgentRuntime.ts +308 -0
- 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 };
|