@truefoundry/assistant-ui-runtime 0.1.0-rc.1
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/README.md +567 -0
- package/dist/index.d.ts +300 -0
- package/dist/index.js +4440 -0
- package/dist/index.js.map +1 -0
- package/package.json +70 -0
- package/src/agentSessionModule.d.ts +25 -0
- package/src/agentSpec.ts +44 -0
- package/src/askUserQuestion.ts +37 -0
- package/src/attachmentAdapter.test.ts +56 -0
- package/src/attachmentAdapter.ts +58 -0
- package/src/bindDraftAgentSession.test.ts +55 -0
- package/src/bindDraftAgentSession.ts +66 -0
- package/src/buildEditedUserMessageContent.test.ts +61 -0
- package/src/collectPending.test.ts +69 -0
- package/src/collectPending.ts +165 -0
- package/src/constants.ts +2 -0
- package/src/convertTurnMessages.test.ts +1991 -0
- package/src/convertTurnMessages.ts +1251 -0
- package/src/createSubAgent.ts +8 -0
- package/src/draftAgentConfig.test.ts +88 -0
- package/src/draftSessionBridge.ts +28 -0
- package/src/extractTurnUserText.ts +21 -0
- package/src/foldPeerThreads.test.ts +386 -0
- package/src/foldPeerThreads.ts +587 -0
- package/src/hooks.ts +123 -0
- package/src/index.ts +68 -0
- package/src/lastUserMessageText.ts +19 -0
- package/src/loadSessionSnapshot.test.ts +57 -0
- package/src/loadSessionSnapshot.ts +35 -0
- package/src/mcpAuth.ts +44 -0
- package/src/messageCustomMetadata.ts +37 -0
- package/src/modelMessageContent.ts +135 -0
- package/src/modelMessageImageContent.test.ts +109 -0
- package/src/modelMessageImageContent.ts +193 -0
- package/src/requiredActionInputs.test.ts +394 -0
- package/src/requiredActionInputs.ts +65 -0
- package/src/requiredActionsFromActiveUpdate.test.ts +95 -0
- package/src/sessionListStartTimestamp.ts +6 -0
- package/src/sessionSnapshot.ts +107 -0
- package/src/sessions.ts +36 -0
- package/src/streamTurn.test.ts +243 -0
- package/src/streamTurn.ts +119 -0
- package/src/toolApproval.test.ts +137 -0
- package/src/toolApproval.ts +459 -0
- package/src/toolResponse.test.ts +136 -0
- package/src/toolResponse.ts +427 -0
- package/src/truefoundryDraftThreadListAdapter.test.ts +140 -0
- package/src/truefoundryDraftThreadListAdapter.ts +63 -0
- package/src/truefoundryExtras.ts +45 -0
- package/src/truefoundryThreadListAdapter.test.ts +103 -0
- package/src/truefoundryThreadListAdapter.ts +59 -0
- package/src/turnEventHelpers.ts +98 -0
- package/src/turnStreamUpdate.ts +11 -0
- package/src/types.ts +92 -0
- package/src/useDraftAgentSpec.ts +173 -0
- package/src/useTrueFoundryAgentMessages.test.tsx +723 -0
- package/src/useTrueFoundryAgentMessages.ts +757 -0
- package/src/useTrueFoundryAgentRuntime.ts +268 -0
|
@@ -0,0 +1,757 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { generateId } from "@assistant-ui/core";
|
|
4
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
5
|
+
import type {
|
|
6
|
+
AgentSessionClient,
|
|
7
|
+
McpAuthRequiredEvent,
|
|
8
|
+
ToolApprovalRequiredEvent,
|
|
9
|
+
ToolResponseRequiredEvent,
|
|
10
|
+
Turn,
|
|
11
|
+
TurnInputItem,
|
|
12
|
+
TurnStateDone,
|
|
13
|
+
} from "truefoundry-gateway-sdk/agents";
|
|
14
|
+
import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
|
|
15
|
+
|
|
16
|
+
import { ROOT_THREAD_ID } from "./constants.js";
|
|
17
|
+
import {
|
|
18
|
+
buildEditedUserMessageContent,
|
|
19
|
+
buildSnapshotBeforeTurnIndex,
|
|
20
|
+
computeGroupRootBaseline,
|
|
21
|
+
extractTurnUserMessageContent,
|
|
22
|
+
projectSessionMessages,
|
|
23
|
+
resolveGatewayBranchPreviousTurnId,
|
|
24
|
+
rootModelMessageIdsSinceBaseline,
|
|
25
|
+
userMessageContentToText,
|
|
26
|
+
type UserMessageContent,
|
|
27
|
+
} from "./convertTurnMessages.js";
|
|
28
|
+
import { extractTurnUserText } from "./extractTurnUserText.js";
|
|
29
|
+
import { loadSessionSnapshot } from "./loadSessionSnapshot.js";
|
|
30
|
+
import { MCP_AUTH_RESUME_RUN_CUSTOM_KEY } from "./mcpAuth.js";
|
|
31
|
+
import type { TrueFoundryMessageCustomMetadata } from "./messageCustomMetadata.js";
|
|
32
|
+
import {
|
|
33
|
+
collectRequiredActionInputs,
|
|
34
|
+
findPausedAssistantMessage,
|
|
35
|
+
messageHasPendingRequiredActions,
|
|
36
|
+
type RequiredActionInput,
|
|
37
|
+
} from "./requiredActionInputs.js";
|
|
38
|
+
import { getSession, type GetSessionOptions } from "./sessions.js";
|
|
39
|
+
import {
|
|
40
|
+
createEmptySessionSnapshot,
|
|
41
|
+
replaceSessionSnapshot,
|
|
42
|
+
type SessionSnapshot,
|
|
43
|
+
type SessionTurnRecord,
|
|
44
|
+
} from "./sessionSnapshot.js";
|
|
45
|
+
import { resumeTurnStream, streamTurnContent } from "./streamTurn.js";
|
|
46
|
+
import {
|
|
47
|
+
TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY,
|
|
48
|
+
type RespondToToolApprovalOptions,
|
|
49
|
+
} from "./toolApproval.js";
|
|
50
|
+
import {
|
|
51
|
+
applyUserToolResponsesToFold,
|
|
52
|
+
TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY,
|
|
53
|
+
type RespondToToolResponseOptions,
|
|
54
|
+
} from "./toolResponse.js";
|
|
55
|
+
import type { TurnStreamUpdate } from "./turnStreamUpdate.js";
|
|
56
|
+
|
|
57
|
+
export type UseTrueFoundryAgentMessagesOptions = {
|
|
58
|
+
client: AgentSessionClient;
|
|
59
|
+
sessionId: string | undefined;
|
|
60
|
+
listEventsConcurrency?: number | undefined;
|
|
61
|
+
onError?: ((error: unknown) => void) | undefined;
|
|
62
|
+
initializeSession?: () => Promise<{
|
|
63
|
+
remoteId: string;
|
|
64
|
+
externalId: string | undefined;
|
|
65
|
+
}>;
|
|
66
|
+
/** Maps a thread `remoteId` to the gateway session id used for turns. */
|
|
67
|
+
resolveConversationSessionId?: (remoteId: string) => Promise<string>;
|
|
68
|
+
/** When set, turns bind to `/agents/sessions/{draftSessionId}/turns` after draft validation. */
|
|
69
|
+
draftGateway?: TrueFoundryGateway;
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
export type SendTurnOptions =
|
|
73
|
+
| { userMessage: UserMessageContent; previousTurnId?: string | null }
|
|
74
|
+
| { inputs: RequiredActionInput[] }
|
|
75
|
+
| { resumeMcpAuth: true };
|
|
76
|
+
|
|
77
|
+
function buildCompletedTurnState(
|
|
78
|
+
completedAt: string,
|
|
79
|
+
requiredActions: TurnStateDone["requiredActions"] = [],
|
|
80
|
+
): TurnStateDone {
|
|
81
|
+
return {
|
|
82
|
+
status: "done",
|
|
83
|
+
requiredActions,
|
|
84
|
+
completedAt,
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Reconstructs the pending required actions a paused in-flight update carried,
|
|
90
|
+
* so the pause survives `commitActiveStream`'s synthetic "done" state.
|
|
91
|
+
*
|
|
92
|
+
* `commitActiveStream` fabricates a `TurnStateDone` for the just-finished
|
|
93
|
+
* stream, and the projection derives an assistant message's `requires-action`
|
|
94
|
+
* status from `turn.state.requiredActions` (see `findApprovalRequiredInTurn` /
|
|
95
|
+
* `findResponseRequiredInTurn` / `findMcpAuthRequired`). If we returned an empty
|
|
96
|
+
* list here, the committed turn would look "complete", the projected message
|
|
97
|
+
* would lose its `requires-action` status, and `findPausedAssistantMessage`
|
|
98
|
+
* (used by `trySendCollectedRequiredActions`) would never see it — so answering
|
|
99
|
+
* a tool approval or an `ask_user_question` would never send the resume turn.
|
|
100
|
+
*
|
|
101
|
+
* The paused update already carries the pause state: `status` is
|
|
102
|
+
* `requires-action` and `metadata.custom` holds the pending thread id(s) (and,
|
|
103
|
+
* for MCP, the server list). Only the thread id is read downstream, so an empty
|
|
104
|
+
* `toolCalls` list is sufficient here — the resume inputs are collected from the
|
|
105
|
+
* message content, not from these reconstructed actions.
|
|
106
|
+
*/
|
|
107
|
+
export function requiredActionsFromActiveUpdate(
|
|
108
|
+
update: TurnStreamUpdate,
|
|
109
|
+
): TurnStateDone["requiredActions"] {
|
|
110
|
+
const custom = update.metadata?.custom as TrueFoundryMessageCustomMetadata | undefined;
|
|
111
|
+
const requiredActions: TurnStateDone["requiredActions"] = [];
|
|
112
|
+
const createdAt = new Date().toISOString();
|
|
113
|
+
|
|
114
|
+
if (custom?.pendingMcpAuth === true && Array.isArray(custom.mcpServers)) {
|
|
115
|
+
const mcpAuthRequired: McpAuthRequiredEvent = {
|
|
116
|
+
type: "mcp.auth_required",
|
|
117
|
+
id: generateId(),
|
|
118
|
+
createdAt,
|
|
119
|
+
mcpServers: custom.mcpServers,
|
|
120
|
+
};
|
|
121
|
+
requiredActions.push(mcpAuthRequired);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (update.status?.type === "requires-action") {
|
|
125
|
+
const approvalThreadId = custom?.[TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY];
|
|
126
|
+
if (typeof approvalThreadId === "string") {
|
|
127
|
+
const approvalRequired: ToolApprovalRequiredEvent = {
|
|
128
|
+
type: "tool.approval_required",
|
|
129
|
+
id: generateId(),
|
|
130
|
+
createdAt,
|
|
131
|
+
threadId: approvalThreadId,
|
|
132
|
+
toolCalls: [],
|
|
133
|
+
};
|
|
134
|
+
requiredActions.push(approvalRequired);
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const responseThreadId = custom?.[TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY];
|
|
138
|
+
if (typeof responseThreadId === "string") {
|
|
139
|
+
const responseRequired: ToolResponseRequiredEvent = {
|
|
140
|
+
type: "tool.response_required",
|
|
141
|
+
id: generateId(),
|
|
142
|
+
createdAt,
|
|
143
|
+
threadId: responseThreadId,
|
|
144
|
+
toolCalls: [],
|
|
145
|
+
};
|
|
146
|
+
requiredActions.push(responseRequired);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
return requiredActions;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function buildUserTurnInput(content: UserMessageContent): TurnInputItem {
|
|
154
|
+
return { type: "user.message", content };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function appendTurnInputs(
|
|
158
|
+
base: TurnInputItem[],
|
|
159
|
+
continuationInputs?: RequiredActionInput[],
|
|
160
|
+
): TurnInputItem[] {
|
|
161
|
+
if (continuationInputs == null || continuationInputs.length === 0) {
|
|
162
|
+
return base;
|
|
163
|
+
}
|
|
164
|
+
return [...base, ...continuationInputs];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function commitActiveStream(
|
|
168
|
+
snapshot: SessionSnapshot,
|
|
169
|
+
continuationInputs?: RequiredActionInput[],
|
|
170
|
+
): SessionSnapshot {
|
|
171
|
+
const active = snapshot.activeStream;
|
|
172
|
+
if (active == null || active.streamComplete !== true) {
|
|
173
|
+
return snapshot;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const activeSandboxId = (
|
|
177
|
+
active.update.metadata?.custom as { sandboxId?: string } | undefined
|
|
178
|
+
)?.sandboxId;
|
|
179
|
+
|
|
180
|
+
const completedState = buildCompletedTurnState(
|
|
181
|
+
new Date().toISOString(),
|
|
182
|
+
requiredActionsFromActiveUpdate(active.update),
|
|
183
|
+
);
|
|
184
|
+
const baseline =
|
|
185
|
+
snapshot.groupRootBaseline ?? computeGroupRootBaseline(snapshot.turns);
|
|
186
|
+
const rootModelMessageIds = rootModelMessageIdsSinceBaseline(
|
|
187
|
+
snapshot.fold,
|
|
188
|
+
baseline,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
const lastTurn = snapshot.turns.at(-1);
|
|
192
|
+
if (lastTurn?.id === active.turnId) {
|
|
193
|
+
return replaceSessionSnapshot(snapshot, {
|
|
194
|
+
turns: snapshot.turns.map((turn) =>
|
|
195
|
+
turn.id === active.turnId
|
|
196
|
+
? {
|
|
197
|
+
...turn,
|
|
198
|
+
state: completedState,
|
|
199
|
+
input: appendTurnInputs(turn.input ?? [], continuationInputs),
|
|
200
|
+
...(rootModelMessageIds != null
|
|
201
|
+
? { rootModelMessageIds }
|
|
202
|
+
: {}),
|
|
203
|
+
...(activeSandboxId != null ? { sandboxId: activeSandboxId } : {}),
|
|
204
|
+
}
|
|
205
|
+
: turn,
|
|
206
|
+
),
|
|
207
|
+
pendingUser: undefined,
|
|
208
|
+
activeStream: undefined,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const record: SessionTurnRecord = {
|
|
213
|
+
id: active.turnId,
|
|
214
|
+
createdAt:
|
|
215
|
+
snapshot.pendingUser?.createdAt.toISOString() ?? new Date().toISOString(),
|
|
216
|
+
state: completedState,
|
|
217
|
+
input: appendTurnInputs(
|
|
218
|
+
snapshot.pendingUser
|
|
219
|
+
? [buildUserTurnInput(snapshot.pendingUser.content)]
|
|
220
|
+
: [],
|
|
221
|
+
continuationInputs,
|
|
222
|
+
),
|
|
223
|
+
...(snapshot.pendingUser != null
|
|
224
|
+
? { userText: userMessageContentToText(snapshot.pendingUser.content) }
|
|
225
|
+
: {}),
|
|
226
|
+
...(rootModelMessageIds != null ? { rootModelMessageIds } : {}),
|
|
227
|
+
...(activeSandboxId != null ? { sandboxId: activeSandboxId } : {}),
|
|
228
|
+
};
|
|
229
|
+
|
|
230
|
+
return replaceSessionSnapshot(snapshot, {
|
|
231
|
+
turns: [...snapshot.turns, record],
|
|
232
|
+
pendingUser: undefined,
|
|
233
|
+
activeStream: undefined,
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
async function resolveActiveSessionId(
|
|
238
|
+
remoteId: string,
|
|
239
|
+
resolveConversationSessionId?: (remoteId: string) => Promise<string>,
|
|
240
|
+
): Promise<string> {
|
|
241
|
+
if (resolveConversationSessionId != null) {
|
|
242
|
+
return resolveConversationSessionId(remoteId);
|
|
243
|
+
}
|
|
244
|
+
return remoteId;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function findTurnIndex(snapshot: SessionSnapshot, turnId: string): number {
|
|
248
|
+
const committedIndex = snapshot.turns.findIndex((turn) => turn.id === turnId);
|
|
249
|
+
if (committedIndex !== -1) {
|
|
250
|
+
return committedIndex;
|
|
251
|
+
}
|
|
252
|
+
if (snapshot.pendingUser?.turnId === turnId) {
|
|
253
|
+
return snapshot.turns.length;
|
|
254
|
+
}
|
|
255
|
+
throw new Error(`Turn ${turnId} not found in session snapshot`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function resolveTurnInput(
|
|
259
|
+
snapshot: SessionSnapshot,
|
|
260
|
+
turnId: string,
|
|
261
|
+
): TurnInputItem[] | undefined {
|
|
262
|
+
const turnRecord = snapshot.turns.find((turn) => turn.id === turnId);
|
|
263
|
+
if (turnRecord?.input != null) {
|
|
264
|
+
return turnRecord.input;
|
|
265
|
+
}
|
|
266
|
+
if (snapshot.pendingUser?.turnId === turnId) {
|
|
267
|
+
return [{ type: "user.message", content: snapshot.pendingUser.content }];
|
|
268
|
+
}
|
|
269
|
+
return undefined;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
export function useTrueFoundryAgentMessages({
|
|
273
|
+
client,
|
|
274
|
+
sessionId,
|
|
275
|
+
listEventsConcurrency,
|
|
276
|
+
onError,
|
|
277
|
+
initializeSession,
|
|
278
|
+
resolveConversationSessionId,
|
|
279
|
+
draftGateway,
|
|
280
|
+
}: UseTrueFoundryAgentMessagesOptions) {
|
|
281
|
+
const sessionOptions = useMemo<GetSessionOptions | undefined>(
|
|
282
|
+
() =>
|
|
283
|
+
draftGateway != null ? { draftGateway } : undefined,
|
|
284
|
+
[draftGateway],
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const [snapshot, setSnapshot] = useState<SessionSnapshot>(createEmptySessionSnapshot);
|
|
288
|
+
const [isRunning, setIsRunning] = useState(false);
|
|
289
|
+
const [isLoading, setIsLoading] = useState(false);
|
|
290
|
+
|
|
291
|
+
const snapshotRef = useRef(snapshot);
|
|
292
|
+
snapshotRef.current = snapshot;
|
|
293
|
+
|
|
294
|
+
const createdAtByMessageIdRef = useRef(new Map<string, Date>());
|
|
295
|
+
const abortControllerRef = useRef<AbortController | null>(null);
|
|
296
|
+
const activeRunRef = useRef<Promise<void> | null>(null);
|
|
297
|
+
const runningTurnRef = useRef<Turn | undefined>(undefined);
|
|
298
|
+
const loadGenerationRef = useRef(0);
|
|
299
|
+
const lazilyCreatedSessionIdRef = useRef<string | undefined>(undefined);
|
|
300
|
+
|
|
301
|
+
const projectOptions = useMemo(
|
|
302
|
+
() => ({
|
|
303
|
+
getCreatedAt: (messageId: string, fallback: Date) => {
|
|
304
|
+
const cache = createdAtByMessageIdRef.current;
|
|
305
|
+
const existing = cache.get(messageId);
|
|
306
|
+
if (existing != null) {
|
|
307
|
+
return existing;
|
|
308
|
+
}
|
|
309
|
+
cache.set(messageId, fallback);
|
|
310
|
+
return fallback;
|
|
311
|
+
},
|
|
312
|
+
}),
|
|
313
|
+
[],
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
const messages = useMemo(
|
|
317
|
+
() => projectSessionMessages(snapshot, projectOptions),
|
|
318
|
+
[snapshot, projectOptions],
|
|
319
|
+
);
|
|
320
|
+
|
|
321
|
+
const applyStreamUpdate = useCallback(
|
|
322
|
+
(update: TurnStreamUpdate, turnId: string, isContinuation: boolean) => {
|
|
323
|
+
setSnapshot((prev) =>
|
|
324
|
+
replaceSessionSnapshot(prev, {
|
|
325
|
+
activeStream: {
|
|
326
|
+
turnId,
|
|
327
|
+
update,
|
|
328
|
+
isContinuation,
|
|
329
|
+
},
|
|
330
|
+
}),
|
|
331
|
+
);
|
|
332
|
+
},
|
|
333
|
+
[],
|
|
334
|
+
);
|
|
335
|
+
|
|
336
|
+
const runStream = useCallback(
|
|
337
|
+
(
|
|
338
|
+
createStream: (signal: AbortSignal) => AsyncGenerator<TurnStreamUpdate>,
|
|
339
|
+
turnId: string,
|
|
340
|
+
isContinuation: boolean,
|
|
341
|
+
): Promise<void> => {
|
|
342
|
+
abortControllerRef.current?.abort();
|
|
343
|
+
const abortController = new AbortController();
|
|
344
|
+
abortControllerRef.current = abortController;
|
|
345
|
+
setIsRunning(true);
|
|
346
|
+
|
|
347
|
+
const run = (async () => {
|
|
348
|
+
try {
|
|
349
|
+
for await (const update of createStream(abortController.signal)) {
|
|
350
|
+
if (abortController.signal.aborted) {
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
applyStreamUpdate(update, turnId, isContinuation);
|
|
354
|
+
}
|
|
355
|
+
} catch (error) {
|
|
356
|
+
if (error instanceof Error && error.name === "AbortError") {
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
359
|
+
onError?.(error);
|
|
360
|
+
throw error;
|
|
361
|
+
} finally {
|
|
362
|
+
if (abortControllerRef.current === abortController) {
|
|
363
|
+
abortControllerRef.current = null;
|
|
364
|
+
}
|
|
365
|
+
setIsRunning(false);
|
|
366
|
+
setSnapshot((prev) => {
|
|
367
|
+
if (prev.activeStream == null) {
|
|
368
|
+
return prev;
|
|
369
|
+
}
|
|
370
|
+
const marked = replaceSessionSnapshot(prev, {
|
|
371
|
+
activeStream: {
|
|
372
|
+
...prev.activeStream,
|
|
373
|
+
streamComplete: true,
|
|
374
|
+
},
|
|
375
|
+
requiredActions: {
|
|
376
|
+
approvals: new Map(),
|
|
377
|
+
toolResponses: new Map(),
|
|
378
|
+
},
|
|
379
|
+
});
|
|
380
|
+
return commitActiveStream(marked);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
383
|
+
})();
|
|
384
|
+
|
|
385
|
+
activeRunRef.current = run;
|
|
386
|
+
void run
|
|
387
|
+
.catch(() => undefined)
|
|
388
|
+
.finally(() => {
|
|
389
|
+
if (activeRunRef.current === run) {
|
|
390
|
+
activeRunRef.current = null;
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
return run;
|
|
394
|
+
},
|
|
395
|
+
[applyStreamUpdate, onError],
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
const load = useCallback(async () => {
|
|
399
|
+
if (sessionId == null) {
|
|
400
|
+
createdAtByMessageIdRef.current = new Map();
|
|
401
|
+
setSnapshot(createEmptySessionSnapshot());
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
if (sessionId === lazilyCreatedSessionIdRef.current) {
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
const generation = ++loadGenerationRef.current;
|
|
409
|
+
abortControllerRef.current?.abort();
|
|
410
|
+
setIsLoading(true);
|
|
411
|
+
|
|
412
|
+
try {
|
|
413
|
+
const conversationSessionId = await resolveActiveSessionId(
|
|
414
|
+
sessionId,
|
|
415
|
+
resolveConversationSessionId,
|
|
416
|
+
);
|
|
417
|
+
const loadedSnapshot = await loadSessionSnapshot(
|
|
418
|
+
client,
|
|
419
|
+
conversationSessionId,
|
|
420
|
+
listEventsConcurrency,
|
|
421
|
+
sessionOptions,
|
|
422
|
+
);
|
|
423
|
+
if (generation !== loadGenerationRef.current) {
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
createdAtByMessageIdRef.current = new Map();
|
|
428
|
+
setSnapshot(loadedSnapshot);
|
|
429
|
+
runningTurnRef.current = loadedSnapshot.runningTurn;
|
|
430
|
+
|
|
431
|
+
if (loadedSnapshot.runningTurn != null) {
|
|
432
|
+
const turn = loadedSnapshot.runningTurn;
|
|
433
|
+
const isContinuation = !extractTurnUserText(turn.input);
|
|
434
|
+
// TODO: pass afterSequenceNumber once stream ingestion tracks sequence numbers.
|
|
435
|
+
await runStream(
|
|
436
|
+
(signal) =>
|
|
437
|
+
resumeTurnStream(
|
|
438
|
+
turn,
|
|
439
|
+
snapshotRef.current.fold,
|
|
440
|
+
signal,
|
|
441
|
+
undefined,
|
|
442
|
+
snapshotRef.current.groupRootBaseline,
|
|
443
|
+
),
|
|
444
|
+
turn.id,
|
|
445
|
+
isContinuation,
|
|
446
|
+
);
|
|
447
|
+
}
|
|
448
|
+
} catch (error) {
|
|
449
|
+
onError?.(error);
|
|
450
|
+
throw error;
|
|
451
|
+
} finally {
|
|
452
|
+
if (generation === loadGenerationRef.current) {
|
|
453
|
+
setIsLoading(false);
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}, [client, listEventsConcurrency, onError, resolveConversationSessionId, runStream, sessionId, sessionOptions]);
|
|
457
|
+
|
|
458
|
+
useEffect(() => {
|
|
459
|
+
void load().catch(() => undefined);
|
|
460
|
+
}, [load]);
|
|
461
|
+
|
|
462
|
+
const sendTurn = useCallback(
|
|
463
|
+
async (options: SendTurnOptions) => {
|
|
464
|
+
let activeSessionId = sessionId;
|
|
465
|
+
if (activeSessionId == null) {
|
|
466
|
+
if (initializeSession == null) {
|
|
467
|
+
throw new Error("Cannot send a turn without an active session.");
|
|
468
|
+
}
|
|
469
|
+
const { remoteId } = await initializeSession();
|
|
470
|
+
activeSessionId = remoteId;
|
|
471
|
+
lazilyCreatedSessionIdRef.current = remoteId;
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
const conversationSessionId = await resolveActiveSessionId(
|
|
475
|
+
activeSessionId,
|
|
476
|
+
resolveConversationSessionId,
|
|
477
|
+
);
|
|
478
|
+
const session = await getSession(client, conversationSessionId, sessionOptions);
|
|
479
|
+
const isContinuation =
|
|
480
|
+
"inputs" in options ||
|
|
481
|
+
("resumeMcpAuth" in options && options.resumeMcpAuth === true);
|
|
482
|
+
const continuationTurnId = snapshotRef.current.activeStream?.turnId;
|
|
483
|
+
const turnId =
|
|
484
|
+
isContinuation && continuationTurnId != null
|
|
485
|
+
? continuationTurnId
|
|
486
|
+
: generateId();
|
|
487
|
+
|
|
488
|
+
if ("inputs" in options) {
|
|
489
|
+
applyUserToolResponsesToFold(
|
|
490
|
+
snapshotRef.current.fold,
|
|
491
|
+
options.inputs,
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
setSnapshot((prev) =>
|
|
496
|
+
commitActiveStream(
|
|
497
|
+
prev,
|
|
498
|
+
"inputs" in options ? options.inputs : undefined,
|
|
499
|
+
),
|
|
500
|
+
);
|
|
501
|
+
|
|
502
|
+
let groupRootBaseline: readonly string[] | undefined;
|
|
503
|
+
|
|
504
|
+
if ("userMessage" in options) {
|
|
505
|
+
const rootBucket =
|
|
506
|
+
snapshotRef.current.fold.threads.get(ROOT_THREAD_ID);
|
|
507
|
+
groupRootBaseline = [...(rootBucket?.modelMessageIds ?? [])];
|
|
508
|
+
setSnapshot((prev) =>
|
|
509
|
+
replaceSessionSnapshot(prev, {
|
|
510
|
+
pendingUser: {
|
|
511
|
+
turnId,
|
|
512
|
+
content: options.userMessage,
|
|
513
|
+
createdAt: new Date(),
|
|
514
|
+
},
|
|
515
|
+
activeStream: undefined,
|
|
516
|
+
groupRootBaseline,
|
|
517
|
+
}),
|
|
518
|
+
);
|
|
519
|
+
} else {
|
|
520
|
+
groupRootBaseline =
|
|
521
|
+
snapshotRef.current.groupRootBaseline ??
|
|
522
|
+
computeGroupRootBaseline(snapshotRef.current.turns);
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
await runStream(
|
|
526
|
+
(signal) => {
|
|
527
|
+
if ("inputs" in options) {
|
|
528
|
+
return streamTurnContent(
|
|
529
|
+
session,
|
|
530
|
+
snapshotRef.current.fold,
|
|
531
|
+
{ inputs: options.inputs },
|
|
532
|
+
signal,
|
|
533
|
+
groupRootBaseline,
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
if ("resumeMcpAuth" in options) {
|
|
537
|
+
return streamTurnContent(
|
|
538
|
+
session,
|
|
539
|
+
snapshotRef.current.fold,
|
|
540
|
+
{ resumeMcpAuth: true },
|
|
541
|
+
signal,
|
|
542
|
+
groupRootBaseline,
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
return streamTurnContent(
|
|
546
|
+
session,
|
|
547
|
+
snapshotRef.current.fold,
|
|
548
|
+
{
|
|
549
|
+
userMessage: options.userMessage,
|
|
550
|
+
...(options.previousTurnId !== undefined
|
|
551
|
+
? { previousTurnId: options.previousTurnId }
|
|
552
|
+
: {}),
|
|
553
|
+
},
|
|
554
|
+
signal,
|
|
555
|
+
groupRootBaseline,
|
|
556
|
+
);
|
|
557
|
+
},
|
|
558
|
+
turnId,
|
|
559
|
+
isContinuation,
|
|
560
|
+
);
|
|
561
|
+
},
|
|
562
|
+
[client, initializeSession, resolveConversationSessionId, runStream, sessionId, sessionOptions],
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
const cancel = useCallback(async () => {
|
|
566
|
+
if (sessionId == null) {
|
|
567
|
+
abortControllerRef.current?.abort();
|
|
568
|
+
return;
|
|
569
|
+
}
|
|
570
|
+
const conversationSessionId = await resolveActiveSessionId(
|
|
571
|
+
sessionId,
|
|
572
|
+
resolveConversationSessionId,
|
|
573
|
+
);
|
|
574
|
+
const session = await getSession(client, conversationSessionId, sessionOptions);
|
|
575
|
+
// Request cancellation but keep consuming the stream. After cancel(),
|
|
576
|
+
// the backend gracefully closes the SSE stream: it emits a terminal
|
|
577
|
+
// turn.done event and then ends the stream, which lets the active run
|
|
578
|
+
// drain to completion on its own instead of being torn down mid-flight.
|
|
579
|
+
await session.cancel().catch(() => undefined);
|
|
580
|
+
// Wait for the in-flight stream to finish draining. No explicit
|
|
581
|
+
// reconcile is needed here — the cancelled turn is terminal and local
|
|
582
|
+
// state reconciles against the event log on the next session load.
|
|
583
|
+
await activeRunRef.current?.catch(() => undefined);
|
|
584
|
+
}, [client, resolveConversationSessionId, sessionId, sessionOptions]);
|
|
585
|
+
|
|
586
|
+
const isRunningRef = useRef(isRunning);
|
|
587
|
+
isRunningRef.current = isRunning;
|
|
588
|
+
|
|
589
|
+
const trySendCollectedRequiredActions = useCallback(
|
|
590
|
+
(nextSnapshot: SessionSnapshot) => {
|
|
591
|
+
if (isRunningRef.current) {
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
const projected = projectSessionMessages(nextSnapshot, projectOptions);
|
|
595
|
+
const paused = findPausedAssistantMessage(projected);
|
|
596
|
+
if (paused == null || messageHasPendingRequiredActions(paused)) {
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
const inputs = collectRequiredActionInputs(paused);
|
|
600
|
+
if (inputs.length > 0) {
|
|
601
|
+
void sendTurn({ inputs }).catch((error) => onError?.(error));
|
|
602
|
+
}
|
|
603
|
+
},
|
|
604
|
+
[onError, projectOptions, sendTurn],
|
|
605
|
+
);
|
|
606
|
+
|
|
607
|
+
const respondToToolApproval = useCallback(
|
|
608
|
+
(response: RespondToToolApprovalOptions) => {
|
|
609
|
+
const prev = snapshotRef.current;
|
|
610
|
+
const approvals = new Map(prev.requiredActions.approvals);
|
|
611
|
+
approvals.set(response.approvalId, {
|
|
612
|
+
approved: response.approved,
|
|
613
|
+
...(response.reason != null ? { reason: response.reason } : {}),
|
|
614
|
+
});
|
|
615
|
+
const nextSnapshot = replaceSessionSnapshot(prev, {
|
|
616
|
+
requiredActions: {
|
|
617
|
+
...prev.requiredActions,
|
|
618
|
+
approvals,
|
|
619
|
+
},
|
|
620
|
+
});
|
|
621
|
+
setSnapshot(nextSnapshot);
|
|
622
|
+
trySendCollectedRequiredActions(nextSnapshot);
|
|
623
|
+
},
|
|
624
|
+
[trySendCollectedRequiredActions],
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
const respondToToolResponse = useCallback(
|
|
628
|
+
(response: RespondToToolResponseOptions) => {
|
|
629
|
+
const prev = snapshotRef.current;
|
|
630
|
+
const toolResponses = new Map(prev.requiredActions.toolResponses);
|
|
631
|
+
toolResponses.set(response.toolCallId, { content: response.content });
|
|
632
|
+
const nextSnapshot = replaceSessionSnapshot(prev, {
|
|
633
|
+
requiredActions: {
|
|
634
|
+
...prev.requiredActions,
|
|
635
|
+
toolResponses,
|
|
636
|
+
},
|
|
637
|
+
});
|
|
638
|
+
setSnapshot(nextSnapshot);
|
|
639
|
+
trySendCollectedRequiredActions(nextSnapshot);
|
|
640
|
+
},
|
|
641
|
+
[trySendCollectedRequiredActions],
|
|
642
|
+
);
|
|
643
|
+
|
|
644
|
+
const resumeRun = useCallback(async () => {
|
|
645
|
+
const turn = runningTurnRef.current;
|
|
646
|
+
if (turn == null) {
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
// TODO: pass afterSequenceNumber once stream ingestion tracks sequence numbers.
|
|
650
|
+
await runStream(
|
|
651
|
+
(signal) =>
|
|
652
|
+
resumeTurnStream(
|
|
653
|
+
turn,
|
|
654
|
+
snapshotRef.current.fold,
|
|
655
|
+
signal,
|
|
656
|
+
undefined,
|
|
657
|
+
snapshotRef.current.groupRootBaseline,
|
|
658
|
+
),
|
|
659
|
+
turn.id,
|
|
660
|
+
true,
|
|
661
|
+
);
|
|
662
|
+
}, [runStream]);
|
|
663
|
+
|
|
664
|
+
const branchFromTurn = useCallback(
|
|
665
|
+
async (turnId: string, userMessage: UserMessageContent) => {
|
|
666
|
+
let activeSessionId = sessionId;
|
|
667
|
+
if (activeSessionId == null) {
|
|
668
|
+
throw new Error("Cannot branch from a turn without an active session.");
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
const committed = commitActiveStream(snapshotRef.current);
|
|
672
|
+
setSnapshot(committed);
|
|
673
|
+
|
|
674
|
+
const turnIndex = findTurnIndex(committed, turnId);
|
|
675
|
+
|
|
676
|
+
await cancel();
|
|
677
|
+
|
|
678
|
+
const conversationSessionId = await resolveActiveSessionId(
|
|
679
|
+
activeSessionId,
|
|
680
|
+
resolveConversationSessionId,
|
|
681
|
+
);
|
|
682
|
+
const session = await getSession(client, conversationSessionId, sessionOptions);
|
|
683
|
+
const previousTurnId = await resolveGatewayBranchPreviousTurnId(
|
|
684
|
+
session,
|
|
685
|
+
turnIndex,
|
|
686
|
+
);
|
|
687
|
+
const rewound = await buildSnapshotBeforeTurnIndex(
|
|
688
|
+
session,
|
|
689
|
+
turnIndex,
|
|
690
|
+
listEventsConcurrency,
|
|
691
|
+
);
|
|
692
|
+
createdAtByMessageIdRef.current = new Map();
|
|
693
|
+
setSnapshot(rewound);
|
|
694
|
+
|
|
695
|
+
await sendTurn({
|
|
696
|
+
userMessage,
|
|
697
|
+
previousTurnId,
|
|
698
|
+
});
|
|
699
|
+
},
|
|
700
|
+
[
|
|
701
|
+
cancel,
|
|
702
|
+
client,
|
|
703
|
+
listEventsConcurrency,
|
|
704
|
+
resolveConversationSessionId,
|
|
705
|
+
sendTurn,
|
|
706
|
+
sessionId,
|
|
707
|
+
sessionOptions,
|
|
708
|
+
],
|
|
709
|
+
);
|
|
710
|
+
|
|
711
|
+
const resetFromTurn = useCallback(
|
|
712
|
+
async (turnId: string) => {
|
|
713
|
+
const committed = commitActiveStream(snapshotRef.current);
|
|
714
|
+
const originalInput = resolveTurnInput(committed, turnId);
|
|
715
|
+
if (originalInput == null) {
|
|
716
|
+
throw new Error(`Turn ${turnId} not found in session snapshot`);
|
|
717
|
+
}
|
|
718
|
+
const userMessage = extractTurnUserMessageContent(originalInput);
|
|
719
|
+
await branchFromTurn(turnId, userMessage);
|
|
720
|
+
},
|
|
721
|
+
[branchFromTurn],
|
|
722
|
+
);
|
|
723
|
+
|
|
724
|
+
const editFromTurn = useCallback(
|
|
725
|
+
async (turnId: string, editedText: string) => {
|
|
726
|
+
const committed = commitActiveStream(snapshotRef.current);
|
|
727
|
+
const originalInput = resolveTurnInput(committed, turnId);
|
|
728
|
+
if (originalInput == null) {
|
|
729
|
+
throw new Error(`Turn ${turnId} not found in session snapshot`);
|
|
730
|
+
}
|
|
731
|
+
const userMessage = buildEditedUserMessageContent(
|
|
732
|
+
editedText,
|
|
733
|
+
originalInput,
|
|
734
|
+
);
|
|
735
|
+
await branchFromTurn(turnId, userMessage);
|
|
736
|
+
},
|
|
737
|
+
[branchFromTurn],
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
return {
|
|
741
|
+
messages,
|
|
742
|
+
isRunning,
|
|
743
|
+
isLoading,
|
|
744
|
+
load,
|
|
745
|
+
sendTurn,
|
|
746
|
+
cancel,
|
|
747
|
+
respondToToolApproval,
|
|
748
|
+
respondToToolResponse,
|
|
749
|
+
resumeRun,
|
|
750
|
+
branchFromTurn,
|
|
751
|
+
resetFromTurn,
|
|
752
|
+
editFromTurn,
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
export { findPausedAssistantMessage, MCP_AUTH_RESUME_RUN_CUSTOM_KEY };
|
|
757
|
+
|