@truefoundry/assistant-ui-runtime 0.1.0-rc.1 → 0.1.2
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 +45 -77
- package/dist/index.d.ts +14 -2
- package/dist/index.js +697 -147
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/agentSessionModule.d.ts +14 -2
- package/src/convertTurnMessages.test.ts +502 -1
- package/src/convertTurnMessages.ts +471 -28
- package/src/createSubAgent.ts +13 -5
- package/src/draftAgentConfig.test.ts +1 -1
- package/src/foldPeerThreads.test.ts +56 -0
- package/src/foldPeerThreads.ts +7 -1
- package/src/hooks.ts +24 -0
- package/src/index.ts +7 -5
- package/src/loadSessionSnapshot.test.ts +21 -6
- package/src/loadSessionSnapshot.ts +9 -5
- package/src/{bindDraftAgentSession.test.ts → private/bindDraftAgentSession.test.ts} +3 -2
- package/src/{draftSessionBridge.ts → private/draftSessionBridge.ts} +8 -2
- package/src/{truefoundryDraftThreadListAdapter.ts → private/truefoundryDraftThreadListAdapter.ts} +1 -1
- package/src/private/useDraftAgentSpec.test.tsx +153 -0
- package/src/{useDraftAgentSpec.ts → private/useDraftAgentSpec.ts} +80 -3
- package/src/sessionSnapshot.ts +48 -2
- package/src/sessions.ts +1 -1
- package/src/streamTurn.test.ts +34 -0
- package/src/streamTurn.ts +9 -1
- package/src/truefoundryExtras.ts +5 -1
- package/src/types.ts +1 -1
- package/src/useTrueFoundryAgentMessages.test.tsx +275 -1
- package/src/useTrueFoundryAgentMessages.ts +263 -70
- package/src/useTrueFoundryAgentRuntime.ts +51 -13
- /package/src/{agentSpec.ts → private/agentSpec.ts} +0 -0
- /package/src/{bindDraftAgentSession.ts → private/bindDraftAgentSession.ts} +0 -0
- /package/src/{truefoundryDraftThreadListAdapter.test.ts → private/truefoundryDraftThreadListAdapter.test.ts} +0 -0
|
@@ -10,12 +10,15 @@ import type {
|
|
|
10
10
|
AgentSession,
|
|
11
11
|
McpAuthRequiredEvent,
|
|
12
12
|
Turn,
|
|
13
|
+
TurnCreatedEvent,
|
|
14
|
+
TurnDoneEvent,
|
|
13
15
|
TurnEvent,
|
|
14
16
|
TurnInputItem,
|
|
15
17
|
TurnStreamData,
|
|
16
18
|
} from "truefoundry-gateway-sdk/agents";
|
|
17
19
|
|
|
18
20
|
import { ROOT_THREAD_ID } from "./constants.js";
|
|
21
|
+
import { extractTurnUserText } from "./extractTurnUserText.js";
|
|
19
22
|
import {
|
|
20
23
|
buildRootAssistantContent,
|
|
21
24
|
buildRootAssistantContentForIds,
|
|
@@ -24,6 +27,7 @@ import {
|
|
|
24
27
|
ingestStreamEvent,
|
|
25
28
|
ingestTurnEvent,
|
|
26
29
|
PeerThreadFoldState,
|
|
30
|
+
type ThreadBucket,
|
|
27
31
|
} from "./foldPeerThreads.js";
|
|
28
32
|
import {
|
|
29
33
|
appendMcpAuthToTurnContent,
|
|
@@ -39,9 +43,12 @@ import {
|
|
|
39
43
|
type ProjectSessionMessagesOptions,
|
|
40
44
|
type SessionTurnRecord,
|
|
41
45
|
type RequiredActionsOverlay,
|
|
46
|
+
type GatewaySessionEventItem,
|
|
47
|
+
type SessionHistoryPagination,
|
|
42
48
|
createEmptySessionSnapshot,
|
|
43
49
|
emptyRequiredActionsOverlay,
|
|
44
50
|
replaceSessionSnapshot,
|
|
51
|
+
sessionEventsToSessionRecord,
|
|
45
52
|
turnToSessionRecord,
|
|
46
53
|
} from "./sessionSnapshot.js";
|
|
47
54
|
import {
|
|
@@ -186,12 +193,442 @@ import {
|
|
|
186
193
|
* `user.message`.
|
|
187
194
|
* - Tool-call identity is stable via `toolCallId` across events, fold state, and
|
|
188
195
|
* assistant-ui parts.
|
|
189
|
-
* - Reload (`
|
|
196
|
+
* - Reload (`buildSnapshotFromSessionEvents`) and live paths must produce the same
|
|
190
197
|
* per-group scoping; history uses cumulative `groupRootIds` per turn index,
|
|
191
198
|
* live uses `groupRootBaseline`.
|
|
192
199
|
*/
|
|
193
200
|
|
|
194
201
|
const TURN_EVENTS_PAGE_SIZE = 25;
|
|
202
|
+
const SESSION_EVENTS_PAGE_SIZE = 100;
|
|
203
|
+
/** Cap how many event pages initial load / load-older may chain for a group boundary. */
|
|
204
|
+
const MAX_HISTORY_BOUNDARY_PAGES = 10;
|
|
205
|
+
|
|
206
|
+
type FetchSessionEventsOptions = {
|
|
207
|
+
/**
|
|
208
|
+
* Newest turn in the listing window (initial load only). Lists that turn and
|
|
209
|
+
* its ancestors. Omit to use the session last turn. Running-turn events are
|
|
210
|
+
* excluded by the API — subscribe to that turn for live events.
|
|
211
|
+
*/
|
|
212
|
+
lastTurnId?: string;
|
|
213
|
+
};
|
|
214
|
+
|
|
215
|
+
type SessionEventsPageResult = {
|
|
216
|
+
/** Newest-first items from this request. */
|
|
217
|
+
itemsNewestFirst: GatewaySessionEventItem[];
|
|
218
|
+
olderPageToken?: string;
|
|
219
|
+
hasOlder: boolean;
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Drops a leading incomplete turn (events before the first `turn.created`) that
|
|
224
|
+
* appears when a page boundary splits a turn.
|
|
225
|
+
*/
|
|
226
|
+
function trimIncompleteLeadingEvents(
|
|
227
|
+
itemsAsc: GatewaySessionEventItem[],
|
|
228
|
+
): GatewaySessionEventItem[] {
|
|
229
|
+
const start = itemsAsc.findIndex((item) => item.event.type === "turn.created");
|
|
230
|
+
if (start === -1) {
|
|
231
|
+
return [];
|
|
232
|
+
}
|
|
233
|
+
return itemsAsc.slice(start);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Whether the oldest complete turn in a chronological window opens a user group.
|
|
238
|
+
* `incomplete` means we still lack a finished oldest turn (need an older page).
|
|
239
|
+
*/
|
|
240
|
+
function oldestCompleteTurnGroupState(
|
|
241
|
+
itemsAsc: GatewaySessionEventItem[],
|
|
242
|
+
): "user-group" | "continuation" | "incomplete" {
|
|
243
|
+
const trimmed = trimIncompleteLeadingEvents(itemsAsc);
|
|
244
|
+
if (trimmed.length === 0) {
|
|
245
|
+
return "incomplete";
|
|
246
|
+
}
|
|
247
|
+
const created = trimmed[0];
|
|
248
|
+
if (created?.event.type !== "turn.created") {
|
|
249
|
+
return "incomplete";
|
|
250
|
+
}
|
|
251
|
+
const hasDone = trimmed.some(
|
|
252
|
+
(item) => item.turnId === created.turnId && item.event.type === "turn.done",
|
|
253
|
+
);
|
|
254
|
+
if (!hasDone) {
|
|
255
|
+
return "incomplete";
|
|
256
|
+
}
|
|
257
|
+
return extractTurnUserText(created.event.input) != null
|
|
258
|
+
? "user-group"
|
|
259
|
+
: "continuation";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async function fetchSessionEventsPage(
|
|
263
|
+
session: AgentSession,
|
|
264
|
+
options?: FetchSessionEventsOptions & { pageToken?: string },
|
|
265
|
+
): Promise<SessionEventsPageResult> {
|
|
266
|
+
const page = await session.listEvents({
|
|
267
|
+
limit: SESSION_EVENTS_PAGE_SIZE,
|
|
268
|
+
...(options?.lastTurnId != null ? { lastTurnId: options.lastTurnId } : {}),
|
|
269
|
+
...(options?.pageToken != null ? { pageToken: options.pageToken } : {}),
|
|
270
|
+
});
|
|
271
|
+
const response = page.response as {
|
|
272
|
+
pagination?: { nextPageToken?: string };
|
|
273
|
+
};
|
|
274
|
+
const olderPageToken = response.pagination?.nextPageToken;
|
|
275
|
+
const hasOlder =
|
|
276
|
+
typeof page.hasNextPage === "function"
|
|
277
|
+
? page.hasNextPage()
|
|
278
|
+
: olderPageToken != null && olderPageToken !== "";
|
|
279
|
+
return {
|
|
280
|
+
itemsNewestFirst: page.data as GatewaySessionEventItem[],
|
|
281
|
+
...(olderPageToken != null && olderPageToken !== ""
|
|
282
|
+
? { olderPageToken }
|
|
283
|
+
: {}),
|
|
284
|
+
hasOlder,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Fetches pages until the chronological window starts on a complete user-message
|
|
290
|
+
* turn group (or history is exhausted).
|
|
291
|
+
*/
|
|
292
|
+
async function fetchSessionEventsWindow(
|
|
293
|
+
session: AgentSession,
|
|
294
|
+
options?: FetchSessionEventsOptions & { pageToken?: string },
|
|
295
|
+
): Promise<{
|
|
296
|
+
itemsAsc: GatewaySessionEventItem[];
|
|
297
|
+
olderPageToken?: string;
|
|
298
|
+
hasOlder: boolean;
|
|
299
|
+
}> {
|
|
300
|
+
let itemsNewestFirst: GatewaySessionEventItem[] = [];
|
|
301
|
+
let pageToken = options?.pageToken;
|
|
302
|
+
let olderPageToken: string | undefined;
|
|
303
|
+
let hasOlder = false;
|
|
304
|
+
|
|
305
|
+
for (let pageCount = 0; pageCount < MAX_HISTORY_BOUNDARY_PAGES; pageCount++) {
|
|
306
|
+
const page = await fetchSessionEventsPage(session, {
|
|
307
|
+
...options,
|
|
308
|
+
...(pageToken != null ? { pageToken } : {}),
|
|
309
|
+
});
|
|
310
|
+
itemsNewestFirst = [...itemsNewestFirst, ...page.itemsNewestFirst];
|
|
311
|
+
olderPageToken = page.olderPageToken;
|
|
312
|
+
hasOlder = page.hasOlder;
|
|
313
|
+
|
|
314
|
+
const itemsAsc = trimIncompleteLeadingEvents([...itemsNewestFirst].reverse());
|
|
315
|
+
const groupState = oldestCompleteTurnGroupState(itemsAsc);
|
|
316
|
+
if (groupState === "user-group" || !hasOlder) {
|
|
317
|
+
return {
|
|
318
|
+
itemsAsc,
|
|
319
|
+
...(olderPageToken != null ? { olderPageToken } : {}),
|
|
320
|
+
hasOlder,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
if (olderPageToken == null) {
|
|
325
|
+
return { itemsAsc, hasOlder: false };
|
|
326
|
+
}
|
|
327
|
+
pageToken = olderPageToken;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
const itemsAsc = trimIncompleteLeadingEvents([...itemsNewestFirst].reverse());
|
|
331
|
+
return {
|
|
332
|
+
itemsAsc,
|
|
333
|
+
...(olderPageToken != null ? { olderPageToken } : {}),
|
|
334
|
+
hasOlder,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* Fetches session-level events via `session.listEvents()`. The API returns pages
|
|
340
|
+
* in desc order (newest first); the collected array is reversed before returning
|
|
341
|
+
* so callers receive events in chronological (asc) order.
|
|
342
|
+
*
|
|
343
|
+
* Used by rewind/edit paths that need the full ancestor window.
|
|
344
|
+
*/
|
|
345
|
+
async function fetchAllSessionEvents(
|
|
346
|
+
session: AgentSession,
|
|
347
|
+
options?: FetchSessionEventsOptions,
|
|
348
|
+
): Promise<GatewaySessionEventItem[]> {
|
|
349
|
+
const items: GatewaySessionEventItem[] = [];
|
|
350
|
+
for await (const item of await session.listEvents({
|
|
351
|
+
limit: SESSION_EVENTS_PAGE_SIZE,
|
|
352
|
+
...(options?.lastTurnId != null ? { lastTurnId: options.lastTurnId } : {}),
|
|
353
|
+
})) {
|
|
354
|
+
items.push(item as GatewaySessionEventItem);
|
|
355
|
+
}
|
|
356
|
+
items.reverse();
|
|
357
|
+
return items;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function cloneThreadBucket(bucket: ThreadBucket): ThreadBucket {
|
|
361
|
+
return {
|
|
362
|
+
events: new Map(bucket.events),
|
|
363
|
+
modelMessageIds: [...bucket.modelMessageIds],
|
|
364
|
+
toolResults: new Map(bucket.toolResults),
|
|
365
|
+
pendingApprovals: new Map(bucket.pendingApprovals),
|
|
366
|
+
approvalDecisions: new Map(bucket.approvalDecisions),
|
|
367
|
+
pendingResponses: new Map(bucket.pendingResponses),
|
|
368
|
+
done: bucket.done,
|
|
369
|
+
...(bucket.title != null ? { title: bucket.title } : {}),
|
|
370
|
+
...(bucket.agentInfo != null ? { agentInfo: bucket.agentInfo } : {}),
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Prepends older fold state ahead of the currently loaded fold (scroll-up). */
|
|
375
|
+
export function prependFoldState(
|
|
376
|
+
older: PeerThreadFoldState,
|
|
377
|
+
newer: PeerThreadFoldState,
|
|
378
|
+
): PeerThreadFoldState {
|
|
379
|
+
const result = new PeerThreadFoldState();
|
|
380
|
+
const threadIds = new Set([...older.threads.keys(), ...newer.threads.keys()]);
|
|
381
|
+
for (const threadId of threadIds) {
|
|
382
|
+
const olderBucket = older.threads.get(threadId);
|
|
383
|
+
const newerBucket = newer.threads.get(threadId);
|
|
384
|
+
if (olderBucket == null && newerBucket != null) {
|
|
385
|
+
result.threads.set(threadId, cloneThreadBucket(newerBucket));
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
if (newerBucket == null && olderBucket != null) {
|
|
389
|
+
result.threads.set(threadId, cloneThreadBucket(olderBucket));
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (olderBucket == null || newerBucket == null) {
|
|
393
|
+
continue;
|
|
394
|
+
}
|
|
395
|
+
result.threads.set(threadId, {
|
|
396
|
+
events: new Map([...olderBucket.events, ...newerBucket.events]),
|
|
397
|
+
modelMessageIds: [
|
|
398
|
+
...olderBucket.modelMessageIds,
|
|
399
|
+
...newerBucket.modelMessageIds,
|
|
400
|
+
],
|
|
401
|
+
toolResults: new Map([
|
|
402
|
+
...olderBucket.toolResults,
|
|
403
|
+
...newerBucket.toolResults,
|
|
404
|
+
]),
|
|
405
|
+
pendingApprovals: new Map([
|
|
406
|
+
...olderBucket.pendingApprovals,
|
|
407
|
+
...newerBucket.pendingApprovals,
|
|
408
|
+
]),
|
|
409
|
+
approvalDecisions: new Map([
|
|
410
|
+
...olderBucket.approvalDecisions,
|
|
411
|
+
...newerBucket.approvalDecisions,
|
|
412
|
+
]),
|
|
413
|
+
pendingResponses: new Map([
|
|
414
|
+
...olderBucket.pendingResponses,
|
|
415
|
+
...newerBucket.pendingResponses,
|
|
416
|
+
]),
|
|
417
|
+
done: newerBucket.done || olderBucket.done,
|
|
418
|
+
...(newerBucket.title != null || olderBucket.title != null
|
|
419
|
+
? { title: newerBucket.title ?? olderBucket.title }
|
|
420
|
+
: {}),
|
|
421
|
+
...(newerBucket.agentInfo != null || olderBucket.agentInfo != null
|
|
422
|
+
? { agentInfo: newerBucket.agentInfo ?? olderBucket.agentInfo }
|
|
423
|
+
: {}),
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
for (const [threadId, link] of older.threadParents) {
|
|
427
|
+
result.threadParents.set(threadId, link);
|
|
428
|
+
}
|
|
429
|
+
for (const [threadId, link] of newer.threadParents) {
|
|
430
|
+
result.threadParents.set(threadId, link);
|
|
431
|
+
}
|
|
432
|
+
return result;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Ingests a chronologically ordered list of session-level event items into the
|
|
437
|
+
* snapshot. `turn.created` marks the start of a turn (provides user input),
|
|
438
|
+
* content events are folded as usual, and `turn.done` finalises the turn record
|
|
439
|
+
* and pushes it to `snapshot.turns`.
|
|
440
|
+
*
|
|
441
|
+
* `onTurnComplete` is called after each `turn.done` with the partially-built
|
|
442
|
+
* snapshot so callers can update UI progressively. The fold state is correct at
|
|
443
|
+
* that point for every turn up to and including the just-completed one.
|
|
444
|
+
*/
|
|
445
|
+
function ingestSessionEventsIntoSnapshot(
|
|
446
|
+
snapshot: SessionSnapshot,
|
|
447
|
+
items: GatewaySessionEventItem[],
|
|
448
|
+
onTurnComplete?: (snap: SessionSnapshot) => void,
|
|
449
|
+
): void {
|
|
450
|
+
let currentTurnId: string | null = null;
|
|
451
|
+
let currentCreatedEvent: TurnCreatedEvent | null = null;
|
|
452
|
+
let currentContentEvents: TurnEvent[] = [];
|
|
453
|
+
let beforeCount = 0;
|
|
454
|
+
|
|
455
|
+
for (const item of items) {
|
|
456
|
+
const { turnId, event } = item;
|
|
457
|
+
|
|
458
|
+
if (event.type === "turn.created") {
|
|
459
|
+
currentTurnId = turnId;
|
|
460
|
+
currentCreatedEvent = event;
|
|
461
|
+
currentContentEvents = [];
|
|
462
|
+
beforeCount =
|
|
463
|
+
snapshot.fold.threads.get(ROOT_THREAD_ID)?.modelMessageIds.length ?? 0;
|
|
464
|
+
} else if (event.type === "turn.done") {
|
|
465
|
+
if (currentTurnId == null || currentCreatedEvent == null) {
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
const afterBucket = snapshot.fold.threads.get(ROOT_THREAD_ID);
|
|
470
|
+
const rootModelMessageIds = (afterBucket?.modelMessageIds ?? []).slice(
|
|
471
|
+
beforeCount,
|
|
472
|
+
);
|
|
473
|
+
|
|
474
|
+
const sandboxEvent = currentContentEvents.find(
|
|
475
|
+
(ev): ev is Extract<TurnEvent, { type: "sandbox.created" }> =>
|
|
476
|
+
ev.type === "sandbox.created",
|
|
477
|
+
);
|
|
478
|
+
|
|
479
|
+
applyUserToolResponsesToFold(
|
|
480
|
+
snapshot.fold,
|
|
481
|
+
currentCreatedEvent.input ?? [],
|
|
482
|
+
);
|
|
483
|
+
snapshot.turns.push(
|
|
484
|
+
sessionEventsToSessionRecord(
|
|
485
|
+
currentTurnId,
|
|
486
|
+
currentCreatedEvent,
|
|
487
|
+
event,
|
|
488
|
+
rootModelMessageIds,
|
|
489
|
+
sandboxEvent?.sandboxId,
|
|
490
|
+
),
|
|
491
|
+
);
|
|
492
|
+
|
|
493
|
+
// Pass a new object reference so React's Object.is check in
|
|
494
|
+
// useState sees a changed value and schedules a re-render.
|
|
495
|
+
// The snapshot is mutated in place throughout this loop, so
|
|
496
|
+
// passing `snapshot` directly would make every tick look identical
|
|
497
|
+
// to React after the first setSnapshot call.
|
|
498
|
+
onTurnComplete?.(replaceSessionSnapshot(snapshot, {}));
|
|
499
|
+
|
|
500
|
+
currentTurnId = null;
|
|
501
|
+
currentCreatedEvent = null;
|
|
502
|
+
currentContentEvents = [];
|
|
503
|
+
} else {
|
|
504
|
+
if (currentTurnId != null) {
|
|
505
|
+
ingestTurnEvent(snapshot.fold, event as TurnEvent);
|
|
506
|
+
currentContentEvents.push(event as TurnEvent);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
function attachRunningTurn(
|
|
513
|
+
snapshot: SessionSnapshot,
|
|
514
|
+
runningTurn: Turn | undefined,
|
|
515
|
+
): SessionSnapshot {
|
|
516
|
+
if (runningTurn == null) {
|
|
517
|
+
return snapshot;
|
|
518
|
+
}
|
|
519
|
+
const pendingUserText = extractTurnUserText(runningTurn.input);
|
|
520
|
+
return replaceSessionSnapshot(snapshot, {
|
|
521
|
+
runningTurn,
|
|
522
|
+
unstable_resume: true as const,
|
|
523
|
+
groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
|
|
524
|
+
...(pendingUserText
|
|
525
|
+
? {
|
|
526
|
+
pendingUser: {
|
|
527
|
+
turnId: runningTurn.id,
|
|
528
|
+
content: extractTurnUserMessageContent(runningTurn.input),
|
|
529
|
+
createdAt: new Date(runningTurn.createdAt),
|
|
530
|
+
},
|
|
531
|
+
}
|
|
532
|
+
: {}),
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Builds a session snapshot using the session-level `listEvents` API.
|
|
538
|
+
*
|
|
539
|
+
* Loads the newest event page (extending only when a page boundary splits a
|
|
540
|
+
* turn group), then leaves older pages for `prependOlderSessionHistory`.
|
|
541
|
+
* Only `listTurns({ limit: 1 })` is called first to detect a currently-running
|
|
542
|
+
* turn (the session-level API does not return events for the running turn).
|
|
543
|
+
*
|
|
544
|
+
* `onProgress` is called after each complete turn is ingested so callers can
|
|
545
|
+
* update the UI progressively while the processing loop runs.
|
|
546
|
+
*/
|
|
547
|
+
export async function buildSnapshotFromSessionEvents(
|
|
548
|
+
session: AgentSession,
|
|
549
|
+
onProgress?: (snap: SessionSnapshot) => void,
|
|
550
|
+
): Promise<SessionSnapshot> {
|
|
551
|
+
// Detect a running turn with a single listTurns page — do not drain pagination.
|
|
552
|
+
const turnsPage = await session.listTurns({ limit: 1 });
|
|
553
|
+
const newestTurn = turnsPage.data[0] as Turn | undefined;
|
|
554
|
+
const runningTurn =
|
|
555
|
+
newestTurn?.state?.status === "running" ? newestTurn : undefined;
|
|
556
|
+
|
|
557
|
+
const window = await fetchSessionEventsWindow(session);
|
|
558
|
+
const historyPagination: SessionHistoryPagination = {
|
|
559
|
+
hasOlder: window.hasOlder,
|
|
560
|
+
...(window.olderPageToken != null
|
|
561
|
+
? { olderPageToken: window.olderPageToken }
|
|
562
|
+
: {}),
|
|
563
|
+
};
|
|
564
|
+
|
|
565
|
+
const snapshot = createEmptySessionSnapshot();
|
|
566
|
+
ingestSessionEventsIntoSnapshot(snapshot, window.itemsAsc, onProgress);
|
|
567
|
+
|
|
568
|
+
const withHistory = replaceSessionSnapshot(snapshot, {
|
|
569
|
+
historyEvents: window.itemsAsc,
|
|
570
|
+
historyPagination,
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
return attachRunningTurn(withHistory, runningTurn);
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Fetches the next older `listEvents` window and prepends it onto `snapshot`
|
|
578
|
+
* without tearing down live stream / pending UI state.
|
|
579
|
+
*/
|
|
580
|
+
export async function prependOlderSessionHistory(
|
|
581
|
+
session: AgentSession,
|
|
582
|
+
snapshot: SessionSnapshot,
|
|
583
|
+
): Promise<SessionSnapshot> {
|
|
584
|
+
const pagination = snapshot.historyPagination;
|
|
585
|
+
if (pagination?.hasOlder !== true || pagination.olderPageToken == null) {
|
|
586
|
+
return snapshot;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
const window = await fetchSessionEventsWindow(session, {
|
|
590
|
+
pageToken: pagination.olderPageToken,
|
|
591
|
+
});
|
|
592
|
+
if (window.itemsAsc.length === 0) {
|
|
593
|
+
return replaceSessionSnapshot(snapshot, {
|
|
594
|
+
historyPagination: { hasOlder: false },
|
|
595
|
+
});
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const olderSnap = createEmptySessionSnapshot();
|
|
599
|
+
ingestSessionEventsIntoSnapshot(olderSnap, window.itemsAsc);
|
|
600
|
+
|
|
601
|
+
const existingIds = new Set(snapshot.turns.map((turn) => turn.id));
|
|
602
|
+
const olderTurns = olderSnap.turns.filter((turn) => !existingIds.has(turn.id));
|
|
603
|
+
const mergedFold = prependFoldState(olderSnap.fold, snapshot.fold);
|
|
604
|
+
const historyEvents = [
|
|
605
|
+
...window.itemsAsc,
|
|
606
|
+
...(snapshot.historyEvents ?? []),
|
|
607
|
+
];
|
|
608
|
+
const olderRootIds = olderTurns.flatMap(
|
|
609
|
+
(turn) => turn.rootModelMessageIds ?? [],
|
|
610
|
+
);
|
|
611
|
+
|
|
612
|
+
return replaceSessionSnapshot(snapshot, {
|
|
613
|
+
fold: mergedFold,
|
|
614
|
+
turns: [...olderTurns, ...snapshot.turns],
|
|
615
|
+
historyEvents,
|
|
616
|
+
historyPagination: {
|
|
617
|
+
hasOlder: window.hasOlder,
|
|
618
|
+
...(window.olderPageToken != null
|
|
619
|
+
? { olderPageToken: window.olderPageToken }
|
|
620
|
+
: {}),
|
|
621
|
+
},
|
|
622
|
+
...(snapshot.groupRootBaseline != null
|
|
623
|
+
? {
|
|
624
|
+
groupRootBaseline: [
|
|
625
|
+
...olderRootIds,
|
|
626
|
+
...snapshot.groupRootBaseline,
|
|
627
|
+
],
|
|
628
|
+
}
|
|
629
|
+
: {}),
|
|
630
|
+
});
|
|
631
|
+
}
|
|
195
632
|
|
|
196
633
|
export type ConvertTurnsResult = {
|
|
197
634
|
messages: ThreadMessage[];
|
|
@@ -775,25 +1212,25 @@ export async function buildSnapshotFromSession(
|
|
|
775
1212
|
session: AgentSession,
|
|
776
1213
|
concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
|
|
777
1214
|
): Promise<SessionSnapshot> {
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
1215
|
+
// Completed history comes from session-level listEvents. The session API
|
|
1216
|
+
// excludes the running turn — hydrate that turn via turn.listEvents so
|
|
1217
|
+
// convertTurnsToThreadMessages still surfaces in-flight content.
|
|
1218
|
+
const snapshot = await buildSnapshotFromSessionEvents(session);
|
|
1219
|
+
if (snapshot.runningTurn == null) {
|
|
1220
|
+
return snapshot;
|
|
781
1221
|
}
|
|
782
|
-
turns.reverse();
|
|
783
1222
|
|
|
784
|
-
const
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
const runningTurn = ingestTurnsIntoSnapshot(snapshot, turns, eventArrays);
|
|
1223
|
+
const turn = snapshot.runningTurn;
|
|
1224
|
+
const eventArrays = await fetchAllTurnEventsWithConcurrency([turn], concurrency);
|
|
1225
|
+
ingestTurnsIntoSnapshot(snapshot, [turn], eventArrays);
|
|
788
1226
|
|
|
1227
|
+
// Hydrated in-flight content lives in `turns` / `fold` now — drop the
|
|
1228
|
+
// reconnect seed so projection does not duplicate the user bubble.
|
|
789
1229
|
return replaceSessionSnapshot(snapshot, {
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
|
|
795
|
-
}
|
|
796
|
-
: {}),
|
|
1230
|
+
runningTurn: turn,
|
|
1231
|
+
unstable_resume: true as const,
|
|
1232
|
+
groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
|
|
1233
|
+
pendingUser: undefined,
|
|
797
1234
|
});
|
|
798
1235
|
}
|
|
799
1236
|
|
|
@@ -803,11 +1240,7 @@ export async function buildSnapshotBeforeTurn(
|
|
|
803
1240
|
beforeTurnId: string,
|
|
804
1241
|
concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
|
|
805
1242
|
): Promise<SessionSnapshot> {
|
|
806
|
-
const turns
|
|
807
|
-
for await (const turn of await session.listTurns()) {
|
|
808
|
-
turns.push(turn);
|
|
809
|
-
}
|
|
810
|
-
turns.reverse();
|
|
1243
|
+
const turns = await listSessionTurnsOrdered(session);
|
|
811
1244
|
|
|
812
1245
|
const beforeIndex = turns.findIndex((turn) => turn.id === beforeTurnId);
|
|
813
1246
|
if (beforeIndex === -1) {
|
|
@@ -821,7 +1254,7 @@ export async function buildSnapshotBeforeTurn(
|
|
|
821
1254
|
export async function buildSnapshotBeforeTurnIndex(
|
|
822
1255
|
session: AgentSession,
|
|
823
1256
|
turnIndex: number,
|
|
824
|
-
|
|
1257
|
+
_concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
|
|
825
1258
|
orderedTurns?: Turn[],
|
|
826
1259
|
): Promise<SessionSnapshot> {
|
|
827
1260
|
if (turnIndex <= 0) {
|
|
@@ -830,16 +1263,16 @@ export async function buildSnapshotBeforeTurnIndex(
|
|
|
830
1263
|
|
|
831
1264
|
const turns = orderedTurns ?? (await listSessionTurnsOrdered(session));
|
|
832
1265
|
const turnsToInclude = turns.slice(0, turnIndex);
|
|
833
|
-
|
|
1266
|
+
const lastTurnId = turnsToInclude.at(-1)?.id;
|
|
1267
|
+
if (lastTurnId == null) {
|
|
834
1268
|
return createEmptySessionSnapshot();
|
|
835
1269
|
}
|
|
836
1270
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
);
|
|
1271
|
+
// Anchor the session events window at the newest included turn so the
|
|
1272
|
+
// ancestor chain matches `[turns[0], …, turns[turnIndex - 1]]`.
|
|
1273
|
+
const items = await fetchAllSessionEvents(session, { lastTurnId });
|
|
841
1274
|
const snapshot = createEmptySessionSnapshot();
|
|
842
|
-
|
|
1275
|
+
ingestSessionEventsIntoSnapshot(snapshot, items);
|
|
843
1276
|
return snapshot;
|
|
844
1277
|
}
|
|
845
1278
|
|
|
@@ -856,6 +1289,16 @@ export async function resolveGatewayBranchPreviousTurnId(
|
|
|
856
1289
|
return turns[turnIndex - 1]?.id ?? null;
|
|
857
1290
|
}
|
|
858
1291
|
|
|
1292
|
+
/** Resolves `previousTurnId` by turn id so partial history windows stay correct. */
|
|
1293
|
+
export async function resolveGatewayBranchPreviousTurnIdForTurn(
|
|
1294
|
+
session: AgentSession,
|
|
1295
|
+
turnId: string,
|
|
1296
|
+
): Promise<string | null> {
|
|
1297
|
+
const turns = await listSessionTurnsOrdered(session);
|
|
1298
|
+
const turnIndex = turns.findIndex((turn) => turn.id === turnId);
|
|
1299
|
+
return resolveGatewayBranchPreviousTurnId(session, turnIndex, turns);
|
|
1300
|
+
}
|
|
1301
|
+
|
|
859
1302
|
async function listSessionTurnsOrdered(session: AgentSession): Promise<Turn[]> {
|
|
860
1303
|
const turns: Turn[] = [];
|
|
861
1304
|
for await (const turn of await session.listTurns()) {
|
package/src/createSubAgent.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import type { ToolCall } from "truefoundry-gateway-sdk/agents";
|
|
2
2
|
|
|
3
|
-
export function isCreateSubAgentToolCall(
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
3
|
+
export function isCreateSubAgentToolCall(
|
|
4
|
+
toolCall: Pick<ToolCall, "toolInfo" | "function">,
|
|
5
|
+
): boolean {
|
|
6
|
+
// Gateway may attach truefoundry-system toolInfo on persisted turns, but streamed
|
|
7
|
+
// model.message tool calls often only carry function.name. Without the fallback,
|
|
8
|
+
// foldPeerThreads never nests child threads under the spawning tool call.
|
|
9
|
+
if (
|
|
10
|
+
toolCall.toolInfo?.type === "truefoundry-system" &&
|
|
11
|
+
toolCall.toolInfo?.name === "create_sub_agent"
|
|
12
|
+
) {
|
|
13
|
+
return true;
|
|
14
|
+
}
|
|
15
|
+
return toolCall.function?.name === "create_sub_agent";
|
|
8
16
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
|
-
import { mergeAgentSpec } from "./agentSpec.js";
|
|
3
|
+
import { mergeAgentSpec } from "./private/agentSpec.js";
|
|
4
4
|
import { resolveTrueFoundryAgentConfig } from "./types.js";
|
|
5
5
|
|
|
6
6
|
describe("resolveTrueFoundryAgentConfig", () => {
|
|
@@ -129,6 +129,62 @@ describe("foldPeerThreads", () => {
|
|
|
129
129
|
});
|
|
130
130
|
});
|
|
131
131
|
|
|
132
|
+
it("attaches sub-agent artifact from thread.created before child model messages arrive", () => {
|
|
133
|
+
const state = new PeerThreadFoldState();
|
|
134
|
+
|
|
135
|
+
ingestTurnEvent(
|
|
136
|
+
state,
|
|
137
|
+
modelMessage({
|
|
138
|
+
id: "root-msg",
|
|
139
|
+
threadId: ROOT_THREAD_ID,
|
|
140
|
+
toolCalls: [
|
|
141
|
+
{
|
|
142
|
+
id: "spawn-1",
|
|
143
|
+
type: "function",
|
|
144
|
+
function: { name: "create_sub_agent", arguments: "{}" },
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
ingestTurnEvent(
|
|
151
|
+
state,
|
|
152
|
+
threadCreated({
|
|
153
|
+
id: "t-created",
|
|
154
|
+
threadId: "child-1",
|
|
155
|
+
title: "gateway-usage-7d",
|
|
156
|
+
agentInfo: {
|
|
157
|
+
type: "dynamic",
|
|
158
|
+
name: "gateway-usage-7d",
|
|
159
|
+
input: "run queries",
|
|
160
|
+
},
|
|
161
|
+
parent: { threadId: ROOT_THREAD_ID, toolCallId: "spawn-1" },
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
const parts = buildRootAssistantContent(state);
|
|
166
|
+
const spawn = parts.find((part) => part.type === "tool-call");
|
|
167
|
+
expect(spawn?.type).toBe("tool-call");
|
|
168
|
+
if (spawn?.type !== "tool-call") {
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
expect(spawn.messages).toBeUndefined();
|
|
173
|
+
expect(spawn.artifact).toEqual({
|
|
174
|
+
subAgents: [
|
|
175
|
+
{
|
|
176
|
+
threadId: "child-1",
|
|
177
|
+
title: "gateway-usage-7d",
|
|
178
|
+
agentInfo: {
|
|
179
|
+
type: "dynamic",
|
|
180
|
+
name: "gateway-usage-7d",
|
|
181
|
+
input: "run queries",
|
|
182
|
+
},
|
|
183
|
+
},
|
|
184
|
+
],
|
|
185
|
+
});
|
|
186
|
+
});
|
|
187
|
+
|
|
132
188
|
it("classifies stream events by thread id", () => {
|
|
133
189
|
const state = new PeerThreadFoldState();
|
|
134
190
|
expect(
|
package/src/foldPeerThreads.ts
CHANGED
|
@@ -389,11 +389,17 @@ function attachSubAgentMessages(
|
|
|
389
389
|
});
|
|
390
390
|
}
|
|
391
391
|
}
|
|
392
|
-
if (messages.length === 0) {
|
|
392
|
+
if (messages.length === 0 && subAgents.length === 0) {
|
|
393
393
|
return part;
|
|
394
394
|
}
|
|
395
395
|
|
|
396
396
|
const artifact: SubAgentArtifact = { subAgents };
|
|
397
|
+
// thread.created (title, agentInfo) arrives before the child's first model.message.
|
|
398
|
+
// Attach artifact-only so UI can render the sub-agent header immediately; see README
|
|
399
|
+
// SubAgentArtifact / MessagePartPrimitive.Messages in agent-ui ToolCallContainer.
|
|
400
|
+
if (messages.length === 0) {
|
|
401
|
+
return { ...part, artifact };
|
|
402
|
+
}
|
|
397
403
|
return { ...part, messages, artifact };
|
|
398
404
|
});
|
|
399
405
|
}
|