@truefoundry/assistant-ui-runtime 0.1.6 → 0.1.8

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 (46) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +22 -17
  3. package/dist/chunk-CXBZ6WLZ.js +636 -0
  4. package/dist/chunk-CXBZ6WLZ.js.map +1 -0
  5. package/dist/index.d.ts +20 -24
  6. package/dist/index.js +269 -166
  7. package/dist/index.js.map +1 -1
  8. package/dist/plugins/truefoundry-agent-server-adapter/index.d.ts +82 -39
  9. package/dist/plugins/truefoundry-agent-server-adapter/index.js +3 -1
  10. package/dist/server/index.d.ts +2 -2
  11. package/dist/{types-BfiFf8O1.d.ts → types-B_z-FsDS.d.ts} +208 -10
  12. package/package.json +1 -1
  13. package/src/convertTurnMessages.ts +4 -0
  14. package/src/{private → draft}/agentSpec.ts +14 -17
  15. package/src/{private → draft}/draftSessionBridge.ts +1 -2
  16. package/src/{private → draft}/truefoundryDraftThreadListAdapter.test.ts +1 -1
  17. package/src/{private → draft}/truefoundryDraftThreadListAdapter.ts +6 -2
  18. package/src/{private → draft}/useDraftAgentSpec.ts +16 -5
  19. package/src/draftAgentConfig.test.ts +2 -1
  20. package/src/harness.temp.ts +85 -0
  21. package/src/index.ts +43 -7
  22. package/src/plugins/truefoundry-agent-server-adapter/README.md +83 -44
  23. package/src/plugins/truefoundry-agent-server-adapter/chatServer.ts +365 -0
  24. package/src/plugins/truefoundry-agent-server-adapter/cp.test.ts +444 -0
  25. package/src/plugins/truefoundry-agent-server-adapter/cp.ts +482 -0
  26. package/src/plugins/truefoundry-agent-server-adapter/createTrueFoundryAgentUIServer.ts +94 -0
  27. package/src/plugins/truefoundry-agent-server-adapter/guards.ts +1 -1
  28. package/src/plugins/truefoundry-agent-server-adapter/index.ts +20 -351
  29. package/src/plugins/truefoundry-agent-server-adapter/normalizeAgentSpec.test.ts +85 -0
  30. package/src/plugins/truefoundry-agent-server-adapter/normalizeAgentSpec.ts +84 -0
  31. package/src/plugins/truefoundry-agent-server-adapter/types.ts +7 -5
  32. package/src/server/index.ts +29 -0
  33. package/src/server/types.ts +264 -12
  34. package/src/streamTurn.test.ts +27 -27
  35. package/src/streamTurn.ts +2 -2
  36. package/src/truefoundryExtras.ts +4 -1
  37. package/src/truefoundryOwnedSessionsThreadListAdapter.ts +5 -2
  38. package/src/truefoundryThreadListAdapter.test.ts +22 -0
  39. package/src/truefoundryThreadListAdapter.ts +4 -1
  40. package/src/types.ts +1 -2
  41. package/src/useTrueFoundryAgentMessages.test.tsx +262 -2
  42. package/src/useTrueFoundryAgentMessages.ts +284 -176
  43. package/src/useTrueFoundryAgentRuntime.ts +31 -21
  44. package/dist/chunk-Q2SHKMLM.js +0 -270
  45. package/dist/chunk-Q2SHKMLM.js.map +0 -1
  46. /package/src/{private → draft}/useDraftAgentSpec.test.tsx +0 -0
@@ -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;
@@ -850,7 +994,7 @@ describe("useTrueFoundryAgentMessages", () => {
850
994
  });
851
995
 
852
996
  describe("batched resume invariant", () => {
853
- it("issues exactly one prepareAndExecuteTurn input batch across root and sub-agent threads", async () => {
997
+ it("issues exactly one createTurn input batch across root and sub-agent threads", async () => {
854
998
  vi.mocked(loadSessionSnapshot).mockResolvedValue(
855
999
  snapshotWithAssistantMessage(assistantMessageWithMultiThreadPendingActions()),
856
1000
  );
@@ -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
  });