@truefoundry/assistant-ui-runtime 0.1.3-rc.1 → 0.1.3-rc.3
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/dist/index.js +46 -14
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
- package/src/streamTurn.test.ts +85 -0
- package/src/streamTurn.ts +40 -6
- package/src/useTrueFoundryAgentMessages.ts +49 -10
package/package.json
CHANGED
package/src/streamTurn.test.ts
CHANGED
|
@@ -227,6 +227,91 @@ describe("streamTurn", () => {
|
|
|
227
227
|
},
|
|
228
228
|
);
|
|
229
229
|
});
|
|
230
|
+
|
|
231
|
+
it("notifies gateway turn id when turn.done errors with no content yields", async () => {
|
|
232
|
+
const gatewayTurnId = "01ky6mqzmczwt6ssyd5r02gjjc";
|
|
233
|
+
const turn = {
|
|
234
|
+
id: undefined as string | undefined,
|
|
235
|
+
execute: vi.fn(() =>
|
|
236
|
+
(async function* () {
|
|
237
|
+
// SDK sets turn.id when turn.created is observed.
|
|
238
|
+
turn.id = gatewayTurnId;
|
|
239
|
+
yield streamData(1, {
|
|
240
|
+
type: "turn.created",
|
|
241
|
+
createdAt,
|
|
242
|
+
input: [{ type: "user.message", content: "hello" }],
|
|
243
|
+
});
|
|
244
|
+
yield streamData(2, {
|
|
245
|
+
type: "turn.done",
|
|
246
|
+
createdAt,
|
|
247
|
+
state: {
|
|
248
|
+
status: "error",
|
|
249
|
+
message:
|
|
250
|
+
"Publisher Model is not servable in region us-central1.",
|
|
251
|
+
},
|
|
252
|
+
});
|
|
253
|
+
})(),
|
|
254
|
+
),
|
|
255
|
+
};
|
|
256
|
+
const prepareTurn = vi.fn(() => turn);
|
|
257
|
+
const session = {
|
|
258
|
+
prepareTurn,
|
|
259
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
260
|
+
} as unknown as AgentSession;
|
|
261
|
+
const onTurnIdAvailable = vi.fn();
|
|
262
|
+
|
|
263
|
+
await expect(
|
|
264
|
+
collectUpdates(
|
|
265
|
+
streamTurnContent(
|
|
266
|
+
session,
|
|
267
|
+
new PeerThreadFoldState(),
|
|
268
|
+
{ userMessage: "hello" },
|
|
269
|
+
new AbortController().signal,
|
|
270
|
+
undefined,
|
|
271
|
+
onTurnIdAvailable,
|
|
272
|
+
),
|
|
273
|
+
),
|
|
274
|
+
).rejects.toThrow("Publisher Model is not servable in region us-central1.");
|
|
275
|
+
|
|
276
|
+
expect(onTurnIdAvailable).toHaveBeenCalledTimes(1);
|
|
277
|
+
expect(onTurnIdAvailable).toHaveBeenCalledWith(gatewayTurnId);
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
it("does not notify when an error stream never assigns turn.id", async () => {
|
|
281
|
+
const execute = vi.fn(() =>
|
|
282
|
+
(async function* () {
|
|
283
|
+
yield streamData(1, {
|
|
284
|
+
type: "turn.done",
|
|
285
|
+
createdAt,
|
|
286
|
+
state: {
|
|
287
|
+
status: "error",
|
|
288
|
+
message: "boom",
|
|
289
|
+
},
|
|
290
|
+
});
|
|
291
|
+
})(),
|
|
292
|
+
);
|
|
293
|
+
const prepareTurn = vi.fn(() => ({ id: undefined, execute }));
|
|
294
|
+
const session = {
|
|
295
|
+
prepareTurn,
|
|
296
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
297
|
+
} as unknown as AgentSession;
|
|
298
|
+
const onTurnIdAvailable = vi.fn();
|
|
299
|
+
|
|
300
|
+
await expect(
|
|
301
|
+
collectUpdates(
|
|
302
|
+
streamTurnContent(
|
|
303
|
+
session,
|
|
304
|
+
new PeerThreadFoldState(),
|
|
305
|
+
{ userMessage: "hello" },
|
|
306
|
+
new AbortController().signal,
|
|
307
|
+
undefined,
|
|
308
|
+
onTurnIdAvailable,
|
|
309
|
+
),
|
|
310
|
+
),
|
|
311
|
+
).rejects.toThrow("boom");
|
|
312
|
+
|
|
313
|
+
expect(onTurnIdAvailable).not.toHaveBeenCalled();
|
|
314
|
+
});
|
|
230
315
|
});
|
|
231
316
|
|
|
232
317
|
describe("resumeTurnStream", () => {
|
package/src/streamTurn.ts
CHANGED
|
@@ -2,6 +2,7 @@ import {
|
|
|
2
2
|
type AgentSession,
|
|
3
3
|
type Turn,
|
|
4
4
|
type TurnInputItem,
|
|
5
|
+
TrueFoundryGatewayApi,
|
|
5
6
|
} from "truefoundry-gateway-sdk/agents";
|
|
6
7
|
|
|
7
8
|
import {
|
|
@@ -18,7 +19,7 @@ export type StreamTurnOptions = {
|
|
|
18
19
|
inputs?: RequiredActionInput[];
|
|
19
20
|
/**
|
|
20
21
|
* Branch anchor for `prepareTurn`. Omit for `"auto"`. Pass `null` for a fresh
|
|
21
|
-
* root turn
|
|
22
|
+
* root turn — sent as `previous_turn_id: null` on the wire.
|
|
22
23
|
*/
|
|
23
24
|
previousTurnId?: string | null;
|
|
24
25
|
/** Extra headers for the createTurn request (`execute` request options). */
|
|
@@ -53,14 +54,26 @@ export async function* streamTurnContent(
|
|
|
53
54
|
options: StreamTurnOptions,
|
|
54
55
|
abortSignal: AbortSignal,
|
|
55
56
|
groupRootBaseline?: readonly string[],
|
|
57
|
+
/**
|
|
58
|
+
* Called once with the gateway-assigned turn ID as soon as it becomes
|
|
59
|
+
* available (after the first `turn.created` SSE event). Use this to
|
|
60
|
+
* reconcile the locally-generated optimistic ID with the real gateway ID
|
|
61
|
+
* so that edit/retry can find the turn in `buildSnapshotBeforeTurn`.
|
|
62
|
+
*/
|
|
63
|
+
onTurnIdAvailable?: (turnId: string) => void,
|
|
56
64
|
): AsyncGenerator<TurnStreamUpdate> {
|
|
57
|
-
|
|
65
|
+
// When previousTurnId is explicitly null, pass it through to prepareTurn so the
|
|
66
|
+
// SDK serializer emits `previous_turn_id: null` on the wire (first turn in session).
|
|
67
|
+
// The SDK type doesn't admit null, but the Fern serializer handles it correctly.
|
|
68
|
+
const previousTurnId: TrueFoundryGatewayApi.PreviousTurnIdInput | null | undefined =
|
|
58
69
|
options.previousTurnId === null
|
|
59
|
-
?
|
|
70
|
+
? null
|
|
60
71
|
: (options.previousTurnId ?? "auto");
|
|
61
72
|
const turn = session.prepareTurn({
|
|
62
73
|
input: buildTurnInput(options),
|
|
63
|
-
...(previousTurnId
|
|
74
|
+
...(previousTurnId !== undefined
|
|
75
|
+
? { previousTurnId: previousTurnId as TrueFoundryGatewayApi.PreviousTurnIdInput }
|
|
76
|
+
: {}),
|
|
64
77
|
});
|
|
65
78
|
|
|
66
79
|
const onAbort = bindAbort(session, abortSignal);
|
|
@@ -68,8 +81,16 @@ export async function* streamTurnContent(
|
|
|
68
81
|
return;
|
|
69
82
|
}
|
|
70
83
|
|
|
84
|
+
let turnIdNotified = false;
|
|
85
|
+
const notifyTurnIdIfAvailable = () => {
|
|
86
|
+
if (!turnIdNotified && turn.id != null) {
|
|
87
|
+
onTurnIdAvailable?.(turn.id);
|
|
88
|
+
turnIdNotified = true;
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
71
92
|
try {
|
|
72
|
-
|
|
93
|
+
for await (const update of streamTurnEvents(
|
|
73
94
|
turn.execute(
|
|
74
95
|
{ stream: true },
|
|
75
96
|
{
|
|
@@ -79,8 +100,21 @@ export async function* streamTurnContent(
|
|
|
79
100
|
),
|
|
80
101
|
foldState,
|
|
81
102
|
groupRootBaseline,
|
|
82
|
-
)
|
|
103
|
+
)) {
|
|
104
|
+
// After the first `turn.created` event, `turn.id` is set.
|
|
105
|
+
// Notify BEFORE yielding so the caller can update its tracking
|
|
106
|
+
// before the snapshot is written with the stream update.
|
|
107
|
+
notifyTurnIdIfAvailable();
|
|
108
|
+
yield update;
|
|
109
|
+
}
|
|
110
|
+
// Handle streams that complete without yielding any content.
|
|
111
|
+
notifyTurnIdIfAvailable();
|
|
83
112
|
} catch (error) {
|
|
113
|
+
// Error streams often throw on `turn.done` (status=error) without ever
|
|
114
|
+
// yielding content (e.g. model not servable). `turn.id` is still set
|
|
115
|
+
// after `turn.created` — notify so edit/retry can resolve the turn.
|
|
116
|
+
// Same for AbortError after create: keep local ids aligned with gateway.
|
|
117
|
+
notifyTurnIdIfAvailable();
|
|
84
118
|
if (error instanceof Error && error.name === "AbortError") {
|
|
85
119
|
return;
|
|
86
120
|
}
|
|
@@ -353,7 +353,14 @@ export function useTrueFoundryAgentMessages({
|
|
|
353
353
|
const runStream = useCallback(
|
|
354
354
|
(
|
|
355
355
|
createStream: (signal: AbortSignal) => AsyncGenerator<TurnStreamUpdate>,
|
|
356
|
-
|
|
356
|
+
/**
|
|
357
|
+
* A mutable ref whose `.current` is the turn ID to use for
|
|
358
|
+
* `activeStream.turnId`. Callers that capture the gateway turn ID
|
|
359
|
+
* via `onTurnIdAvailable` update this ref in-place so that both the
|
|
360
|
+
* pending-update flush and `commitActiveStream` always see the real
|
|
361
|
+
* gateway ID rather than the locally-generated optimistic one.
|
|
362
|
+
*/
|
|
363
|
+
turnIdRef: { current: string },
|
|
357
364
|
isContinuation: boolean,
|
|
358
365
|
): Promise<void> => {
|
|
359
366
|
const streamGeneration = ++streamGenerationRef.current;
|
|
@@ -368,7 +375,6 @@ export function useTrueFoundryAgentMessages({
|
|
|
368
375
|
// message tree (UI hang). The buffer belongs to this stream only.
|
|
369
376
|
let pendingStreamUpdate: {
|
|
370
377
|
update: TurnStreamUpdate;
|
|
371
|
-
turnId: string;
|
|
372
378
|
isContinuation: boolean;
|
|
373
379
|
} | null = null;
|
|
374
380
|
let streamUpdateRaf: number | null = null;
|
|
@@ -383,12 +389,14 @@ export function useTrueFoundryAgentMessages({
|
|
|
383
389
|
) {
|
|
384
390
|
return;
|
|
385
391
|
}
|
|
386
|
-
const { update,
|
|
387
|
-
pending;
|
|
392
|
+
const { update, isContinuation: pendingIsContinuation } = pending;
|
|
388
393
|
setSnapshot((prev) =>
|
|
389
394
|
replaceSessionSnapshot(prev, {
|
|
390
395
|
activeStream: {
|
|
391
|
-
|
|
396
|
+
// Read from the ref so we always use the latest ID,
|
|
397
|
+
// including any gateway ID that arrived after the RAf
|
|
398
|
+
// was scheduled.
|
|
399
|
+
turnId: turnIdRef.current,
|
|
392
400
|
update,
|
|
393
401
|
isContinuation: pendingIsContinuation,
|
|
394
402
|
},
|
|
@@ -397,7 +405,7 @@ export function useTrueFoundryAgentMessages({
|
|
|
397
405
|
};
|
|
398
406
|
|
|
399
407
|
const applyStreamUpdate = (update: TurnStreamUpdate) => {
|
|
400
|
-
pendingStreamUpdate = { update,
|
|
408
|
+
pendingStreamUpdate = { update, isContinuation };
|
|
401
409
|
if (streamUpdateRaf == null) {
|
|
402
410
|
streamUpdateRaf = requestAnimationFrame(flushPendingStreamUpdate);
|
|
403
411
|
}
|
|
@@ -540,7 +548,7 @@ export function useTrueFoundryAgentMessages({
|
|
|
540
548
|
undefined,
|
|
541
549
|
loadedSnapshot.groupRootBaseline,
|
|
542
550
|
),
|
|
543
|
-
turn.id,
|
|
551
|
+
{ current: turn.id },
|
|
544
552
|
isContinuation,
|
|
545
553
|
).catch(() => undefined);
|
|
546
554
|
}
|
|
@@ -588,6 +596,18 @@ export function useTrueFoundryAgentMessages({
|
|
|
588
596
|
isContinuation && continuationTurnId != null
|
|
589
597
|
? continuationTurnId
|
|
590
598
|
: generateId();
|
|
599
|
+
// First turns must send previousTurnId: null.
|
|
600
|
+
const isFirstTurnInSession =
|
|
601
|
+
"userMessage" in options &&
|
|
602
|
+
options.previousTurnId === undefined &&
|
|
603
|
+
snapshotRef.current.turns.length === 0 &&
|
|
604
|
+
snapshotRef.current.pendingUser == null &&
|
|
605
|
+
snapshotRef.current.activeStream == null;
|
|
606
|
+
|
|
607
|
+
// Mutable ref so runStream always reads the latest ID. For new
|
|
608
|
+
// user-message turns the local `generateId()` value is replaced
|
|
609
|
+
// with the gateway-assigned ID once the first SSE event arrives.
|
|
610
|
+
const turnIdRef = { current: turnId };
|
|
591
611
|
|
|
592
612
|
if ("inputs" in options) {
|
|
593
613
|
applyUserToolResponsesToFold(
|
|
@@ -676,14 +696,33 @@ export function useTrueFoundryAgentMessages({
|
|
|
676
696
|
userMessage: options.userMessage,
|
|
677
697
|
...(options.previousTurnId !== undefined
|
|
678
698
|
? { previousTurnId: options.previousTurnId }
|
|
679
|
-
:
|
|
699
|
+
: isFirstTurnInSession
|
|
700
|
+
? { previousTurnId: null }
|
|
701
|
+
: {}),
|
|
680
702
|
...streamHeaders,
|
|
681
703
|
},
|
|
682
704
|
signal,
|
|
683
705
|
groupRootBaseline,
|
|
706
|
+
// Rename the optimistic local ID to the gateway turn ID
|
|
707
|
+
// so that edit/retry can resolve the turn via the gateway.
|
|
708
|
+
(gatewayTurnId) => {
|
|
709
|
+
const oldId = turnIdRef.current;
|
|
710
|
+
if (gatewayTurnId === oldId) return;
|
|
711
|
+
turnIdRef.current = gatewayTurnId;
|
|
712
|
+
// Rename in the ref immediately so any synchronous read
|
|
713
|
+
// (e.g. commitActiveStream) sees the correct ID.
|
|
714
|
+
const renamePendingUser = (prev: SessionSnapshot): SessionSnapshot => {
|
|
715
|
+
if (prev.pendingUser?.turnId !== oldId) return prev;
|
|
716
|
+
return replaceSessionSnapshot(prev, {
|
|
717
|
+
pendingUser: { ...prev.pendingUser, turnId: gatewayTurnId },
|
|
718
|
+
});
|
|
719
|
+
};
|
|
720
|
+
snapshotRef.current = renamePendingUser(snapshotRef.current);
|
|
721
|
+
setSnapshot(renamePendingUser);
|
|
722
|
+
},
|
|
684
723
|
);
|
|
685
724
|
},
|
|
686
|
-
|
|
725
|
+
turnIdRef,
|
|
687
726
|
isContinuation,
|
|
688
727
|
);
|
|
689
728
|
},
|
|
@@ -784,7 +823,7 @@ export function useTrueFoundryAgentMessages({
|
|
|
784
823
|
undefined,
|
|
785
824
|
snapshotRef.current.groupRootBaseline,
|
|
786
825
|
),
|
|
787
|
-
turn.id,
|
|
826
|
+
{ current: turn.id },
|
|
788
827
|
true,
|
|
789
828
|
);
|
|
790
829
|
}, [runStream]);
|