@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
package/src/types.ts
CHANGED
|
@@ -9,7 +9,7 @@ import type {
|
|
|
9
9
|
import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
|
|
10
10
|
import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
|
|
11
11
|
|
|
12
|
-
import type { AgentSpec } from "./agentSpec.js";
|
|
12
|
+
import type { AgentSpec } from "./private/agentSpec.js";
|
|
13
13
|
|
|
14
14
|
export type NamedAgentConfig = {
|
|
15
15
|
mode: "named";
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
import type { ThreadMessage } from "@assistant-ui/core";
|
|
3
3
|
import { act, renderHook, waitFor } from "@testing-library/react";
|
|
4
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
5
|
import type { AgentSessionClient, Turn } from "truefoundry-gateway-sdk/agents";
|
|
6
6
|
import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
|
|
7
7
|
|
|
@@ -288,6 +288,10 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
288
288
|
vi.mocked(resumeTurnStream).mockReturnValue(singleUpdateStream());
|
|
289
289
|
});
|
|
290
290
|
|
|
291
|
+
afterEach(() => {
|
|
292
|
+
vi.unstubAllGlobals();
|
|
293
|
+
});
|
|
294
|
+
|
|
291
295
|
it("clears messages when sessionId is undefined", async () => {
|
|
292
296
|
const { result } = renderHook(() =>
|
|
293
297
|
useTrueFoundryAgentMessages({ client: mockClient, sessionId: undefined }),
|
|
@@ -325,6 +329,56 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
325
329
|
expect(loadSessionSnapshot).not.toHaveBeenCalled();
|
|
326
330
|
});
|
|
327
331
|
|
|
332
|
+
it("sendTurn forwards getTurnHeaders only when they resolve to a value", async () => {
|
|
333
|
+
const getTurnHeaders = vi
|
|
334
|
+
.fn()
|
|
335
|
+
.mockResolvedValueOnce({
|
|
336
|
+
"x-tfy-session-last-updated-at": "2026-06-30T12:00:00.000Z",
|
|
337
|
+
})
|
|
338
|
+
.mockResolvedValueOnce(undefined);
|
|
339
|
+
|
|
340
|
+
const { result } = renderHook(() =>
|
|
341
|
+
useTrueFoundryAgentMessages({
|
|
342
|
+
client: mockClient,
|
|
343
|
+
sessionId: "session-1",
|
|
344
|
+
getTurnHeaders,
|
|
345
|
+
}),
|
|
346
|
+
);
|
|
347
|
+
|
|
348
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
349
|
+
|
|
350
|
+
await act(async () => {
|
|
351
|
+
await result.current.sendTurn({ userMessage: "first" });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(getTurnHeaders).toHaveBeenCalledTimes(1);
|
|
355
|
+
expect(streamTurnContent).toHaveBeenCalledWith(
|
|
356
|
+
expect.anything(),
|
|
357
|
+
expect.any(PeerThreadFoldState),
|
|
358
|
+
{
|
|
359
|
+
userMessage: "first",
|
|
360
|
+
headers: {
|
|
361
|
+
"x-tfy-session-last-updated-at": "2026-06-30T12:00:00.000Z",
|
|
362
|
+
},
|
|
363
|
+
},
|
|
364
|
+
expect.any(AbortSignal),
|
|
365
|
+
expect.any(Array),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
await act(async () => {
|
|
369
|
+
await result.current.sendTurn({ userMessage: "second" });
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
expect(getTurnHeaders).toHaveBeenCalledTimes(2);
|
|
373
|
+
expect(streamTurnContent).toHaveBeenLastCalledWith(
|
|
374
|
+
expect.anything(),
|
|
375
|
+
expect.any(PeerThreadFoldState),
|
|
376
|
+
{ userMessage: "second" },
|
|
377
|
+
expect.any(AbortSignal),
|
|
378
|
+
expect.any(Array),
|
|
379
|
+
);
|
|
380
|
+
});
|
|
381
|
+
|
|
328
382
|
it("loads converted session history on mount", async () => {
|
|
329
383
|
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
330
384
|
snapshotWithUserTurn("hello"),
|
|
@@ -372,6 +426,52 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
372
426
|
);
|
|
373
427
|
});
|
|
374
428
|
|
|
429
|
+
it("clears isLoading while a resumed turn is still streaming", async () => {
|
|
430
|
+
let releaseStream: (() => void) | undefined;
|
|
431
|
+
vi.mocked(resumeTurnStream).mockReturnValue(
|
|
432
|
+
(async function* () {
|
|
433
|
+
await new Promise<void>((resolve) => {
|
|
434
|
+
releaseStream = resolve;
|
|
435
|
+
});
|
|
436
|
+
yield { content: [{ type: "text" as const, text: "streamed reply" }] };
|
|
437
|
+
})(),
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const runningTurn = {
|
|
441
|
+
id: "turn-running",
|
|
442
|
+
input: [{ type: "user.message", content: "keep going" }],
|
|
443
|
+
createdAt: new Date().toISOString(),
|
|
444
|
+
} as Turn;
|
|
445
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue(
|
|
446
|
+
replaceSessionSnapshot(createEmptySessionSnapshot(), {
|
|
447
|
+
runningTurn,
|
|
448
|
+
unstable_resume: true,
|
|
449
|
+
pendingUser: {
|
|
450
|
+
turnId: runningTurn.id,
|
|
451
|
+
content: "keep going",
|
|
452
|
+
createdAt: new Date(runningTurn.createdAt),
|
|
453
|
+
},
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
|
|
457
|
+
const { result } = renderHook(() =>
|
|
458
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
462
|
+
expect(resumeTurnStream).toHaveBeenCalled();
|
|
463
|
+
await waitFor(() => expect(result.current.isRunning).toBe(true));
|
|
464
|
+
expect(result.current.messages[0]).toMatchObject({
|
|
465
|
+
role: "user",
|
|
466
|
+
content: [{ type: "text", text: "keep going" }],
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
await act(async () => {
|
|
470
|
+
releaseStream?.();
|
|
471
|
+
});
|
|
472
|
+
await waitFor(() => expect(result.current.isRunning).toBe(false));
|
|
473
|
+
});
|
|
474
|
+
|
|
375
475
|
it("sendTurn appends a user message and streams the assistant reply", async () => {
|
|
376
476
|
const { result } = renderHook(() =>
|
|
377
477
|
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
@@ -395,6 +495,180 @@ describe("useTrueFoundryAgentMessages", () => {
|
|
|
395
495
|
});
|
|
396
496
|
});
|
|
397
497
|
|
|
498
|
+
it("editFromTurn drops prior turns before showing the edited user message", async () => {
|
|
499
|
+
const createdAt = new Date().toISOString();
|
|
500
|
+
const fold = new PeerThreadFoldState();
|
|
501
|
+
ingestTurnEvent(fold, {
|
|
502
|
+
type: "model.message",
|
|
503
|
+
id: "model-1",
|
|
504
|
+
createdAt,
|
|
505
|
+
threadId: ROOT_THREAD_ID,
|
|
506
|
+
role: "assistant",
|
|
507
|
+
content: "How are you",
|
|
508
|
+
} as never);
|
|
509
|
+
|
|
510
|
+
vi.mocked(loadSessionSnapshot).mockResolvedValue({
|
|
511
|
+
...replaceSessionSnapshot(createEmptySessionSnapshot(), {
|
|
512
|
+
turns: [
|
|
513
|
+
{
|
|
514
|
+
id: "turn-1",
|
|
515
|
+
userText: "Hello",
|
|
516
|
+
createdAt,
|
|
517
|
+
state: {
|
|
518
|
+
status: "done",
|
|
519
|
+
requiredActions: [],
|
|
520
|
+
completedAt: createdAt,
|
|
521
|
+
},
|
|
522
|
+
input: [{ type: "user.message", content: "Hello" }],
|
|
523
|
+
rootModelMessageIds: ["model-1"],
|
|
524
|
+
},
|
|
525
|
+
],
|
|
526
|
+
}),
|
|
527
|
+
fold,
|
|
528
|
+
});
|
|
529
|
+
vi.mocked(getSession).mockResolvedValue({
|
|
530
|
+
cancel: vi.fn().mockResolvedValue(undefined),
|
|
531
|
+
listTurns: vi.fn(async function* () {}),
|
|
532
|
+
listEvents: vi.fn(async function* () {}),
|
|
533
|
+
} as never);
|
|
534
|
+
|
|
535
|
+
let releaseStream: (() => void) | undefined;
|
|
536
|
+
vi.mocked(streamTurnContent).mockReturnValue(
|
|
537
|
+
(async function* () {
|
|
538
|
+
yield {
|
|
539
|
+
content: [{ type: "text" as const, text: "sunny" }],
|
|
540
|
+
};
|
|
541
|
+
await new Promise<void>((resolve) => {
|
|
542
|
+
releaseStream = resolve;
|
|
543
|
+
});
|
|
544
|
+
})(),
|
|
545
|
+
);
|
|
546
|
+
|
|
547
|
+
const { result } = renderHook(() =>
|
|
548
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
549
|
+
);
|
|
550
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
551
|
+
expect(result.current.messages.map((m) => m.role)).toEqual([
|
|
552
|
+
"user",
|
|
553
|
+
"assistant",
|
|
554
|
+
]);
|
|
555
|
+
expect(result.current.messages[0]).toMatchObject({
|
|
556
|
+
content: [{ type: "text", text: "Hello" }],
|
|
557
|
+
});
|
|
558
|
+
|
|
559
|
+
let editPromise: Promise<void>;
|
|
560
|
+
await act(async () => {
|
|
561
|
+
editPromise = result.current.editFromTurn(
|
|
562
|
+
"turn-1",
|
|
563
|
+
"what is the weather like?",
|
|
564
|
+
);
|
|
565
|
+
await Promise.resolve();
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
await waitFor(() => {
|
|
569
|
+
const texts = result.current.messages
|
|
570
|
+
.filter((m) => m.role === "user")
|
|
571
|
+
.map((m) =>
|
|
572
|
+
m.content
|
|
573
|
+
.filter((p): p is { type: "text"; text: string } => p.type === "text")
|
|
574
|
+
.map((p) => p.text)
|
|
575
|
+
.join(""),
|
|
576
|
+
);
|
|
577
|
+
expect(texts).toEqual(["what is the weather like?"]);
|
|
578
|
+
});
|
|
579
|
+
expect(
|
|
580
|
+
result.current.messages.some(
|
|
581
|
+
(m) =>
|
|
582
|
+
m.role === "user" &&
|
|
583
|
+
m.content.some((p) => p.type === "text" && p.text === "Hello"),
|
|
584
|
+
),
|
|
585
|
+
).toBe(false);
|
|
586
|
+
expect(
|
|
587
|
+
result.current.messages.some(
|
|
588
|
+
(m) =>
|
|
589
|
+
m.role === "assistant" &&
|
|
590
|
+
m.content.some(
|
|
591
|
+
(p) => p.type === "text" && p.text.includes("How are you"),
|
|
592
|
+
),
|
|
593
|
+
),
|
|
594
|
+
).toBe(false);
|
|
595
|
+
|
|
596
|
+
await act(async () => {
|
|
597
|
+
releaseStream?.();
|
|
598
|
+
await editPromise!;
|
|
599
|
+
});
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
it("does not let a superseded stream complete the current stream", async () => {
|
|
603
|
+
let releaseFirstStream: (() => void) | undefined;
|
|
604
|
+
let releaseSecondStream: (() => void) | undefined;
|
|
605
|
+
let nextAnimationFrame = 1;
|
|
606
|
+
const animationFrames = new Map<number, FrameRequestCallback>();
|
|
607
|
+
vi.stubGlobal(
|
|
608
|
+
"requestAnimationFrame",
|
|
609
|
+
vi.fn((callback: FrameRequestCallback) => {
|
|
610
|
+
const frame = nextAnimationFrame++;
|
|
611
|
+
animationFrames.set(frame, callback);
|
|
612
|
+
return frame;
|
|
613
|
+
}),
|
|
614
|
+
);
|
|
615
|
+
vi.stubGlobal(
|
|
616
|
+
"cancelAnimationFrame",
|
|
617
|
+
vi.fn((frame: number) => animationFrames.delete(frame)),
|
|
618
|
+
);
|
|
619
|
+
vi.mocked(streamTurnContent)
|
|
620
|
+
.mockReturnValueOnce(
|
|
621
|
+
(async function* () {
|
|
622
|
+
yield { content: [{ type: "text" as const, text: "first reply" }] };
|
|
623
|
+
await new Promise<void>((resolve) => {
|
|
624
|
+
releaseFirstStream = resolve;
|
|
625
|
+
});
|
|
626
|
+
})(),
|
|
627
|
+
)
|
|
628
|
+
.mockReturnValueOnce(
|
|
629
|
+
(async function* () {
|
|
630
|
+
yield { content: [{ type: "text" as const, text: "second reply" }] };
|
|
631
|
+
await new Promise<void>((resolve) => {
|
|
632
|
+
releaseSecondStream = resolve;
|
|
633
|
+
});
|
|
634
|
+
})(),
|
|
635
|
+
);
|
|
636
|
+
|
|
637
|
+
const { result } = renderHook(() =>
|
|
638
|
+
useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
|
|
639
|
+
);
|
|
640
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
641
|
+
|
|
642
|
+
const firstSend = result.current.sendTurn({ userMessage: "first" });
|
|
643
|
+
await waitFor(() => expect(streamTurnContent).toHaveBeenCalledTimes(1));
|
|
644
|
+
|
|
645
|
+
const secondSend = result.current.sendTurn({ userMessage: "second" });
|
|
646
|
+
await waitFor(() => expect(streamTurnContent).toHaveBeenCalledTimes(2));
|
|
647
|
+
|
|
648
|
+
await act(async () => {
|
|
649
|
+
releaseFirstStream?.();
|
|
650
|
+
await firstSend;
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
expect(result.current.isRunning).toBe(true);
|
|
654
|
+
|
|
655
|
+
await act(async () => {
|
|
656
|
+
animationFrames.get(2)?.(performance.now());
|
|
657
|
+
});
|
|
658
|
+
expect(result.current.messages.at(-1)).toMatchObject({
|
|
659
|
+
role: "assistant",
|
|
660
|
+
content: [{ type: "text", text: "second reply" }],
|
|
661
|
+
});
|
|
662
|
+
expect(result.current.messages.at(-1)?.status).not.toMatchObject({
|
|
663
|
+
type: "complete",
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
await act(async () => {
|
|
667
|
+
releaseSecondStream?.();
|
|
668
|
+
await secondSend;
|
|
669
|
+
});
|
|
670
|
+
});
|
|
671
|
+
|
|
398
672
|
it("carries a streamed sandboxId through commit so it survives after the stream completes", async () => {
|
|
399
673
|
vi.mocked(streamTurnContent).mockReturnValue(
|
|
400
674
|
(async function* () {
|