@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.
- package/README.md +14 -77
- package/dist/index.d.ts +4 -1
- package/dist/index.js +314 -122
- package/dist/index.js.map +1 -1
- package/package.json +2 -2
- package/src/agentSessionModule.d.ts +14 -2
- package/src/convertTurnMessages.test.ts +362 -0
- package/src/convertTurnMessages.ts +193 -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 +6 -0
- package/src/index.ts +6 -5
- package/src/loadSessionSnapshot.test.ts +21 -6
- package/src/loadSessionSnapshot.ts +9 -5
- package/src/{truefoundryDraftThreadListAdapter.ts → private/truefoundryDraftThreadListAdapter.ts} +1 -1
- package/src/sessionSnapshot.ts +27 -1
- package/src/sessions.ts +1 -1
- package/src/truefoundryExtras.ts +2 -1
- package/src/types.ts +1 -1
- package/src/useTrueFoundryAgentMessages.test.tsx +225 -1
- package/src/useTrueFoundryAgentMessages.ts +178 -60
- package/src/useTrueFoundryAgentRuntime.ts +27 -13
- /package/src/{agentSpec.ts → private/agentSpec.ts} +0 -0
- /package/src/{bindDraftAgentSession.test.ts → private/bindDraftAgentSession.test.ts} +0 -0
- /package/src/{bindDraftAgentSession.ts → private/bindDraftAgentSession.ts} +0 -0
- /package/src/{draftSessionBridge.ts → private/draftSessionBridge.ts} +0 -0
- /package/src/{truefoundryDraftThreadListAdapter.test.ts → private/truefoundryDraftThreadListAdapter.test.ts} +0 -0
- /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* () {
|