@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
@@ -10,12 +10,15 @@ import type {
10
10
  AgentSession,
11
11
  McpAuthRequiredEvent,
12
12
  Turn,
13
+ TurnCreatedEvent,
14
+ TurnDoneEvent,
13
15
  TurnEvent,
14
16
  TurnInputItem,
15
17
  TurnStreamData,
16
18
  } from "truefoundry-gateway-sdk/agents";
17
19
 
18
20
  import { ROOT_THREAD_ID } from "./constants.js";
21
+ import { extractTurnUserText } from "./extractTurnUserText.js";
19
22
  import {
20
23
  buildRootAssistantContent,
21
24
  buildRootAssistantContentForIds,
@@ -42,6 +45,7 @@ import {
42
45
  createEmptySessionSnapshot,
43
46
  emptyRequiredActionsOverlay,
44
47
  replaceSessionSnapshot,
48
+ sessionEventsToSessionRecord,
45
49
  turnToSessionRecord,
46
50
  } from "./sessionSnapshot.js";
47
51
  import {
@@ -186,12 +190,23 @@ import {
186
190
  * `user.message`.
187
191
  * - Tool-call identity is stable via `toolCallId` across events, fold state, and
188
192
  * assistant-ui parts.
189
- * - Reload (`buildSnapshotFromSession`) and live paths must produce the same
193
+ * - Reload (`buildSnapshotFromSessionEvents`) and live paths must produce the same
190
194
  * per-group scoping; history uses cumulative `groupRootIds` per turn index,
191
195
  * live uses `groupRootBaseline`.
192
196
  */
193
197
 
194
198
  const TURN_EVENTS_PAGE_SIZE = 25;
199
+ const SESSION_EVENTS_PAGE_SIZE = 100;
200
+
201
+ /**
202
+ * Session-level event item — structurally equivalent to
203
+ * `TrueFoundryGateway.SessionEventItem` from
204
+ * `AgentSession.listEvents` (not re-exported from agents subpath).
205
+ */
206
+ type GatewaySessionEventItem = {
207
+ turnId: string;
208
+ event: TurnCreatedEvent | TurnDoneEvent | TurnEvent;
209
+ };
195
210
 
196
211
  export type ConvertTurnsResult = {
197
212
  messages: ThreadMessage[];
@@ -731,6 +746,163 @@ export function projectSessionMessages(
731
746
 
732
747
  const DEFAULT_LIST_EVENTS_CONCURRENCY = 5;
733
748
 
749
+ type FetchSessionEventsOptions = {
750
+ /**
751
+ * Newest turn in the listing window (initial load only). Lists that turn and
752
+ * its ancestors. Omit to use the session last turn. Running-turn events are
753
+ * excluded by the API — subscribe to that turn for live events.
754
+ */
755
+ lastTurnId?: string;
756
+ };
757
+
758
+ /**
759
+ * Fetches session-level events via `session.listEvents()`. The API returns pages
760
+ * in desc order (newest first); the collected array is reversed before returning
761
+ * so callers receive events in chronological (asc) order.
762
+ */
763
+ async function fetchAllSessionEvents(
764
+ session: AgentSession,
765
+ options?: FetchSessionEventsOptions,
766
+ ): Promise<GatewaySessionEventItem[]> {
767
+ const items: GatewaySessionEventItem[] = [];
768
+ for await (const item of await session.listEvents({
769
+ limit: SESSION_EVENTS_PAGE_SIZE,
770
+ ...(options?.lastTurnId != null ? { lastTurnId: options.lastTurnId } : {}),
771
+ })) {
772
+ items.push(item as GatewaySessionEventItem);
773
+ }
774
+ items.reverse();
775
+ return items;
776
+ }
777
+
778
+ /**
779
+ * Ingests a chronologically ordered list of session-level event items into the
780
+ * snapshot. `turn.created` marks the start of a turn (provides user input),
781
+ * content events are folded as usual, and `turn.done` finalises the turn record
782
+ * and pushes it to `snapshot.turns`.
783
+ *
784
+ * `onTurnComplete` is called after each `turn.done` with the partially-built
785
+ * snapshot so callers can update UI progressively. The fold state is correct at
786
+ * that point for every turn up to and including the just-completed one.
787
+ */
788
+ function ingestSessionEventsIntoSnapshot(
789
+ snapshot: SessionSnapshot,
790
+ items: GatewaySessionEventItem[],
791
+ onTurnComplete?: (snap: SessionSnapshot) => void,
792
+ ): void {
793
+ let currentTurnId: string | null = null;
794
+ let currentCreatedEvent: TurnCreatedEvent | null = null;
795
+ let currentContentEvents: TurnEvent[] = [];
796
+ let beforeCount = 0;
797
+
798
+ for (const item of items) {
799
+ const { turnId, event } = item;
800
+
801
+ if (event.type === "turn.created") {
802
+ currentTurnId = turnId;
803
+ currentCreatedEvent = event;
804
+ currentContentEvents = [];
805
+ beforeCount =
806
+ snapshot.fold.threads.get(ROOT_THREAD_ID)?.modelMessageIds.length ?? 0;
807
+ } else if (event.type === "turn.done") {
808
+ if (currentTurnId == null || currentCreatedEvent == null) {
809
+ continue;
810
+ }
811
+
812
+ const afterBucket = snapshot.fold.threads.get(ROOT_THREAD_ID);
813
+ const rootModelMessageIds = (afterBucket?.modelMessageIds ?? []).slice(
814
+ beforeCount,
815
+ );
816
+
817
+ const sandboxEvent = currentContentEvents.find(
818
+ (ev): ev is Extract<TurnEvent, { type: "sandbox.created" }> =>
819
+ ev.type === "sandbox.created",
820
+ );
821
+
822
+ applyUserToolResponsesToFold(
823
+ snapshot.fold,
824
+ currentCreatedEvent.input ?? [],
825
+ );
826
+ snapshot.turns.push(
827
+ sessionEventsToSessionRecord(
828
+ currentTurnId,
829
+ currentCreatedEvent,
830
+ event,
831
+ rootModelMessageIds,
832
+ sandboxEvent?.sandboxId,
833
+ ),
834
+ );
835
+
836
+ // Pass a new object reference so React's Object.is check in
837
+ // useState sees a changed value and schedules a re-render.
838
+ // The snapshot is mutated in place throughout this loop, so
839
+ // passing `snapshot` directly would make every tick look identical
840
+ // to React after the first setSnapshot call.
841
+ onTurnComplete?.(replaceSessionSnapshot(snapshot, {}));
842
+
843
+ currentTurnId = null;
844
+ currentCreatedEvent = null;
845
+ currentContentEvents = [];
846
+ } else {
847
+ if (currentTurnId != null) {
848
+ ingestTurnEvent(snapshot.fold, event as TurnEvent);
849
+ currentContentEvents.push(event as TurnEvent);
850
+ }
851
+ }
852
+ }
853
+ }
854
+
855
+ /**
856
+ * Builds a session snapshot using the session-level `listEvents` API.
857
+ *
858
+ * Compared to `buildSnapshotFromSession`, this makes a single paginated request
859
+ * for all history events instead of one per turn. Only `listTurns({ limit: 1 })`
860
+ * is called first to detect a currently-running turn (the session-level API does
861
+ * not return events for the running turn).
862
+ *
863
+ * `onProgress` is called after each complete turn is ingested so callers can
864
+ * update the UI progressively while the processing loop runs.
865
+ */
866
+ export async function buildSnapshotFromSessionEvents(
867
+ session: AgentSession,
868
+ onProgress?: (snap: SessionSnapshot) => void,
869
+ ): Promise<SessionSnapshot> {
870
+ // Detect a running turn with a single minimal listTurns call.
871
+ let runningTurn: Turn | undefined;
872
+ for await (const turn of await session.listTurns({ limit: 1 })) {
873
+ if ((turn as Turn).state?.status === "running") {
874
+ runningTurn = turn as Turn;
875
+ }
876
+ }
877
+
878
+ const items = await fetchAllSessionEvents(session);
879
+
880
+ const snapshot = createEmptySessionSnapshot();
881
+ ingestSessionEventsIntoSnapshot(snapshot, items, onProgress);
882
+
883
+ if (runningTurn == null) {
884
+ return snapshot;
885
+ }
886
+
887
+ // Session listEvents excludes the running turn. Seed its user message so
888
+ // reconnect UI can show the in-flight bubble before subscribe yields.
889
+ const pendingUserText = extractTurnUserText(runningTurn.input);
890
+ return replaceSessionSnapshot(snapshot, {
891
+ runningTurn,
892
+ unstable_resume: true as const,
893
+ groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
894
+ ...(pendingUserText
895
+ ? {
896
+ pendingUser: {
897
+ turnId: runningTurn.id,
898
+ content: extractTurnUserMessageContent(runningTurn.input),
899
+ createdAt: new Date(runningTurn.createdAt),
900
+ },
901
+ }
902
+ : {}),
903
+ });
904
+ }
905
+
734
906
  function ingestTurnsIntoSnapshot(
735
907
  snapshot: SessionSnapshot,
736
908
  turns: Turn[],
@@ -775,25 +947,22 @@ export async function buildSnapshotFromSession(
775
947
  session: AgentSession,
776
948
  concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
777
949
  ): Promise<SessionSnapshot> {
778
- const turns: Turn[] = [];
779
- for await (const turn of await session.listTurns()) {
780
- turns.push(turn);
950
+ // Completed history comes from session-level listEvents. The session API
951
+ // excludes the running turn hydrate that turn via turn.listEvents so
952
+ // convertTurnsToThreadMessages still surfaces in-flight content.
953
+ const snapshot = await buildSnapshotFromSessionEvents(session);
954
+ if (snapshot.runningTurn == null) {
955
+ return snapshot;
781
956
  }
782
- turns.reverse();
783
957
 
784
- const eventArrays = await fetchAllTurnEventsWithConcurrency(turns, concurrency);
785
-
786
- const snapshot = createEmptySessionSnapshot();
787
- const runningTurn = ingestTurnsIntoSnapshot(snapshot, turns, eventArrays);
958
+ const turn = snapshot.runningTurn;
959
+ const eventArrays = await fetchAllTurnEventsWithConcurrency([turn], concurrency);
960
+ ingestTurnsIntoSnapshot(snapshot, [turn], eventArrays);
788
961
 
789
962
  return replaceSessionSnapshot(snapshot, {
790
- ...(runningTurn != null
791
- ? {
792
- runningTurn,
793
- unstable_resume: true as const,
794
- groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
795
- }
796
- : {}),
963
+ runningTurn: turn,
964
+ unstable_resume: true as const,
965
+ groupRootBaseline: computeGroupRootBaseline(snapshot.turns),
797
966
  });
798
967
  }
799
968
 
@@ -803,11 +972,7 @@ export async function buildSnapshotBeforeTurn(
803
972
  beforeTurnId: string,
804
973
  concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
805
974
  ): Promise<SessionSnapshot> {
806
- const turns: Turn[] = [];
807
- for await (const turn of await session.listTurns()) {
808
- turns.push(turn);
809
- }
810
- turns.reverse();
975
+ const turns = await listSessionTurnsOrdered(session);
811
976
 
812
977
  const beforeIndex = turns.findIndex((turn) => turn.id === beforeTurnId);
813
978
  if (beforeIndex === -1) {
@@ -821,7 +986,7 @@ export async function buildSnapshotBeforeTurn(
821
986
  export async function buildSnapshotBeforeTurnIndex(
822
987
  session: AgentSession,
823
988
  turnIndex: number,
824
- concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
989
+ _concurrency: number = DEFAULT_LIST_EVENTS_CONCURRENCY,
825
990
  orderedTurns?: Turn[],
826
991
  ): Promise<SessionSnapshot> {
827
992
  if (turnIndex <= 0) {
@@ -830,16 +995,16 @@ export async function buildSnapshotBeforeTurnIndex(
830
995
 
831
996
  const turns = orderedTurns ?? (await listSessionTurnsOrdered(session));
832
997
  const turnsToInclude = turns.slice(0, turnIndex);
833
- if (turnsToInclude.length === 0) {
998
+ const lastTurnId = turnsToInclude.at(-1)?.id;
999
+ if (lastTurnId == null) {
834
1000
  return createEmptySessionSnapshot();
835
1001
  }
836
1002
 
837
- const eventArrays = await fetchAllTurnEventsWithConcurrency(
838
- turnsToInclude,
839
- concurrency,
840
- );
1003
+ // Anchor the session events window at the newest included turn so the
1004
+ // ancestor chain matches `[turns[0], …, turns[turnIndex - 1]]`.
1005
+ const items = await fetchAllSessionEvents(session, { lastTurnId });
841
1006
  const snapshot = createEmptySessionSnapshot();
842
- ingestTurnsIntoSnapshot(snapshot, turnsToInclude, eventArrays);
1007
+ ingestSessionEventsIntoSnapshot(snapshot, items);
843
1008
  return snapshot;
844
1009
  }
845
1010
 
@@ -1,8 +1,16 @@
1
1
  import type { ToolCall } from "truefoundry-gateway-sdk/agents";
2
2
 
3
- export function isCreateSubAgentToolCall(toolCall: Pick<ToolCall, "toolInfo">): boolean {
4
- return (
5
- toolCall.toolInfo.type === "truefoundry-system" &&
6
- toolCall.toolInfo.name === "create_sub_agent"
7
- );
3
+ export function isCreateSubAgentToolCall(
4
+ toolCall: Pick<ToolCall, "toolInfo" | "function">,
5
+ ): boolean {
6
+ // Gateway may attach truefoundry-system toolInfo on persisted turns, but streamed
7
+ // model.message tool calls often only carry function.name. Without the fallback,
8
+ // foldPeerThreads never nests child threads under the spawning tool call.
9
+ if (
10
+ toolCall.toolInfo?.type === "truefoundry-system" &&
11
+ toolCall.toolInfo?.name === "create_sub_agent"
12
+ ) {
13
+ return true;
14
+ }
15
+ return toolCall.function?.name === "create_sub_agent";
8
16
  }
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest";
2
2
 
3
- import { mergeAgentSpec } from "./agentSpec.js";
3
+ import { mergeAgentSpec } from "./private/agentSpec.js";
4
4
  import { resolveTrueFoundryAgentConfig } from "./types.js";
5
5
 
6
6
  describe("resolveTrueFoundryAgentConfig", () => {
@@ -129,6 +129,62 @@ describe("foldPeerThreads", () => {
129
129
  });
130
130
  });
131
131
 
132
+ it("attaches sub-agent artifact from thread.created before child model messages arrive", () => {
133
+ const state = new PeerThreadFoldState();
134
+
135
+ ingestTurnEvent(
136
+ state,
137
+ modelMessage({
138
+ id: "root-msg",
139
+ threadId: ROOT_THREAD_ID,
140
+ toolCalls: [
141
+ {
142
+ id: "spawn-1",
143
+ type: "function",
144
+ function: { name: "create_sub_agent", arguments: "{}" },
145
+ },
146
+ ],
147
+ }),
148
+ );
149
+
150
+ ingestTurnEvent(
151
+ state,
152
+ threadCreated({
153
+ id: "t-created",
154
+ threadId: "child-1",
155
+ title: "gateway-usage-7d",
156
+ agentInfo: {
157
+ type: "dynamic",
158
+ name: "gateway-usage-7d",
159
+ input: "run queries",
160
+ },
161
+ parent: { threadId: ROOT_THREAD_ID, toolCallId: "spawn-1" },
162
+ }),
163
+ );
164
+
165
+ const parts = buildRootAssistantContent(state);
166
+ const spawn = parts.find((part) => part.type === "tool-call");
167
+ expect(spawn?.type).toBe("tool-call");
168
+ if (spawn?.type !== "tool-call") {
169
+ return;
170
+ }
171
+
172
+ expect(spawn.messages).toBeUndefined();
173
+ expect(spawn.artifact).toEqual({
174
+ subAgents: [
175
+ {
176
+ threadId: "child-1",
177
+ title: "gateway-usage-7d",
178
+ agentInfo: {
179
+ type: "dynamic",
180
+ name: "gateway-usage-7d",
181
+ input: "run queries",
182
+ },
183
+ },
184
+ ],
185
+ });
186
+ });
187
+
132
188
  it("classifies stream events by thread id", () => {
133
189
  const state = new PeerThreadFoldState();
134
190
  expect(
@@ -389,11 +389,17 @@ function attachSubAgentMessages(
389
389
  });
390
390
  }
391
391
  }
392
- if (messages.length === 0) {
392
+ if (messages.length === 0 && subAgents.length === 0) {
393
393
  return part;
394
394
  }
395
395
 
396
396
  const artifact: SubAgentArtifact = { subAgents };
397
+ // thread.created (title, agentInfo) arrives before the child's first model.message.
398
+ // Attach artifact-only so UI can render the sub-agent header immediately; see README
399
+ // SubAgentArtifact / MessagePartPrimitive.Messages in agent-ui ToolCallContainer.
400
+ if (messages.length === 0) {
401
+ return { ...part, artifact };
402
+ }
397
403
  return { ...part, messages, artifact };
398
404
  });
399
405
  }
package/src/hooks.ts CHANGED
@@ -98,6 +98,12 @@ export const useTrueFoundryCancel = () => {
98
98
  return () => trueFoundryExtras.get(aui).cancel();
99
99
  };
100
100
 
101
+ /** Returns a function to reload (retry) the current session from any render context. */
102
+ export const useTrueFoundryReload = () => {
103
+ const aui = useAui();
104
+ return () => trueFoundryExtras.get(aui).reload();
105
+ };
106
+
101
107
  /** Returns a function to reset (re-submit) a user turn from any render context. */
102
108
  export const useTrueFoundryResetFromTurn = () => {
103
109
  const aui = useAui();
package/src/index.ts CHANGED
@@ -13,11 +13,11 @@ export {
13
13
  export type { ConvertTurnsResult, UserMessageContent } from "./convertTurnMessages.js";
14
14
  export { ROOT_THREAD_ID } from "./constants.js";
15
15
  export type { UseTrueFoundryAgentRuntimeOptions, NamedAgentConfig, DraftAgentConfig, TrueFoundryAgentConfig } from "./types.js";
16
- export type { AgentSpec, AgentSpecUpdate, DraftSession } from "./agentSpec.js";
17
- export { mergeAgentSpec, draftSessionTitle } from "./agentSpec.js";
18
- export { createTrueFoundryDraftThreadListAdapter } from "./truefoundryDraftThreadListAdapter.js";
19
- export { createDraftSessionBridge } from "./draftSessionBridge.js";
20
- export type { DraftSessionBridge } from "./draftSessionBridge.js";
16
+ export type { AgentSpec, AgentSpecUpdate, DraftSession } from "./private/agentSpec.js";
17
+ export { mergeAgentSpec, draftSessionTitle } from "./private/agentSpec.js";
18
+ export { createTrueFoundryDraftThreadListAdapter } from "./private/truefoundryDraftThreadListAdapter.js";
19
+ export { createDraftSessionBridge } from "./private/draftSessionBridge.js";
20
+ export type { DraftSessionBridge } from "./private/draftSessionBridge.js";
21
21
  export {
22
22
  useTrueFoundryAgentSpec,
23
23
  useTrueFoundryUpdateAgentSpec,
@@ -47,6 +47,7 @@ export {
47
47
  useTrueFoundryDownloadSandboxFile,
48
48
  useTrueFoundryCancel,
49
49
  useTrueFoundryResetFromTurn,
50
+ useTrueFoundryReload,
50
51
  } from "./hooks.js";
51
52
  export {
52
53
  collectApprovalInputs,
@@ -11,11 +11,11 @@ vi.mock("./convertTurnMessages.js", async (importOriginal) => {
11
11
  const actual = await importOriginal<typeof import("./convertTurnMessages.js")>();
12
12
  return {
13
13
  ...actual,
14
- buildSnapshotFromSession: vi.fn(),
14
+ buildSnapshotFromSessionEvents: vi.fn(),
15
15
  };
16
16
  });
17
17
 
18
- import { buildSnapshotFromSession } from "./convertTurnMessages.js";
18
+ import { buildSnapshotFromSessionEvents } from "./convertTurnMessages.js";
19
19
  import { loadSessionSnapshot } from "./loadSessionSnapshot.js";
20
20
  import { getSession } from "./sessions.js";
21
21
 
@@ -28,7 +28,7 @@ describe("loadSessionSnapshot", () => {
28
28
 
29
29
  it("deduplicates concurrent loads for the same session", async () => {
30
30
  vi.mocked(getSession).mockResolvedValue({} as never);
31
- vi.mocked(buildSnapshotFromSession).mockResolvedValue(
31
+ vi.mocked(buildSnapshotFromSessionEvents).mockResolvedValue(
32
32
  createEmptySessionSnapshot(),
33
33
  );
34
34
 
@@ -39,12 +39,12 @@ describe("loadSessionSnapshot", () => {
39
39
 
40
40
  expect(first).toBe(second);
41
41
  expect(getSession).toHaveBeenCalledTimes(1);
42
- expect(buildSnapshotFromSession).toHaveBeenCalledTimes(1);
42
+ expect(buildSnapshotFromSessionEvents).toHaveBeenCalledTimes(1);
43
43
  });
44
44
 
45
45
  it("allows a new load after the previous one settles", async () => {
46
46
  vi.mocked(getSession).mockResolvedValue({} as never);
47
- vi.mocked(buildSnapshotFromSession).mockResolvedValue(
47
+ vi.mocked(buildSnapshotFromSessionEvents).mockResolvedValue(
48
48
  createEmptySessionSnapshot(),
49
49
  );
50
50
 
@@ -52,6 +52,21 @@ describe("loadSessionSnapshot", () => {
52
52
  await loadSessionSnapshot(mockClient, "session-1");
53
53
 
54
54
  expect(getSession).toHaveBeenCalledTimes(2);
55
- expect(buildSnapshotFromSession).toHaveBeenCalledTimes(2);
55
+ expect(buildSnapshotFromSessionEvents).toHaveBeenCalledTimes(2);
56
+ });
57
+
58
+ it("forwards onProgress to buildSnapshotFromSessionEvents", async () => {
59
+ vi.mocked(getSession).mockResolvedValue({} as never);
60
+ vi.mocked(buildSnapshotFromSessionEvents).mockResolvedValue(
61
+ createEmptySessionSnapshot(),
62
+ );
63
+
64
+ const onProgress = vi.fn();
65
+ await loadSessionSnapshot(mockClient, "session-1", undefined, onProgress);
66
+
67
+ expect(buildSnapshotFromSessionEvents).toHaveBeenCalledWith(
68
+ {},
69
+ onProgress,
70
+ );
56
71
  });
57
72
  });
@@ -1,7 +1,6 @@
1
1
  import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
2
- import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
3
2
 
4
- import { buildSnapshotFromSession } from "./convertTurnMessages.js";
3
+ import { buildSnapshotFromSessionEvents } from "./convertTurnMessages.js";
5
4
  import { getSession, type GetSessionOptions } from "./sessions.js";
6
5
  import type { SessionSnapshot } from "./sessionSnapshot.js";
7
6
 
@@ -10,20 +9,25 @@ const inflightBySessionId = new Map<string, Promise<SessionSnapshot>>();
10
9
  /**
11
10
  * Loads a session snapshot once per concurrent burst for a given session id.
12
11
  * React Strict Mode and overlapping fetch/load paths share the same in-flight
13
- * request instead of duplicating getSession + listTurns calls.
12
+ * request instead of duplicating getSession + listEvents calls.
13
+ *
14
+ * `onProgress` is called after each complete turn is ingested so the caller
15
+ * can update the UI progressively while history is being processed.
14
16
  */
15
17
  export function loadSessionSnapshot(
16
18
  client: AgentSessionClient,
17
19
  sessionId: string,
18
- concurrency?: number,
19
20
  sessionOptions?: GetSessionOptions,
21
+ onProgress?: (snap: SessionSnapshot) => void,
20
22
  ): Promise<SessionSnapshot> {
21
23
  const cacheKey =
22
24
  sessionOptions?.draftGateway != null ? `draft:${sessionId}` : sessionId;
23
25
  let inflight = inflightBySessionId.get(cacheKey);
24
26
  if (inflight == null) {
25
27
  inflight = getSession(client, sessionId, sessionOptions)
26
- .then((session) => buildSnapshotFromSession(session, concurrency))
28
+ .then((session) =>
29
+ buildSnapshotFromSessionEvents(session, onProgress),
30
+ )
27
31
  .finally(() => {
28
32
  if (inflightBySessionId.get(cacheKey) === inflight) {
29
33
  inflightBySessionId.delete(cacheKey);
@@ -2,7 +2,7 @@ import type { RemoteThreadListAdapter } from "@assistant-ui/core";
2
2
  import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
3
3
 
4
4
  import { draftSessionTitle, type AgentSpec } from "./agentSpec.js";
5
- import { sessionListStartTimestamp } from "./sessionListStartTimestamp.js";
5
+ import { sessionListStartTimestamp } from "../sessionListStartTimestamp.js";
6
6
 
7
7
  const THREAD_LIST_PAGE_SIZE = 20;
8
8
 
@@ -1,4 +1,10 @@
1
- import type { McpAuthRequiredEvent, Turn, TurnInputItem } from "truefoundry-gateway-sdk/agents";
1
+ import type {
2
+ McpAuthRequiredEvent,
3
+ Turn,
4
+ TurnCreatedEvent,
5
+ TurnDoneEvent,
6
+ TurnInputItem,
7
+ } from "truefoundry-gateway-sdk/agents";
2
8
 
3
9
  import { extractTurnUserText } from "./extractTurnUserText.js";
4
10
  import { PeerThreadFoldState } from "./foldPeerThreads.js";
@@ -81,6 +87,26 @@ export function turnToSessionRecord(turn: Turn): SessionTurnRecord {
81
87
  };
82
88
  }
83
89
 
90
+ /** Builds a SessionTurnRecord from session-level TurnCreatedEvent + TurnDoneEvent data. */
91
+ export function sessionEventsToSessionRecord(
92
+ turnId: string,
93
+ createdEvent: TurnCreatedEvent,
94
+ doneEvent: TurnDoneEvent,
95
+ rootModelMessageIds: readonly string[],
96
+ sandboxId?: string,
97
+ ): SessionTurnRecord {
98
+ const userText = extractTurnUserText(createdEvent.input);
99
+ return {
100
+ id: turnId,
101
+ ...(userText ? { userText } : {}),
102
+ createdAt: createdEvent.createdAt,
103
+ state: doneEvent.state,
104
+ input: createdEvent.input,
105
+ rootModelMessageIds,
106
+ ...(sandboxId != null ? { sandboxId } : {}),
107
+ };
108
+ }
109
+
84
110
  /** Returns a new snapshot wrapper; fold maps may be mutated in place before calling. */
85
111
  export function replaceSessionSnapshot(
86
112
  snapshot: SessionSnapshot,
package/src/sessions.ts CHANGED
@@ -4,7 +4,7 @@ import type {
4
4
  } from "truefoundry-gateway-sdk/agents";
5
5
  import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
6
6
 
7
- import { bindDraftAgentSession } from "./bindDraftAgentSession.js";
7
+ import { bindDraftAgentSession } from "./private/bindDraftAgentSession.js";
8
8
 
9
9
  const inflightBySessionId = new Map<string, Promise<AgentSession>>();
10
10
 
@@ -1,7 +1,7 @@
1
1
  import { createRuntimeExtras } from "@assistant-ui/core/internal";
2
2
  import type { McpAuthRequiredEvent } from "truefoundry-gateway-sdk/agents";
3
3
 
4
- import type { AgentSpec, AgentSpecUpdate } from "./agentSpec.js";
4
+ import type { AgentSpec, AgentSpecUpdate } from "./private/agentSpec.js";
5
5
  import type { PendingApproval, PendingToolResponse } from "./collectPending.js";
6
6
  import type { RespondToToolApprovalOptions } from "./toolApproval.js";
7
7
  import type { RespondToToolResponseOptions } from "./toolResponse.js";
@@ -27,6 +27,7 @@ export type TrueFoundryRuntimeExtras = {
27
27
  downloadSandboxFile: (path: string) => Promise<Blob>;
28
28
  cancel: () => Promise<void>;
29
29
  resetFromTurn: (turnId: string) => Promise<void>;
30
+ reload: () => void;
30
31
  draft: TrueFoundryDraftRuntimeExtras | null;
31
32
  };
32
33
 
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";