@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.
Files changed (33) hide show
  1. package/README.md +45 -77
  2. package/dist/index.d.ts +14 -2
  3. package/dist/index.js +697 -147
  4. package/dist/index.js.map +1 -1
  5. package/package.json +2 -2
  6. package/src/agentSessionModule.d.ts +14 -2
  7. package/src/convertTurnMessages.test.ts +502 -1
  8. package/src/convertTurnMessages.ts +471 -28
  9. package/src/createSubAgent.ts +13 -5
  10. package/src/draftAgentConfig.test.ts +1 -1
  11. package/src/foldPeerThreads.test.ts +56 -0
  12. package/src/foldPeerThreads.ts +7 -1
  13. package/src/hooks.ts +24 -0
  14. package/src/index.ts +7 -5
  15. package/src/loadSessionSnapshot.test.ts +21 -6
  16. package/src/loadSessionSnapshot.ts +9 -5
  17. package/src/{bindDraftAgentSession.test.ts → private/bindDraftAgentSession.test.ts} +3 -2
  18. package/src/{draftSessionBridge.ts → private/draftSessionBridge.ts} +8 -2
  19. package/src/{truefoundryDraftThreadListAdapter.ts → private/truefoundryDraftThreadListAdapter.ts} +1 -1
  20. package/src/private/useDraftAgentSpec.test.tsx +153 -0
  21. package/src/{useDraftAgentSpec.ts → private/useDraftAgentSpec.ts} +80 -3
  22. package/src/sessionSnapshot.ts +48 -2
  23. package/src/sessions.ts +1 -1
  24. package/src/streamTurn.test.ts +34 -0
  25. package/src/streamTurn.ts +9 -1
  26. package/src/truefoundryExtras.ts +5 -1
  27. package/src/types.ts +1 -1
  28. package/src/useTrueFoundryAgentMessages.test.tsx +275 -1
  29. package/src/useTrueFoundryAgentMessages.ts +263 -70
  30. package/src/useTrueFoundryAgentRuntime.ts +51 -13
  31. /package/src/{agentSpec.ts → private/agentSpec.ts} +0 -0
  32. /package/src/{bindDraftAgentSession.ts → private/bindDraftAgentSession.ts} +0 -0
  33. /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* () {