@truefoundry/assistant-ui-runtime 0.1.0-rc.1 → 0.1.1

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 (30) hide show
  1. package/README.md +14 -77
  2. package/dist/index.d.ts +4 -1
  3. package/dist/index.js +314 -122
  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 +362 -0
  8. package/src/convertTurnMessages.ts +193 -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 +6 -0
  14. package/src/index.ts +6 -5
  15. package/src/loadSessionSnapshot.test.ts +21 -6
  16. package/src/loadSessionSnapshot.ts +9 -5
  17. package/src/{truefoundryDraftThreadListAdapter.ts → private/truefoundryDraftThreadListAdapter.ts} +1 -1
  18. package/src/sessionSnapshot.ts +27 -1
  19. package/src/sessions.ts +1 -1
  20. package/src/truefoundryExtras.ts +2 -1
  21. package/src/types.ts +1 -1
  22. package/src/useTrueFoundryAgentMessages.test.tsx +225 -1
  23. package/src/useTrueFoundryAgentMessages.ts +178 -60
  24. package/src/useTrueFoundryAgentRuntime.ts +27 -13
  25. /package/src/{agentSpec.ts → private/agentSpec.ts} +0 -0
  26. /package/src/{bindDraftAgentSession.test.ts → private/bindDraftAgentSession.test.ts} +0 -0
  27. /package/src/{bindDraftAgentSession.ts → private/bindDraftAgentSession.ts} +0 -0
  28. /package/src/{draftSessionBridge.ts → private/draftSessionBridge.ts} +0 -0
  29. /package/src/{truefoundryDraftThreadListAdapter.test.ts → private/truefoundryDraftThreadListAdapter.test.ts} +0 -0
  30. /package/src/{useDraftAgentSpec.ts → private/useDraftAgentSpec.ts} +0 -0
@@ -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 }),
@@ -372,6 +376,52 @@ describe("useTrueFoundryAgentMessages", () => {
372
376
  );
373
377
  });
374
378
 
379
+ it("clears isLoading while a resumed turn is still streaming", async () => {
380
+ let releaseStream: (() => void) | undefined;
381
+ vi.mocked(resumeTurnStream).mockReturnValue(
382
+ (async function* () {
383
+ await new Promise<void>((resolve) => {
384
+ releaseStream = resolve;
385
+ });
386
+ yield { content: [{ type: "text" as const, text: "streamed reply" }] };
387
+ })(),
388
+ );
389
+
390
+ const runningTurn = {
391
+ id: "turn-running",
392
+ input: [{ type: "user.message", content: "keep going" }],
393
+ createdAt: new Date().toISOString(),
394
+ } as Turn;
395
+ vi.mocked(loadSessionSnapshot).mockResolvedValue(
396
+ replaceSessionSnapshot(createEmptySessionSnapshot(), {
397
+ runningTurn,
398
+ unstable_resume: true,
399
+ pendingUser: {
400
+ turnId: runningTurn.id,
401
+ content: "keep going",
402
+ createdAt: new Date(runningTurn.createdAt),
403
+ },
404
+ }),
405
+ );
406
+
407
+ const { result } = renderHook(() =>
408
+ useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
409
+ );
410
+
411
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
412
+ expect(resumeTurnStream).toHaveBeenCalled();
413
+ await waitFor(() => expect(result.current.isRunning).toBe(true));
414
+ expect(result.current.messages[0]).toMatchObject({
415
+ role: "user",
416
+ content: [{ type: "text", text: "keep going" }],
417
+ });
418
+
419
+ await act(async () => {
420
+ releaseStream?.();
421
+ });
422
+ await waitFor(() => expect(result.current.isRunning).toBe(false));
423
+ });
424
+
375
425
  it("sendTurn appends a user message and streams the assistant reply", async () => {
376
426
  const { result } = renderHook(() =>
377
427
  useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
@@ -395,6 +445,180 @@ describe("useTrueFoundryAgentMessages", () => {
395
445
  });
396
446
  });
397
447
 
448
+ it("editFromTurn drops prior turns before showing the edited user message", async () => {
449
+ const createdAt = new Date().toISOString();
450
+ const fold = new PeerThreadFoldState();
451
+ ingestTurnEvent(fold, {
452
+ type: "model.message",
453
+ id: "model-1",
454
+ createdAt,
455
+ threadId: ROOT_THREAD_ID,
456
+ role: "assistant",
457
+ content: "How are you",
458
+ } as never);
459
+
460
+ vi.mocked(loadSessionSnapshot).mockResolvedValue({
461
+ ...replaceSessionSnapshot(createEmptySessionSnapshot(), {
462
+ turns: [
463
+ {
464
+ id: "turn-1",
465
+ userText: "Hello",
466
+ createdAt,
467
+ state: {
468
+ status: "done",
469
+ requiredActions: [],
470
+ completedAt: createdAt,
471
+ },
472
+ input: [{ type: "user.message", content: "Hello" }],
473
+ rootModelMessageIds: ["model-1"],
474
+ },
475
+ ],
476
+ }),
477
+ fold,
478
+ });
479
+ vi.mocked(getSession).mockResolvedValue({
480
+ cancel: vi.fn().mockResolvedValue(undefined),
481
+ listTurns: vi.fn(async function* () {}),
482
+ listEvents: vi.fn(async function* () {}),
483
+ } as never);
484
+
485
+ let releaseStream: (() => void) | undefined;
486
+ vi.mocked(streamTurnContent).mockReturnValue(
487
+ (async function* () {
488
+ yield {
489
+ content: [{ type: "text" as const, text: "sunny" }],
490
+ };
491
+ await new Promise<void>((resolve) => {
492
+ releaseStream = resolve;
493
+ });
494
+ })(),
495
+ );
496
+
497
+ const { result } = renderHook(() =>
498
+ useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
499
+ );
500
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
501
+ expect(result.current.messages.map((m) => m.role)).toEqual([
502
+ "user",
503
+ "assistant",
504
+ ]);
505
+ expect(result.current.messages[0]).toMatchObject({
506
+ content: [{ type: "text", text: "Hello" }],
507
+ });
508
+
509
+ let editPromise: Promise<void>;
510
+ await act(async () => {
511
+ editPromise = result.current.editFromTurn(
512
+ "turn-1",
513
+ "what is the weather like?",
514
+ );
515
+ await Promise.resolve();
516
+ });
517
+
518
+ await waitFor(() => {
519
+ const texts = result.current.messages
520
+ .filter((m) => m.role === "user")
521
+ .map((m) =>
522
+ m.content
523
+ .filter((p): p is { type: "text"; text: string } => p.type === "text")
524
+ .map((p) => p.text)
525
+ .join(""),
526
+ );
527
+ expect(texts).toEqual(["what is the weather like?"]);
528
+ });
529
+ expect(
530
+ result.current.messages.some(
531
+ (m) =>
532
+ m.role === "user" &&
533
+ m.content.some((p) => p.type === "text" && p.text === "Hello"),
534
+ ),
535
+ ).toBe(false);
536
+ expect(
537
+ result.current.messages.some(
538
+ (m) =>
539
+ m.role === "assistant" &&
540
+ m.content.some(
541
+ (p) => p.type === "text" && p.text.includes("How are you"),
542
+ ),
543
+ ),
544
+ ).toBe(false);
545
+
546
+ await act(async () => {
547
+ releaseStream?.();
548
+ await editPromise!;
549
+ });
550
+ });
551
+
552
+ it("does not let a superseded stream complete the current stream", async () => {
553
+ let releaseFirstStream: (() => void) | undefined;
554
+ let releaseSecondStream: (() => void) | undefined;
555
+ let nextAnimationFrame = 1;
556
+ const animationFrames = new Map<number, FrameRequestCallback>();
557
+ vi.stubGlobal(
558
+ "requestAnimationFrame",
559
+ vi.fn((callback: FrameRequestCallback) => {
560
+ const frame = nextAnimationFrame++;
561
+ animationFrames.set(frame, callback);
562
+ return frame;
563
+ }),
564
+ );
565
+ vi.stubGlobal(
566
+ "cancelAnimationFrame",
567
+ vi.fn((frame: number) => animationFrames.delete(frame)),
568
+ );
569
+ vi.mocked(streamTurnContent)
570
+ .mockReturnValueOnce(
571
+ (async function* () {
572
+ yield { content: [{ type: "text" as const, text: "first reply" }] };
573
+ await new Promise<void>((resolve) => {
574
+ releaseFirstStream = resolve;
575
+ });
576
+ })(),
577
+ )
578
+ .mockReturnValueOnce(
579
+ (async function* () {
580
+ yield { content: [{ type: "text" as const, text: "second reply" }] };
581
+ await new Promise<void>((resolve) => {
582
+ releaseSecondStream = resolve;
583
+ });
584
+ })(),
585
+ );
586
+
587
+ const { result } = renderHook(() =>
588
+ useTrueFoundryAgentMessages({ client: mockClient, sessionId: "session-1" }),
589
+ );
590
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
591
+
592
+ const firstSend = result.current.sendTurn({ userMessage: "first" });
593
+ await waitFor(() => expect(streamTurnContent).toHaveBeenCalledTimes(1));
594
+
595
+ const secondSend = result.current.sendTurn({ userMessage: "second" });
596
+ await waitFor(() => expect(streamTurnContent).toHaveBeenCalledTimes(2));
597
+
598
+ await act(async () => {
599
+ releaseFirstStream?.();
600
+ await firstSend;
601
+ });
602
+
603
+ expect(result.current.isRunning).toBe(true);
604
+
605
+ await act(async () => {
606
+ animationFrames.get(2)?.(performance.now());
607
+ });
608
+ expect(result.current.messages.at(-1)).toMatchObject({
609
+ role: "assistant",
610
+ content: [{ type: "text", text: "second reply" }],
611
+ });
612
+ expect(result.current.messages.at(-1)?.status).not.toMatchObject({
613
+ type: "complete",
614
+ });
615
+
616
+ await act(async () => {
617
+ releaseSecondStream?.();
618
+ await secondSend;
619
+ });
620
+ });
621
+
398
622
  it("carries a streamed sandboxId through commit so it survives after the stream completes", async () => {
399
623
  vi.mocked(streamTurnContent).mockReturnValue(
400
624
  (async function* () {