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