@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,587 @@
|
|
|
1
|
+
import type { MessageStatus, ThreadMessage } from "@assistant-ui/core";
|
|
2
|
+
import type { ThreadCreatedEvent, ToolResponseRequiredEvent } from "truefoundry-gateway-sdk/agents";
|
|
3
|
+
import {
|
|
4
|
+
isEventDelta,
|
|
5
|
+
type TurnEvent,
|
|
6
|
+
type TurnStreamingEvent,
|
|
7
|
+
} from "truefoundry-gateway-sdk/agents";
|
|
8
|
+
|
|
9
|
+
import { parseAskUserQuestionArgs } from "./askUserQuestion.js";
|
|
10
|
+
import { isCreateSubAgentToolCall } from "./createSubAgent.js";
|
|
11
|
+
import {
|
|
12
|
+
buildAssistantContent,
|
|
13
|
+
type AssistantContentPart,
|
|
14
|
+
type SdkToolCall,
|
|
15
|
+
} from "./modelMessageContent.js";
|
|
16
|
+
import { mergeStreamEventDelta } from "./modelMessageImageContent.js";
|
|
17
|
+
import { ROOT_THREAD_ID } from "./constants.js";
|
|
18
|
+
import type { SubAgentMessageCustomMetadata } from "./messageCustomMetadata.js";
|
|
19
|
+
import { toolApprovalMessageCustom, toolApprovalStatus } from "./toolApproval.js";
|
|
20
|
+
import { toolResponseMessageCustom, toolResponseStatus } from "./toolResponse.js";
|
|
21
|
+
|
|
22
|
+
export { ROOT_THREAD_ID } from "./constants.js";
|
|
23
|
+
|
|
24
|
+
type AgentInfo = ThreadCreatedEvent["agentInfo"];
|
|
25
|
+
|
|
26
|
+
export type SubAgentCustomMetadata = {
|
|
27
|
+
threadId: string;
|
|
28
|
+
title?: string;
|
|
29
|
+
name?: string;
|
|
30
|
+
model?: string;
|
|
31
|
+
input?: string;
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
export type SubAgentArtifact = {
|
|
35
|
+
subAgents: Array<{
|
|
36
|
+
threadId: string;
|
|
37
|
+
title?: string;
|
|
38
|
+
agentInfo?: AgentInfo;
|
|
39
|
+
}>;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
type ToolCallRef = ToolResponseRequiredEvent["toolCalls"][number];
|
|
43
|
+
|
|
44
|
+
export type PendingResponseRef = {
|
|
45
|
+
id: string;
|
|
46
|
+
sourceEventId: string;
|
|
47
|
+
question?: string;
|
|
48
|
+
options?: string[];
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type ApprovalDecisionRef = {
|
|
52
|
+
id: string;
|
|
53
|
+
approved: boolean;
|
|
54
|
+
reason?: string;
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export type ThreadBucket = {
|
|
58
|
+
events: Map<string, TurnEvent>;
|
|
59
|
+
modelMessageIds: string[];
|
|
60
|
+
toolResults: Map<string, string>;
|
|
61
|
+
pendingApprovals: Map<string, { id: string }>;
|
|
62
|
+
approvalDecisions: Map<string, ApprovalDecisionRef>;
|
|
63
|
+
pendingResponses: Map<string, PendingResponseRef>;
|
|
64
|
+
done: boolean;
|
|
65
|
+
title?: string;
|
|
66
|
+
agentInfo?: AgentInfo;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
export type ThreadParentLink = {
|
|
70
|
+
parentThreadId: string;
|
|
71
|
+
toolCallId: string;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const isDev =
|
|
75
|
+
typeof process !== "undefined" &&
|
|
76
|
+
typeof process.env !== "undefined" &&
|
|
77
|
+
process.env.NODE_ENV !== "production";
|
|
78
|
+
|
|
79
|
+
function warnUnexpectedRootThread(threadId: string, eventType: string): void {
|
|
80
|
+
if (!isDev) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
console.warn(
|
|
84
|
+
`[@truefoundry/assistant-ui-runtime] Expected root thread "${ROOT_THREAD_ID}" but received "${threadId}" on ${eventType}.`,
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function assertRootThreadEvent(threadId: string, eventType: string): void {
|
|
89
|
+
if (threadId === ROOT_THREAD_ID) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (isDev) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`[@truefoundry/assistant-ui-runtime] Root-looking event ${eventType} arrived on thread "${threadId}" instead of "${ROOT_THREAD_ID}".`,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
warnUnexpectedRootThread(threadId, eventType);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export class PeerThreadFoldState {
|
|
101
|
+
readonly threads = new Map<string, ThreadBucket>();
|
|
102
|
+
readonly threadParents = new Map<string, ThreadParentLink>();
|
|
103
|
+
|
|
104
|
+
getOrCreateBucket(threadId: string): ThreadBucket {
|
|
105
|
+
let bucket = this.threads.get(threadId);
|
|
106
|
+
if (bucket == null) {
|
|
107
|
+
bucket = {
|
|
108
|
+
events: new Map(),
|
|
109
|
+
modelMessageIds: [],
|
|
110
|
+
toolResults: new Map(),
|
|
111
|
+
pendingApprovals: new Map(),
|
|
112
|
+
approvalDecisions: new Map(),
|
|
113
|
+
pendingResponses: new Map(),
|
|
114
|
+
done: false,
|
|
115
|
+
};
|
|
116
|
+
this.threads.set(threadId, bucket);
|
|
117
|
+
}
|
|
118
|
+
return bucket;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function isTurnScopedEvent(message: TurnStreamingEvent): boolean {
|
|
123
|
+
return message.type === "turn.created" || message.type === "turn.done";
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function ingestEventIntoBucket(
|
|
127
|
+
bucket: ThreadBucket,
|
|
128
|
+
message: TurnStreamingEvent,
|
|
129
|
+
): void {
|
|
130
|
+
if (isTurnScopedEvent(message)) {
|
|
131
|
+
return;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (isEventDelta(message)) {
|
|
135
|
+
const base = bucket.events.get(message.id);
|
|
136
|
+
if (base != null) {
|
|
137
|
+
mergeStreamEventDelta(base, message);
|
|
138
|
+
}
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
bucket.events.set(message.id, message as TurnEvent);
|
|
143
|
+
|
|
144
|
+
if (message.type === "model.message") {
|
|
145
|
+
if (!bucket.modelMessageIds.includes(message.id)) {
|
|
146
|
+
bucket.modelMessageIds.push(message.id);
|
|
147
|
+
}
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (message.type === "tool.response") {
|
|
152
|
+
bucket.toolResults.set(message.toolCallId, message.content);
|
|
153
|
+
bucket.pendingResponses.delete(message.toolCallId);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
if (message.type === "tool.approval_required") {
|
|
158
|
+
for (const ref of message.toolCalls) {
|
|
159
|
+
bucket.pendingApprovals.set(ref.id, { id: ref.id });
|
|
160
|
+
}
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (message.type === "tool.response_required") {
|
|
165
|
+
for (const ref of message.toolCalls) {
|
|
166
|
+
const resolved = resolveAskUserQuestionFromBucket(bucket, ref);
|
|
167
|
+
bucket.pendingResponses.set(ref.id, {
|
|
168
|
+
id: ref.id,
|
|
169
|
+
sourceEventId: ref.sourceEventId,
|
|
170
|
+
...(resolved?.question != null ? { question: resolved.question } : {}),
|
|
171
|
+
...(resolved?.options != null ? { options: resolved.options } : {}),
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (message.type === "thread.done") {
|
|
178
|
+
bucket.done = true;
|
|
179
|
+
if (message.title) {
|
|
180
|
+
bucket.title = message.title;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
function isContentAffectingEvent(message: TurnStreamingEvent): boolean {
|
|
186
|
+
return (
|
|
187
|
+
message.type === "thread.created" ||
|
|
188
|
+
message.type === "thread.done" ||
|
|
189
|
+
message.type === "model.message" ||
|
|
190
|
+
message.type === "model.message.delta" ||
|
|
191
|
+
message.type === "tool.response" ||
|
|
192
|
+
message.type === "tool.approval_required" ||
|
|
193
|
+
message.type === "tool.response_required"
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export function ingestStreamEvent(
|
|
198
|
+
state: PeerThreadFoldState,
|
|
199
|
+
message: TurnStreamingEvent,
|
|
200
|
+
): boolean {
|
|
201
|
+
if (message.type === "mcp.auth_required" || isTurnScopedEvent(message)) {
|
|
202
|
+
return false;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
if (message.threadId == null) {
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (message.type === "thread.created") {
|
|
210
|
+
state.threadParents.set(message.threadId, {
|
|
211
|
+
parentThreadId: message.parent.threadId,
|
|
212
|
+
toolCallId: message.parent.toolCallId,
|
|
213
|
+
});
|
|
214
|
+
const bucket = state.getOrCreateBucket(message.threadId);
|
|
215
|
+
bucket.title = message.title;
|
|
216
|
+
bucket.agentInfo = message.agentInfo;
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (message.type === "model.message" && message.threadId === ROOT_THREAD_ID) {
|
|
221
|
+
assertRootThreadEvent(message.threadId, message.type);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const bucket = state.getOrCreateBucket(message.threadId);
|
|
225
|
+
ingestEventIntoBucket(bucket, message);
|
|
226
|
+
|
|
227
|
+
return isContentAffectingEvent(message);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
export function ingestTurnEvent(state: PeerThreadFoldState, event: TurnEvent): void {
|
|
231
|
+
if (event.threadId == null) {
|
|
232
|
+
return;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if (event.type === "thread.created") {
|
|
236
|
+
state.threadParents.set(event.threadId, {
|
|
237
|
+
parentThreadId: event.parent.threadId,
|
|
238
|
+
toolCallId: event.parent.toolCallId,
|
|
239
|
+
});
|
|
240
|
+
const bucket = state.getOrCreateBucket(event.threadId);
|
|
241
|
+
bucket.title = event.title;
|
|
242
|
+
bucket.agentInfo = event.agentInfo;
|
|
243
|
+
} else if (event.type === "model.message" && event.threadId === ROOT_THREAD_ID) {
|
|
244
|
+
assertRootThreadEvent(event.threadId, event.type);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
ingestEventIntoBucket(state.getOrCreateBucket(event.threadId), event);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function resolveAskUserQuestionFromBucket(
|
|
251
|
+
bucket: ThreadBucket,
|
|
252
|
+
ref: Pick<ToolCallRef, "id" | "sourceEventId">,
|
|
253
|
+
): { question?: string; options?: string[] } | undefined {
|
|
254
|
+
const modelMessage = bucket.events.get(ref.sourceEventId);
|
|
255
|
+
if (modelMessage?.type !== "model.message") {
|
|
256
|
+
return undefined;
|
|
257
|
+
}
|
|
258
|
+
const toolCall = modelMessage.toolCalls?.find((call) => call.id === ref.id);
|
|
259
|
+
if (toolCall == null) {
|
|
260
|
+
return undefined;
|
|
261
|
+
}
|
|
262
|
+
return parseAskUserQuestionArgs(toolCall.function.arguments);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function findToolCallInBucket(
|
|
266
|
+
bucket: ThreadBucket,
|
|
267
|
+
toolCallId: string,
|
|
268
|
+
): SdkToolCall | undefined {
|
|
269
|
+
for (const id of bucket.modelMessageIds) {
|
|
270
|
+
const event = bucket.events.get(id);
|
|
271
|
+
if (event?.type !== "model.message") {
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
const match = event.toolCalls?.find((toolCall) => toolCall.id === toolCallId);
|
|
275
|
+
if (match != null) {
|
|
276
|
+
return match;
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
return undefined;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function isLinkedCreateSubAgentThread(
|
|
283
|
+
state: PeerThreadFoldState,
|
|
284
|
+
subThreadId: string,
|
|
285
|
+
): boolean {
|
|
286
|
+
const link = state.threadParents.get(subThreadId);
|
|
287
|
+
if (link == null) {
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
const parentBucket = state.threads.get(link.parentThreadId);
|
|
291
|
+
if (parentBucket == null) {
|
|
292
|
+
return false;
|
|
293
|
+
}
|
|
294
|
+
const toolCall = findToolCallInBucket(parentBucket, link.toolCallId);
|
|
295
|
+
return toolCall != null && isCreateSubAgentToolCall(toolCall);
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function childSubThreadIds(
|
|
299
|
+
state: PeerThreadFoldState,
|
|
300
|
+
parentThreadId: string,
|
|
301
|
+
toolCallId: string,
|
|
302
|
+
): string[] {
|
|
303
|
+
const ids: string[] = [];
|
|
304
|
+
for (const [subThreadId, link] of state.threadParents) {
|
|
305
|
+
if (
|
|
306
|
+
link.parentThreadId === parentThreadId &&
|
|
307
|
+
link.toolCallId === toolCallId &&
|
|
308
|
+
isLinkedCreateSubAgentThread(state, subThreadId)
|
|
309
|
+
) {
|
|
310
|
+
ids.push(subThreadId);
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return ids;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function bucketHasUnresolvedPendingResponses(bucket: ThreadBucket): boolean {
|
|
317
|
+
for (const id of bucket.pendingResponses.keys()) {
|
|
318
|
+
if (!bucket.toolResults.has(id)) {
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
return false;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function bucketAssistantStatus(bucket: ThreadBucket): MessageStatus {
|
|
326
|
+
if (bucket.pendingApprovals.size > 0) {
|
|
327
|
+
return toolApprovalStatus();
|
|
328
|
+
}
|
|
329
|
+
if (bucketHasUnresolvedPendingResponses(bucket)) {
|
|
330
|
+
return toolResponseStatus();
|
|
331
|
+
}
|
|
332
|
+
if (!bucket.done && bucket.modelMessageIds.length > 0) {
|
|
333
|
+
return { type: "running" };
|
|
334
|
+
}
|
|
335
|
+
return { type: "complete", reason: "stop" };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function buildSubAgentCustomMetadata(
|
|
339
|
+
threadId: string,
|
|
340
|
+
bucket: ThreadBucket,
|
|
341
|
+
): SubAgentMessageCustomMetadata {
|
|
342
|
+
const metadata: SubAgentCustomMetadata = {
|
|
343
|
+
threadId,
|
|
344
|
+
...(bucket.title != null ? { title: bucket.title } : {}),
|
|
345
|
+
...(bucket.agentInfo?.name != null ? { name: bucket.agentInfo.name } : {}),
|
|
346
|
+
...(bucket.agentInfo?.model != null ? { model: bucket.agentInfo.model } : {}),
|
|
347
|
+
...(bucket.agentInfo?.input != null ? { input: bucket.agentInfo.input } : {}),
|
|
348
|
+
};
|
|
349
|
+
return { subAgent: metadata };
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function attachSubAgentMessages(
|
|
353
|
+
state: PeerThreadFoldState,
|
|
354
|
+
parentThreadId: string,
|
|
355
|
+
parts: AssistantContentPart[],
|
|
356
|
+
): AssistantContentPart[] {
|
|
357
|
+
const parentBucket = state.threads.get(parentThreadId);
|
|
358
|
+
|
|
359
|
+
return parts.map((part) => {
|
|
360
|
+
if (part.type !== "tool-call" || parentBucket == null) {
|
|
361
|
+
return part;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
const sdkToolCall = findToolCallInBucket(parentBucket, part.toolCallId);
|
|
365
|
+
if (sdkToolCall == null || !isCreateSubAgentToolCall(sdkToolCall)) {
|
|
366
|
+
return part;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
const childIds = childSubThreadIds(state, parentThreadId, part.toolCallId);
|
|
370
|
+
if (childIds.length === 0) {
|
|
371
|
+
return part;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const messages: ThreadMessage[] = [];
|
|
375
|
+
const subAgents: SubAgentArtifact["subAgents"] = [];
|
|
376
|
+
for (const childId of childIds) {
|
|
377
|
+
const childMessages = buildSubThreadMessages(state, childId);
|
|
378
|
+
if (childMessages.length > 0) {
|
|
379
|
+
messages.push(...childMessages);
|
|
380
|
+
}
|
|
381
|
+
const childBucket = state.threads.get(childId);
|
|
382
|
+
if (childBucket != null) {
|
|
383
|
+
subAgents.push({
|
|
384
|
+
threadId: childId,
|
|
385
|
+
...(childBucket.title != null ? { title: childBucket.title } : {}),
|
|
386
|
+
...(childBucket.agentInfo != null
|
|
387
|
+
? { agentInfo: childBucket.agentInfo }
|
|
388
|
+
: {}),
|
|
389
|
+
});
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
if (messages.length === 0) {
|
|
393
|
+
return part;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
const artifact: SubAgentArtifact = { subAgents };
|
|
397
|
+
return { ...part, messages, artifact };
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function buildThreadAssistantParts(
|
|
402
|
+
state: PeerThreadFoldState,
|
|
403
|
+
threadId: string,
|
|
404
|
+
modelMessageIds?: readonly string[],
|
|
405
|
+
): AssistantContentPart[] {
|
|
406
|
+
const bucket = state.threads.get(threadId);
|
|
407
|
+
if (bucket == null) {
|
|
408
|
+
return [];
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
const ids =
|
|
412
|
+
threadId === ROOT_THREAD_ID && modelMessageIds != null
|
|
413
|
+
? modelMessageIds
|
|
414
|
+
: bucket.modelMessageIds;
|
|
415
|
+
|
|
416
|
+
const parts: AssistantContentPart[] = [];
|
|
417
|
+
// A single tool call can appear in more than one `model.message` event (e.g.
|
|
418
|
+
// once in the paused turn and again in the resumed turn after a tool
|
|
419
|
+
// approval). assistant-ui keys tool parts by `toolCallId` within a message,
|
|
420
|
+
// so emitting the same id twice crashes the render. Collapse duplicates,
|
|
421
|
+
// letting the latest occurrence win while preserving the original position.
|
|
422
|
+
const toolCallIndexById = new Map<string, number>();
|
|
423
|
+
for (const id of ids) {
|
|
424
|
+
const event = bucket.events.get(id);
|
|
425
|
+
if (event?.type !== "model.message") {
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
for (const part of buildAssistantContent(event, {
|
|
429
|
+
toolResults: bucket.toolResults,
|
|
430
|
+
pendingApprovals: bucket.pendingApprovals,
|
|
431
|
+
approvalDecisions: bucket.approvalDecisions,
|
|
432
|
+
pendingResponses: bucket.pendingResponses,
|
|
433
|
+
})) {
|
|
434
|
+
if (part.type === "tool-call") {
|
|
435
|
+
const existingIndex = toolCallIndexById.get(part.toolCallId);
|
|
436
|
+
if (existingIndex != null) {
|
|
437
|
+
parts[existingIndex] = part;
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
toolCallIndexById.set(part.toolCallId, parts.length);
|
|
441
|
+
}
|
|
442
|
+
parts.push(part);
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
return attachSubAgentMessages(state, threadId, parts);
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
function buildSubThreadMessages(
|
|
450
|
+
state: PeerThreadFoldState,
|
|
451
|
+
threadId: string,
|
|
452
|
+
): ThreadMessage[] {
|
|
453
|
+
const bucket = state.threads.get(threadId);
|
|
454
|
+
if (bucket == null) {
|
|
455
|
+
return [];
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const content = buildThreadAssistantParts(state, threadId);
|
|
459
|
+
if (content.length === 0) {
|
|
460
|
+
return [];
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
const custom = {
|
|
464
|
+
...buildSubAgentCustomMetadata(threadId, bucket),
|
|
465
|
+
...(bucket.pendingApprovals.size > 0
|
|
466
|
+
? toolApprovalMessageCustom(threadId)
|
|
467
|
+
: {}),
|
|
468
|
+
...(bucketHasUnresolvedPendingResponses(bucket)
|
|
469
|
+
? toolResponseMessageCustom(threadId)
|
|
470
|
+
: {}),
|
|
471
|
+
};
|
|
472
|
+
|
|
473
|
+
return [
|
|
474
|
+
{
|
|
475
|
+
id: `${threadId}-assistant`,
|
|
476
|
+
role: "assistant",
|
|
477
|
+
content,
|
|
478
|
+
status: bucketAssistantStatus(bucket),
|
|
479
|
+
createdAt: new Date(),
|
|
480
|
+
metadata: {
|
|
481
|
+
unstable_state: null,
|
|
482
|
+
unstable_annotations: [],
|
|
483
|
+
unstable_data: [],
|
|
484
|
+
steps: [],
|
|
485
|
+
custom,
|
|
486
|
+
},
|
|
487
|
+
},
|
|
488
|
+
];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export function buildRootAssistantContent(
|
|
492
|
+
state: PeerThreadFoldState,
|
|
493
|
+
): AssistantContentPart[] {
|
|
494
|
+
return buildThreadAssistantParts(state, ROOT_THREAD_ID);
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export function buildRootAssistantContentForIds(
|
|
498
|
+
state: PeerThreadFoldState,
|
|
499
|
+
modelMessageIds: readonly string[],
|
|
500
|
+
): AssistantContentPart[] {
|
|
501
|
+
return buildThreadAssistantParts(state, ROOT_THREAD_ID, modelMessageIds);
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
export function findFirstPendingApprovalThreadId(
|
|
505
|
+
state: PeerThreadFoldState,
|
|
506
|
+
): string | undefined {
|
|
507
|
+
for (const [threadId, bucket] of state.threads) {
|
|
508
|
+
if (bucket.pendingApprovals.size > 0) {
|
|
509
|
+
return threadId;
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return undefined;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
export function findFirstPendingResponseThreadId(
|
|
516
|
+
state: PeerThreadFoldState,
|
|
517
|
+
): string | undefined {
|
|
518
|
+
for (const [threadId, bucket] of state.threads) {
|
|
519
|
+
if (bucketHasUnresolvedPendingResponses(bucket)) {
|
|
520
|
+
return threadId;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
return undefined;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
export function resolveAskUserQuestion(
|
|
527
|
+
state: PeerThreadFoldState,
|
|
528
|
+
threadId: string,
|
|
529
|
+
ref: Pick<ToolCallRef, "id" | "sourceEventId">,
|
|
530
|
+
): { question?: string; options?: string[] } | undefined {
|
|
531
|
+
const bucket = state.threads.get(threadId);
|
|
532
|
+
if (bucket == null) {
|
|
533
|
+
return undefined;
|
|
534
|
+
}
|
|
535
|
+
return resolveAskUserQuestionFromBucket(bucket, ref);
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
export function isRootThreadId(threadId: string | undefined): boolean {
|
|
539
|
+
return threadId === ROOT_THREAD_ID;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
export function recordToolApprovalInFold(
|
|
543
|
+
fold: PeerThreadFoldState,
|
|
544
|
+
decision: { toolCallId: string; approved: boolean; reason?: string },
|
|
545
|
+
): void {
|
|
546
|
+
const record: ApprovalDecisionRef = {
|
|
547
|
+
id: decision.toolCallId,
|
|
548
|
+
approved: decision.approved,
|
|
549
|
+
...(decision.reason != null ? { reason: decision.reason } : {}),
|
|
550
|
+
};
|
|
551
|
+
let applied = false;
|
|
552
|
+
for (const bucket of fold.threads.values()) {
|
|
553
|
+
if (!bucket.pendingApprovals.has(decision.toolCallId)) {
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
bucket.pendingApprovals.delete(decision.toolCallId);
|
|
557
|
+
bucket.approvalDecisions.set(decision.toolCallId, record);
|
|
558
|
+
applied = true;
|
|
559
|
+
}
|
|
560
|
+
if (applied) {
|
|
561
|
+
return;
|
|
562
|
+
}
|
|
563
|
+
const rootBucket = fold.getOrCreateBucket(ROOT_THREAD_ID);
|
|
564
|
+
rootBucket.approvalDecisions.set(decision.toolCallId, record);
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
/** Persist a user.tool_response answer into fold state (clears pending ask-user). */
|
|
568
|
+
export function recordToolResponseInFold(
|
|
569
|
+
fold: PeerThreadFoldState,
|
|
570
|
+
response: { toolCallId: string; content: string },
|
|
571
|
+
): void {
|
|
572
|
+
let applied = false;
|
|
573
|
+
for (const bucket of fold.threads.values()) {
|
|
574
|
+
if (!bucket.pendingResponses.has(response.toolCallId)) {
|
|
575
|
+
continue;
|
|
576
|
+
}
|
|
577
|
+
bucket.toolResults.set(response.toolCallId, response.content);
|
|
578
|
+
bucket.pendingResponses.delete(response.toolCallId);
|
|
579
|
+
applied = true;
|
|
580
|
+
}
|
|
581
|
+
if (applied) {
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
const rootBucket = fold.getOrCreateBucket(ROOT_THREAD_ID);
|
|
585
|
+
rootBucket.toolResults.set(response.toolCallId, response.content);
|
|
586
|
+
rootBucket.pendingResponses.delete(response.toolCallId);
|
|
587
|
+
}
|
package/src/hooks.ts
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useMemo } from "react";
|
|
4
|
+
import { useAui } from "@assistant-ui/store";
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
EMPTY_DRAFT_EXTRAS,
|
|
8
|
+
trueFoundryExtras,
|
|
9
|
+
type TrueFoundryDraftRuntimeExtras,
|
|
10
|
+
} from "./truefoundryExtras.js";
|
|
11
|
+
import type { RespondToToolApprovalOptions } from "./toolApproval.js";
|
|
12
|
+
import type { RespondToToolResponseOptions } from "./toolResponse.js";
|
|
13
|
+
|
|
14
|
+
/** Pending tool approvals plus a respond action. */
|
|
15
|
+
export const useTrueFoundryApprovals = () => {
|
|
16
|
+
const extras = trueFoundryExtras.use((e) => e, undefined);
|
|
17
|
+
|
|
18
|
+
return useMemo(
|
|
19
|
+
() => ({
|
|
20
|
+
pending: extras?.pendingApprovals ?? [],
|
|
21
|
+
respond:
|
|
22
|
+
extras?.respondToToolApproval ??
|
|
23
|
+
(() => {
|
|
24
|
+
throw new Error("TrueFoundry runtime is not ready yet");
|
|
25
|
+
}),
|
|
26
|
+
}),
|
|
27
|
+
[extras],
|
|
28
|
+
);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Pending ask-user tool responses plus a respond action. */
|
|
32
|
+
export const useTrueFoundryToolResponses = () => {
|
|
33
|
+
const extras = trueFoundryExtras.use((e) => e, undefined);
|
|
34
|
+
|
|
35
|
+
return useMemo(
|
|
36
|
+
() => ({
|
|
37
|
+
pending: extras?.pendingToolResponses ?? [],
|
|
38
|
+
respond:
|
|
39
|
+
extras?.respondToToolResponse ??
|
|
40
|
+
((_response: RespondToToolResponseOptions) => {
|
|
41
|
+
throw new Error("TrueFoundry runtime is not ready yet");
|
|
42
|
+
}),
|
|
43
|
+
}),
|
|
44
|
+
[extras],
|
|
45
|
+
);
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Pending MCP OAuth plus a resume action. */
|
|
49
|
+
export const useTrueFoundryMcpAuth = () => {
|
|
50
|
+
const extras = trueFoundryExtras.use((e) => e, undefined);
|
|
51
|
+
|
|
52
|
+
return useMemo(
|
|
53
|
+
() => ({
|
|
54
|
+
pending: extras?.pendingMcpAuth ?? null,
|
|
55
|
+
resume:
|
|
56
|
+
extras?.resumeMcpAuth ??
|
|
57
|
+
(async () => {
|
|
58
|
+
throw new Error("TrueFoundry runtime is not ready yet");
|
|
59
|
+
}),
|
|
60
|
+
}),
|
|
61
|
+
[extras],
|
|
62
|
+
);
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/** Returns a function to respond to a tool approval from any render context. */
|
|
66
|
+
export const useTrueFoundryRespondToToolApproval = () => {
|
|
67
|
+
const aui = useAui();
|
|
68
|
+
return (response: RespondToToolApprovalOptions) =>
|
|
69
|
+
trueFoundryExtras.get(aui).respondToToolApproval(response);
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/** Returns a function to respond to a pending tool response from any render context. */
|
|
73
|
+
export const useTrueFoundryRespondToToolResponse = () => {
|
|
74
|
+
const aui = useAui();
|
|
75
|
+
return (response: RespondToToolResponseOptions) =>
|
|
76
|
+
trueFoundryExtras.get(aui).respondToToolResponse(response);
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/** Returns a function to resume after MCP OAuth from any render context. */
|
|
80
|
+
export const useTrueFoundryResumeMcpAuth = () => {
|
|
81
|
+
const aui = useAui();
|
|
82
|
+
return () => trueFoundryExtras.get(aui).resumeMcpAuth();
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Current sandboxId for this session, if a sandbox has been created. */
|
|
86
|
+
export const useTrueFoundrySandboxId = (): string | undefined =>
|
|
87
|
+
trueFoundryExtras.use((e) => e.sandboxId, undefined);
|
|
88
|
+
|
|
89
|
+
/** Returns a function to download a sandbox file by path from any render context. */
|
|
90
|
+
export const useTrueFoundryDownloadSandboxFile = () => {
|
|
91
|
+
const aui = useAui();
|
|
92
|
+
return (path: string) => trueFoundryExtras.get(aui).downloadSandboxFile(path);
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Returns a function to cancel the current run from any render context. */
|
|
96
|
+
export const useTrueFoundryCancel = () => {
|
|
97
|
+
const aui = useAui();
|
|
98
|
+
return () => trueFoundryExtras.get(aui).cancel();
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/** Returns a function to reset (re-submit) a user turn from any render context. */
|
|
102
|
+
export const useTrueFoundryResetFromTurn = () => {
|
|
103
|
+
const aui = useAui();
|
|
104
|
+
return (turnId: string) => trueFoundryExtras.get(aui).resetFromTurn(turnId);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** Current draft agent spec and sync state (draft mode only). */
|
|
108
|
+
export const useTrueFoundryAgentSpec = () => {
|
|
109
|
+
const extras = trueFoundryExtras.use((e) => e.draft, null);
|
|
110
|
+
|
|
111
|
+
return useMemo(
|
|
112
|
+
() => ({ ...EMPTY_DRAFT_EXTRAS, ...extras }),
|
|
113
|
+
[extras],
|
|
114
|
+
);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
/** Returns a draft spec updater from any render context. */
|
|
118
|
+
export const useTrueFoundryUpdateAgentSpec = () => {
|
|
119
|
+
const aui = useAui();
|
|
120
|
+
return (update: Parameters<TrueFoundryDraftRuntimeExtras["updateAgentSpec"]>[0]) =>
|
|
121
|
+
trueFoundryExtras.get(aui).draft?.updateAgentSpec(update);
|
|
122
|
+
};
|
|
123
|
+
|