@truefoundry/assistant-ui-runtime 0.1.6 → 0.1.7

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 +3 -2
  2. package/dist/{chunk-Q2SHKMLM.js → chunk-SQDOTGP2.js} +32 -10
  3. package/dist/chunk-SQDOTGP2.js.map +1 -0
  4. package/dist/index.d.ts +19 -23
  5. package/dist/index.js +256 -162
  6. package/dist/index.js.map +1 -1
  7. package/dist/plugins/truefoundry-agent-server-adapter/index.d.ts +3 -6
  8. package/dist/plugins/truefoundry-agent-server-adapter/index.js +1 -1
  9. package/dist/server/index.d.ts +2 -2
  10. package/dist/{types-BfiFf8O1.d.ts → types-DbNsU075.d.ts} +196 -9
  11. package/package.json +1 -1
  12. package/src/{private → draft}/agentSpec.ts +14 -17
  13. package/src/{private → draft}/draftSessionBridge.ts +1 -2
  14. package/src/{private → draft}/truefoundryDraftThreadListAdapter.test.ts +1 -1
  15. package/src/{private → draft}/truefoundryDraftThreadListAdapter.ts +2 -1
  16. package/src/{private → draft}/useDraftAgentSpec.ts +16 -5
  17. package/src/draftAgentConfig.test.ts +2 -1
  18. package/src/index.ts +30 -7
  19. package/src/plugins/truefoundry-agent-server-adapter/index.ts +51 -11
  20. package/src/plugins/truefoundry-agent-server-adapter/types.ts +6 -4
  21. package/src/server/index.ts +23 -0
  22. package/src/server/types.ts +256 -11
  23. package/src/truefoundryExtras.ts +4 -1
  24. package/src/truefoundryOwnedSessionsThreadListAdapter.ts +1 -1
  25. package/src/types.ts +1 -2
  26. package/src/useTrueFoundryAgentMessages.test.tsx +261 -1
  27. package/src/useTrueFoundryAgentMessages.ts +284 -176
  28. package/src/useTrueFoundryAgentRuntime.ts +31 -21
  29. package/dist/chunk-Q2SHKMLM.js.map +0 -1
  30. /package/src/{private → draft}/useDraftAgentSpec.test.tsx +0 -0
@@ -31,6 +31,29 @@ export type {
31
31
  Turn,
32
32
  AgentChatServer,
33
33
  AgentBuilderServer,
34
+ ProviderType,
35
+ ModelEntry,
36
+ ModelProviderConfigBase,
37
+ ModelProviderBase,
38
+ ModelProviderCatalogEntry,
39
+ CreateModelProviderRequest,
40
+ UpdateModelProviderRequest,
41
+ ModelCatalogServer,
42
+ ToolBase,
43
+ ConnectorAuthType,
44
+ ConnectorAuth,
45
+ ConnectorAuthPublic,
46
+ ConnectorConfigBase,
47
+ ConnectorBase,
48
+ ConnectorCatalogEntry,
49
+ CreateConnectorRequest,
50
+ UpdateConnectorRequest,
51
+ ConnectorCatalogServer,
52
+ SkillBase,
53
+ CreateSkillRequest,
54
+ SkillCatalogServer,
55
+ CatalogServer,
56
+ AgentUIServerPort,
34
57
  } from "./types.js";
35
58
 
36
59
  export type {
@@ -10,8 +10,7 @@ import type {
10
10
  ActionRequiredEvent,
11
11
  SessionEventItem,
12
12
  TurnEvent,
13
- TurnStreamData,
14
- TurnStreamingEvent,
13
+ TurnStreamData
15
14
  } from "./events.js";
16
15
 
17
16
  // ---------------------------------------------------------------------------
@@ -72,7 +71,7 @@ export type SkillMount = object;
72
71
  export type McpServerMount = object;
73
72
 
74
73
  // ---------------------------------------------------------------------------
75
- // AgentSpec — model + skills + mcpServers on base; host widens the rest
74
+ // AgentSpec — model / skills / mcpServers are type params; host widens the rest
76
75
  // ---------------------------------------------------------------------------
77
76
 
78
77
  export interface ModelParams {
@@ -87,17 +86,19 @@ export interface Model {
87
86
 
88
87
  /**
89
88
  * SDK-owned agent definition — fields the FE reads/writes.
90
- * Host adds additional fields via `TSpec extends AgentSpec`.
89
+ * Host widens `model` / `skills` / `mcpServers` via type params, and adds
90
+ * extra fields via `TSpec extends AgentSpec<...>`.
91
91
  */
92
- export interface AgentSpec {
93
- model: Model;
94
- skills?: SkillMount[];
95
- mcpServers?: McpServerMount[];
92
+ export interface AgentSpec<
93
+ TModel extends Model = Model,
94
+ TSkill extends SkillMount = SkillMount,
95
+ TMcp extends McpServerMount = McpServerMount,
96
+ > {
97
+ model: TModel;
98
+ skills?: TSkill[];
99
+ mcpServers?: TMcp[];
96
100
  instructions?: string;
97
- messages?: unknown[];
98
101
  variables?: Record<string, string>;
99
- responseFormat?: unknown;
100
- config?: unknown;
101
102
  }
102
103
 
103
104
  // ---------------------------------------------------------------------------
@@ -317,3 +318,247 @@ export interface AgentBuilderServer<
317
318
  }): Promise<TSave>;
318
319
  deleteAgent?(req: { agentName: string }): Promise<void>;
319
320
  }
321
+
322
+ // ---------------------------------------------------------------------------
323
+ // Catalog management — FE-minimal settings DTOs (host extends via generics)
324
+ // ---------------------------------------------------------------------------
325
+
326
+ /**
327
+ * Provider type id. Reserved literal: `"custom"` for user-defined providers;
328
+ * any other string is a builtin (e.g. `"openai"`, `"anthropic"`).
329
+ *
330
+ * Note: `string | "custom"` is useless in TypeScript (`"custom"` ⊆ `string`),
331
+ * so this stays `string` and `"custom"` is a documented convention.
332
+ */
333
+ export type ProviderType = string;
334
+
335
+ /**
336
+ * Model row — form "Model ID" + "Display name".
337
+ * Host extends for properties, etc.
338
+ */
339
+ export interface ModelEntry {
340
+ id: string;
341
+ name: string;
342
+ }
343
+
344
+ /**
345
+ * Write config for create/update (custom form + catalog "Save key").
346
+ * Host extends. `baseUrl` present iff `type === "custom"`.
347
+ */
348
+ export interface ModelProviderConfigBase<TModel extends ModelEntry = ModelEntry> {
349
+ type: ProviderType;
350
+ name: string;
351
+ /** Present iff `type === "custom"`. */
352
+ baseUrl?: string;
353
+ apiKey: string;
354
+ models: TModel[];
355
+ }
356
+
357
+ /**
358
+ * Configured provider card (list/read). No raw `apiKey`.
359
+ * Host extends for apiKeySet, timestamps, etc.
360
+ */
361
+ export interface ModelProviderBase<TModel extends ModelEntry = ModelEntry> {
362
+ id: string;
363
+ type: ProviderType;
364
+ name: string;
365
+ /** Present iff `type === "custom"`. */
366
+ baseUrl?: string;
367
+ models: TModel[];
368
+ }
369
+
370
+ /**
371
+ * Discovery-only catalog provider (AVAILABLE list).
372
+ * `type` must not be `"custom"` — custom providers use the custom form.
373
+ * Host extends for richer model rows.
374
+ */
375
+ export interface ModelProviderCatalogEntry<TModel extends ModelEntry = ModelEntry> {
376
+ type: ProviderType;
377
+ name: string;
378
+ models: TModel[];
379
+ }
380
+
381
+ /** Create — no `id`; server assigns it. Catalog path = entry + apiKey. */
382
+ export type CreateModelProviderRequest<TModel extends ModelEntry = ModelEntry> =
383
+ ModelProviderConfigBase<TModel>;
384
+
385
+ /** Update — `id` required. */
386
+ export type UpdateModelProviderRequest<TModel extends ModelEntry = ModelEntry> =
387
+ ModelProviderConfigBase<TModel> & { id: string };
388
+
389
+ export interface ModelCatalogServer<
390
+ TModel extends ModelEntry = ModelEntry,
391
+ TProvider extends ModelProviderBase<TModel> = ModelProviderBase<TModel>,
392
+ TCatalogProvider extends ModelProviderCatalogEntry<TModel> = ModelProviderCatalogEntry<TModel>,
393
+ TCreate extends CreateModelProviderRequest<TModel> = CreateModelProviderRequest<TModel>,
394
+ TUpdate extends UpdateModelProviderRequest<TModel> = UpdateModelProviderRequest<TModel>,
395
+ > {
396
+ getModelProviderCatalog(): Promise<TCatalogProvider[]>;
397
+ listModelProviders(): Promise<TProvider[]>;
398
+ createModelProvider(req: TCreate): Promise<TProvider>;
399
+ /** Full replace update keyed by provider `id`. */
400
+ updateModelProvider(req: TUpdate): Promise<TProvider>;
401
+ deleteModelProvider?(req: { id: string }): Promise<void>;
402
+ }
403
+
404
+ /** Tool row on a connector detail. Host extends for schemas, etc. */
405
+ export interface ToolBase {
406
+ id: string;
407
+ name: string;
408
+ }
409
+
410
+ /**
411
+ * Auth type id. Reserved literals: `"None"`, `"OAuth"`, `"API Key"`.
412
+ * Stays `string` so hosts can widen (same pattern as `ProviderType`).
413
+ */
414
+ export type ConnectorAuthType = string;
415
+
416
+ /**
417
+ * Write-time connector auth. Host extends / narrows via `TType`.
418
+ * For `"API Key"`, pass `apiKey` (and optional `headerName`).
419
+ */
420
+ export interface ConnectorAuth<TType extends ConnectorAuthType = ConnectorAuthType> {
421
+ type: TType;
422
+ apiKey?: string;
423
+ headerName?: string;
424
+ }
425
+
426
+ /**
427
+ * Catalog / list auth — no secrets. Host extends / narrows via `TType`.
428
+ */
429
+ export interface ConnectorAuthPublic<
430
+ TType extends ConnectorAuthType = ConnectorAuthType,
431
+ > {
432
+ type: TType;
433
+ headerName?: string;
434
+ }
435
+
436
+ /**
437
+ * MCP / connector create-edit config. Host extends for extra fields, etc.
438
+ */
439
+ export interface ConnectorConfigBase<
440
+ TAuth extends ConnectorAuth = ConnectorAuth,
441
+ > {
442
+ name: string;
443
+ url: string;
444
+ auth: TAuth;
445
+ }
446
+
447
+ /**
448
+ * Connected connector row (settings/connectors). No raw `apiKey`.
449
+ * Host extends.
450
+ */
451
+ export interface ConnectorBase<
452
+ TTool extends ToolBase = ToolBase,
453
+ TAuth extends ConnectorAuthPublic = ConnectorAuthPublic,
454
+ > {
455
+ id: string;
456
+ name: string;
457
+ description: string;
458
+ url: string;
459
+ auth: TAuth;
460
+ authenticated: boolean;
461
+ tools: TTool[];
462
+ }
463
+
464
+ /** Discovery catalog entry for "+ Add MCP server". Host extends. */
465
+ export interface ConnectorCatalogEntry<
466
+ TAuth extends ConnectorAuthPublic = ConnectorAuthPublic,
467
+ > {
468
+ id: string;
469
+ name: string;
470
+ description?: string;
471
+ url: string;
472
+ auth: TAuth;
473
+ }
474
+
475
+ /** Create connector — no `id`; server assigns it. Host extends. */
476
+ export type CreateConnectorRequest<TAuth extends ConnectorAuth = ConnectorAuth> =
477
+ ConnectorConfigBase<TAuth>;
478
+
479
+ /** Update connector — `id` required. Host extends. */
480
+ export type UpdateConnectorRequest<TAuth extends ConnectorAuth = ConnectorAuth> =
481
+ ConnectorConfigBase<TAuth> & { id: string };
482
+
483
+ export interface ConnectorCatalogServer<
484
+ TTool extends ToolBase = ToolBase,
485
+ TAuthWrite extends ConnectorAuth = ConnectorAuth,
486
+ TAuthPublic extends ConnectorAuthPublic = ConnectorAuthPublic,
487
+ TConnector extends ConnectorBase<TTool, TAuthPublic> = ConnectorBase<
488
+ TTool,
489
+ TAuthPublic
490
+ >,
491
+ TCatalogEntry extends ConnectorCatalogEntry<TAuthPublic> =
492
+ ConnectorCatalogEntry<TAuthPublic>,
493
+ TCreate extends CreateConnectorRequest<TAuthWrite> =
494
+ CreateConnectorRequest<TAuthWrite>,
495
+ TUpdate extends UpdateConnectorRequest<TAuthWrite> =
496
+ UpdateConnectorRequest<TAuthWrite>,
497
+ > {
498
+ getConnectorCatalog(): Promise<TCatalogEntry[]>;
499
+ listConnectors(req?: { query?: string }): Promise<TConnector[]>;
500
+ createConnector(req: TCreate): Promise<TConnector>;
501
+ /** Full replace update keyed by connector `id`. */
502
+ updateConnector(req: TUpdate): Promise<TConnector>;
503
+ /** Start connector auth (e.g. OAuth). Host may widen return with `authUrl`. */
504
+ authenticateConnector(req: { id: string }): Promise<TConnector>;
505
+ /** Clear connector auth. */
506
+ disconnectConnector(req: { id: string }): Promise<TConnector>;
507
+ deleteConnector?(req: { id: string }): Promise<void>;
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // Skills catalog — FE-minimal settings DTOs (host extends via generics)
512
+ // ---------------------------------------------------------------------------
513
+
514
+ /** Skill row shown in settings/skills (list + delete). Host extends for fqn, etc. */
515
+ export interface SkillBase {
516
+ id: string;
517
+ name: string;
518
+ description: string;
519
+ }
520
+
521
+ /** Create-skill request. Host extends for branch, auth, etc. */
522
+ export interface CreateSkillRequest {
523
+ repo: string;
524
+ directory: string;
525
+ }
526
+
527
+ export interface SkillCatalogServer<
528
+ TSkill extends SkillBase = SkillBase,
529
+ TCreate extends CreateSkillRequest = CreateSkillRequest,
530
+ > {
531
+ listSkills(req?: { query?: string }): Promise<TSkill[]>;
532
+ createSkill(req: TCreate): Promise<TSkill>;
533
+ deleteSkill?(req: { id: string }): Promise<void>;
534
+ }
535
+
536
+ /**
537
+ * Settings management aggregate — modelCatalog + connectorCatalog + optional skillCatalog.
538
+ * Hosts may pass the whole object to an app shell, or a focused sub-port to a page.
539
+ */
540
+ export interface CatalogServer<
541
+ TModelCatalog extends ModelCatalogServer = ModelCatalogServer,
542
+ TConnectorCatalog extends ConnectorCatalogServer = ConnectorCatalogServer,
543
+ TSkillCatalog extends SkillCatalogServer = SkillCatalogServer,
544
+ > {
545
+ modelCatalog: TModelCatalog;
546
+ connectorCatalog: TConnectorCatalog;
547
+ /** Optional — omit when the host has no skills settings surface. */
548
+ skillCatalog?: TSkillCatalog;
549
+ }
550
+
551
+ /**
552
+ * Composed host port: chat + builder + optional settings catalog.
553
+ * Agent-ui's `AgentUIServer` mirrors this shape; named differently here to
554
+ * avoid colliding with that package's local type name.
555
+ *
556
+ * `catalog` is optional — if the host passes it, settings UI can call
557
+ * `useCatalogServer()` / show modelCatalog, connectorCatalog, and skillCatalog;
558
+ * if omitted, those surfaces stay hidden.
559
+ */
560
+ export type AgentUIServerPort<
561
+ TChat extends AgentChatServer = AgentChatServer,
562
+ TBuilder extends AgentBuilderServer = AgentBuilderServer,
563
+ TCatalog extends CatalogServer = CatalogServer,
564
+ > = TChat & TBuilder & { catalog?: TCatalog };
@@ -1,7 +1,8 @@
1
1
  import { createRuntimeExtras } from "@assistant-ui/core/internal";
2
2
  import type { McpAuthRequiredEvent } from "./server/index.js";
3
3
 
4
- import type { AgentSpec, AgentSpecUpdate } from "./private/agentSpec.js";
4
+ import type { AgentSpec } from "./server/types.js";
5
+ import type { AgentSpecUpdate } from "./draft/agentSpec.js";
5
6
  import type { PendingApproval, PendingToolResponse } from "./collectPending.js";
6
7
  import type { RespondToToolApprovalOptions } from "./toolApproval.js";
7
8
  import type { RespondToToolResponseOptions } from "./toolResponse.js";
@@ -11,6 +12,7 @@ export type { PendingApproval, PendingToolResponse };
11
12
  export type TrueFoundryDraftRuntimeExtras = {
12
13
  agentSpec: AgentSpec | null;
13
14
  draftSessionId: string | undefined;
15
+ isSpecLoading: boolean;
14
16
  isSpecSyncing: boolean;
15
17
  specError: unknown | null;
16
18
  updateAgentSpec: (update: AgentSpecUpdate) => void;
@@ -41,6 +43,7 @@ export const trueFoundryExtras = createRuntimeExtras<TrueFoundryRuntimeExtras>(
41
43
  export const EMPTY_DRAFT_EXTRAS: TrueFoundryDraftRuntimeExtras = {
42
44
  agentSpec: null,
43
45
  draftSessionId: undefined,
46
+ isSpecLoading: false,
44
47
  isSpecSyncing: false,
45
48
  specError: null,
46
49
  updateAgentSpec: () => {
@@ -1,7 +1,7 @@
1
1
  import type { RemoteThreadListAdapter } from "@assistant-ui/core";
2
2
 
3
3
  import type { AgentChatServer, Session } from "./server/types.js";
4
- import { draftSessionTitle } from "./private/agentSpec.js";
4
+ import { draftSessionTitle } from "./draft/agentSpec.js";
5
5
  import { sessionListStartTimestamp } from "./sessionListStartTimestamp.js";
6
6
 
7
7
  const THREAD_LIST_PAGE_SIZE = 20;
package/src/types.ts CHANGED
@@ -7,8 +7,7 @@ import type {
7
7
  SpeechSynthesisAdapter,
8
8
  } from "@assistant-ui/core";
9
9
 
10
- import type { AgentSpec } from "./private/agentSpec.js";
11
- import type { AgentChatServer } from "./server/types.js";
10
+ import type { AgentChatServer, AgentSpec } from "./server/types.js";
12
11
 
13
12
  export type NamedAgentConfig = {
14
13
  mode: "named";
@@ -46,6 +46,7 @@ vi.mock("./convertTurnMessages.js", async (importOriginal) => {
46
46
 
47
47
  const mockServer = {
48
48
  cancelSession: vi.fn().mockResolvedValue(undefined),
49
+ listTurns: vi.fn(),
49
50
  } as unknown as AgentChatServer;
50
51
 
51
52
  function snapshotWithAssistantMessage(
@@ -279,6 +280,7 @@ describe("useTrueFoundryAgentMessages", () => {
279
280
  beforeEach(() => {
280
281
  vi.clearAllMocks();
281
282
  vi.mocked(mockServer.cancelSession).mockResolvedValue(undefined);
283
+ vi.mocked(mockServer.listTurns).mockResolvedValue({ data: [] });
282
284
  vi.mocked(loadSessionSnapshot).mockResolvedValue(createEmptySessionSnapshot());
283
285
  vi.mocked(streamTurnContent).mockReturnValue(singleUpdateStream());
284
286
  vi.mocked(resumeTurnStream).mockReturnValue(singleUpdateStream());
@@ -298,6 +300,79 @@ describe("useTrueFoundryAgentMessages", () => {
298
300
  expect(loadSessionSnapshot).not.toHaveBeenCalled();
299
301
  });
300
302
 
303
+ it("loads the initial URL session before it is marked as the main thread", async () => {
304
+ const { rerender } = renderHook(
305
+ ({ isMain }: { isMain: boolean }) =>
306
+ useTrueFoundryAgentMessages({
307
+ server: mockServer,
308
+ sessionId: "session-from-url",
309
+ isMain,
310
+ isInitialSession: true,
311
+ }),
312
+ { initialProps: { isMain: false } },
313
+ );
314
+
315
+ await waitFor(() =>
316
+ expect(loadSessionSnapshot).toHaveBeenCalledWith(
317
+ mockServer,
318
+ "session-from-url",
319
+ expect.any(Function),
320
+ ),
321
+ );
322
+
323
+ rerender({ isMain: true });
324
+ await act(async () => Promise.resolve());
325
+ expect(loadSessionSnapshot).toHaveBeenCalledTimes(1);
326
+
327
+ rerender({ isMain: false });
328
+ rerender({ isMain: true });
329
+ await waitFor(() => expect(loadSessionSnapshot).toHaveBeenCalledTimes(2));
330
+ });
331
+
332
+ it("does not load an inactive background thread", async () => {
333
+ const { result } = renderHook(() =>
334
+ useTrueFoundryAgentMessages({
335
+ server: mockServer,
336
+ sessionId: "background-session",
337
+ isMain: false,
338
+ isInitialSession: false,
339
+ }),
340
+ );
341
+
342
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
343
+ expect(loadSessionSnapshot).not.toHaveBeenCalled();
344
+ });
345
+
346
+ it("retries a failed URL early load before the thread is promoted to main", async () => {
347
+ vi.mocked(loadSessionSnapshot)
348
+ .mockRejectedValueOnce(new Error("load failed"))
349
+ .mockResolvedValueOnce(snapshotWithUserTurn("Hello"));
350
+
351
+ const { result } = renderHook(() =>
352
+ useTrueFoundryAgentMessages({
353
+ server: mockServer,
354
+ sessionId: "session-from-url",
355
+ isMain: false,
356
+ isInitialSession: true,
357
+ }),
358
+ );
359
+
360
+ await waitFor(() => expect(loadSessionSnapshot).toHaveBeenCalledTimes(1));
361
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
362
+
363
+ await act(async () => {
364
+ result.current.retryLoad();
365
+ });
366
+
367
+ await waitFor(() => expect(loadSessionSnapshot).toHaveBeenCalledTimes(2));
368
+ await waitFor(() =>
369
+ expect(result.current.messages[0]).toMatchObject({
370
+ role: "user",
371
+ content: [{ type: "text", text: "Hello" }],
372
+ }),
373
+ );
374
+ });
375
+
301
376
  it("sendTurn lazily initializes a session when sessionId is undefined", async () => {
302
377
  const initializeSession = vi.fn().mockResolvedValue({
303
378
  remoteId: "session-new",
@@ -358,6 +433,7 @@ describe("useTrueFoundryAgentMessages", () => {
358
433
  },
359
434
  expect.any(AbortSignal),
360
435
  expect.any(Array),
436
+ expect.any(Function),
361
437
  );
362
438
 
363
439
  await act(async () => {
@@ -372,6 +448,7 @@ describe("useTrueFoundryAgentMessages", () => {
372
448
  { userMessage: "second" },
373
449
  expect.any(AbortSignal),
374
450
  expect.any(Array),
451
+ expect.any(Function),
375
452
  );
376
453
  });
377
454
 
@@ -385,7 +462,11 @@ describe("useTrueFoundryAgentMessages", () => {
385
462
  );
386
463
 
387
464
  await waitFor(() => expect(result.current.isLoading).toBe(false));
388
- expect(loadSessionSnapshot).toHaveBeenCalledWith(mockServer, "session-1");
465
+ expect(loadSessionSnapshot).toHaveBeenCalledWith(
466
+ mockServer,
467
+ "session-1",
468
+ expect.any(Function),
469
+ );
389
470
  expect(result.current.messages).toHaveLength(1);
390
471
  expect(result.current.messages[0]?.role).toBe("user");
391
472
  });
@@ -493,6 +574,21 @@ describe("useTrueFoundryAgentMessages", () => {
493
574
 
494
575
  it("editFromTurn drops prior turns before showing the edited user message", async () => {
495
576
  const createdAt = new Date().toISOString();
577
+ vi.mocked(mockServer.listTurns).mockResolvedValue({
578
+ data: [
579
+ {
580
+ id: "turn-1",
581
+ sessionId: "session-1",
582
+ createdAt,
583
+ state: {
584
+ status: "done",
585
+ requiredActions: [],
586
+ completedAt: createdAt,
587
+ },
588
+ input: [{ type: "user.message", content: "Hello" }],
589
+ } as Turn,
590
+ ],
591
+ });
496
592
  const fold = new PeerThreadFoldState();
497
593
  ingestTurnEvent(fold, {
498
594
  type: "model.message",
@@ -589,6 +685,54 @@ describe("useTrueFoundryAgentMessages", () => {
589
685
  });
590
686
  });
591
687
 
688
+ it("restores history when edit fails before turn.created", async () => {
689
+ const createdAt = new Date().toISOString();
690
+ const onError = vi.fn();
691
+ const original = snapshotWithUserTurn("Hello");
692
+ vi.mocked(loadSessionSnapshot).mockResolvedValue(original);
693
+ vi.mocked(mockServer.listTurns).mockResolvedValue({
694
+ data: [
695
+ {
696
+ id: "turn-1",
697
+ sessionId: "session-1",
698
+ createdAt,
699
+ state: {
700
+ status: "done",
701
+ requiredActions: [],
702
+ completedAt: createdAt,
703
+ },
704
+ input: [{ type: "user.message", content: "Hello" }],
705
+ } as Turn,
706
+ ],
707
+ });
708
+ vi.mocked(streamTurnContent).mockImplementation(async function* () {
709
+ throw new Error("Turn preparation failed");
710
+ });
711
+
712
+ const { result } = renderHook(() =>
713
+ useTrueFoundryAgentMessages({
714
+ server: mockServer,
715
+ sessionId: "session-1",
716
+ onError,
717
+ }),
718
+ );
719
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
720
+
721
+ await act(async () => {
722
+ await expect(
723
+ result.current.editFromTurn("turn-1", "Edited"),
724
+ ).rejects.toThrow("Turn preparation failed");
725
+ });
726
+
727
+ expect(result.current.messages).toHaveLength(1);
728
+ expect(result.current.messages[0]).toMatchObject({
729
+ role: "user",
730
+ content: [{ type: "text", text: "Hello" }],
731
+ });
732
+ // runStream reports once; callers must not double-toast.
733
+ expect(onError).toHaveBeenCalledOnce();
734
+ });
735
+
592
736
  it("does not let a superseded stream complete the current stream", async () => {
593
737
  let releaseFirstStream: (() => void) | undefined;
594
738
  let releaseSecondStream: (() => void) | undefined;
@@ -962,4 +1106,120 @@ describe("useTrueFoundryAgentMessages", () => {
962
1106
  // on mount and reconciles against the event log on the next page load.
963
1107
  expect(loadSessionSnapshot).toHaveBeenCalledTimes(1);
964
1108
  });
1109
+
1110
+ describe("pre-turn failure rollback", () => {
1111
+ it("reports and restores a user message when initializeSession fails", async () => {
1112
+ const onError = vi.fn();
1113
+ const onPreTurnFailure = vi.fn();
1114
+ const initializeSession = vi
1115
+ .fn()
1116
+ .mockRejectedValue(new Error("Draft session creation failed"));
1117
+
1118
+ const { result } = renderHook(() =>
1119
+ useTrueFoundryAgentMessages({
1120
+ server: mockServer,
1121
+ sessionId: undefined,
1122
+ initializeSession,
1123
+ onError,
1124
+ }),
1125
+ );
1126
+
1127
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
1128
+ expect(result.current.messages).toEqual([]);
1129
+
1130
+ await act(async () => {
1131
+ await expect(
1132
+ result.current.sendTurn({
1133
+ userMessage: "test message",
1134
+ onPreTurnFailure,
1135
+ }),
1136
+ ).rejects.toThrow("Draft session creation failed");
1137
+ });
1138
+
1139
+ expect(result.current.messages).toEqual([]);
1140
+ expect(onPreTurnFailure).toHaveBeenCalledOnce();
1141
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
1142
+ expect(initializeSession).toHaveBeenCalledOnce();
1143
+ expect(streamTurnContent).not.toHaveBeenCalled();
1144
+ });
1145
+
1146
+ it("rolls back when the turns stream fails before turn.created", async () => {
1147
+ const onError = vi.fn();
1148
+ const onPreTurnFailure = vi.fn();
1149
+ vi.mocked(streamTurnContent).mockImplementation(async function* () {
1150
+ throw new Error("Turn preparation failed");
1151
+ });
1152
+
1153
+ const { result } = renderHook(() =>
1154
+ useTrueFoundryAgentMessages({
1155
+ server: mockServer,
1156
+ sessionId: "session-1",
1157
+ onError,
1158
+ }),
1159
+ );
1160
+
1161
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
1162
+
1163
+ await act(async () => {
1164
+ await expect(
1165
+ result.current.sendTurn({
1166
+ userMessage: "test message",
1167
+ onPreTurnFailure,
1168
+ }),
1169
+ ).rejects.toThrow("Turn preparation failed");
1170
+ });
1171
+
1172
+ expect(result.current.messages).toEqual([]);
1173
+ expect(onPreTurnFailure).toHaveBeenCalledOnce();
1174
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
1175
+ });
1176
+
1177
+ it("does not roll back after turn.created registers the user message", async () => {
1178
+ const onError = vi.fn();
1179
+ const onPreTurnFailure = vi.fn();
1180
+ vi.mocked(streamTurnContent).mockImplementation(
1181
+ async function* (
1182
+ _server,
1183
+ _sessionId,
1184
+ _fold,
1185
+ _options,
1186
+ _signal,
1187
+ _baseline,
1188
+ onTurnIdAvailable,
1189
+ ) {
1190
+ onTurnIdAvailable?.("gateway-turn-123");
1191
+ yield { content: [{ type: "text" as const, text: "partial" }] };
1192
+ throw new Error("Mid-stream error");
1193
+ },
1194
+ );
1195
+
1196
+ const { result } = renderHook(() =>
1197
+ useTrueFoundryAgentMessages({
1198
+ server: mockServer,
1199
+ sessionId: "session-1",
1200
+ onError,
1201
+ }),
1202
+ );
1203
+
1204
+ await waitFor(() => expect(result.current.isLoading).toBe(false));
1205
+
1206
+ await act(async () => {
1207
+ await expect(
1208
+ result.current.sendTurn({
1209
+ userMessage: "test message",
1210
+ onPreTurnFailure,
1211
+ }),
1212
+ ).rejects.toThrow("Mid-stream error");
1213
+ });
1214
+
1215
+ const userMessages = result.current.messages.filter((m) => m.role === "user");
1216
+ expect(userMessages).toHaveLength(1);
1217
+ expect(userMessages[0]?.content[0]).toMatchObject({
1218
+ type: "text",
1219
+ text: "test message",
1220
+ });
1221
+ expect(onPreTurnFailure).not.toHaveBeenCalled();
1222
+ expect(onError).toHaveBeenCalledWith(expect.any(Error));
1223
+ });
1224
+ });
965
1225
  });