@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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truefoundry/assistant-ui-runtime",
3
- "version": "0.1.0-rc.1",
3
+ "version": "0.1.1",
4
4
  "description": "TrueFoundry Gateway agent runtime adapter for assistant-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -50,7 +50,7 @@
50
50
  "peerDependencies": {
51
51
  "@types/react": "*",
52
52
  "react": "^18 || ^19",
53
- "truefoundry-gateway-sdk": "^0.2.0"
53
+ "truefoundry-gateway-sdk": "^0.3.1"
54
54
  },
55
55
  "peerDependenciesMeta": {
56
56
  "@types/react": {
@@ -18,8 +18,20 @@ declare module "truefoundry-gateway-sdk/dist/esm/agents/AgentSession.mjs" {
18
18
  }): PreparedTurn;
19
19
  listTurns(
20
20
  opts?: TrueFoundryGatewayApi.agents.SessionsListTurnsRequest,
21
+ requestOptions?: unknown,
21
22
  ): Promise<core.Page<unknown, TrueFoundryGatewayApi.ListTurnsResponse>>;
22
- getTurn(opts: { turnId: string }): Promise<unknown>;
23
- cancel(): Promise<void>;
23
+ /**
24
+ * Paginated session events across turns (newest first); subscribe to a
25
+ * running turn for live events.
26
+ *
27
+ * @param opts.lastTurnId - Newest turn in the listing window (initial load only).
28
+ * Omit to use the session last turn. Running-turn events are excluded.
29
+ */
30
+ listEvents(
31
+ opts?: TrueFoundryGatewayApi.agents.SessionsListEventsRequest,
32
+ requestOptions?: unknown,
33
+ ): Promise<core.Page<TrueFoundryGatewayApi.SessionEventItem, TrueFoundryGatewayApi.ListSessionEventsResponse>>;
34
+ getTurn(opts: { turnId: string }, requestOptions?: unknown): Promise<unknown>;
35
+ cancel(requestOptions?: unknown): Promise<void>;
24
36
  }
25
37
  }
@@ -8,6 +8,8 @@ import type {
8
8
  ToolApprovalRequiredEvent,
9
9
  ToolResponseRequiredEvent,
10
10
  Turn,
11
+ TurnCreatedEvent,
12
+ TurnDoneEvent,
11
13
  TurnEvent,
12
14
  TurnStreamData,
13
15
  } from "truefoundry-gateway-sdk/agents";
@@ -15,6 +17,8 @@ import type {
15
17
  import { ROOT_THREAD_ID } from "./constants.js";
16
18
  import { collectPendingToolResponses } from "./collectPending.js";
17
19
  import {
20
+ buildSnapshotBeforeTurnIndex,
21
+ buildSnapshotFromSessionEvents,
18
22
  buildTurnAssistantContent,
19
23
  buildUserMessageContent,
20
24
  buildUserMessageFromTurnInput,
@@ -132,9 +136,75 @@ function mockTurn(
132
136
  };
133
137
  }
134
138
 
139
+ async function collectTurnListEvents(
140
+ turn: Pick<Turn, "listEvents">,
141
+ ): Promise<TurnEvent[]> {
142
+ const events: TurnEvent[] = [];
143
+ for await (const event of await turn.listEvents()) {
144
+ events.push(event as TurnEvent);
145
+ }
146
+ return events;
147
+ }
148
+
149
+ /**
150
+ * Builds session-level event items from per-turn mocks — newest-first turns,
151
+ * running turns excluded (matches session.listEvents API contract).
152
+ */
153
+ async function sessionEventItemsFromTurns(
154
+ turnsNewestFirst: ReturnType<typeof mockTurn>[],
155
+ lastTurnId?: string,
156
+ ): Promise<{ turnId: string; event: TurnCreatedEvent | TurnDoneEvent | TurnEvent }[]> {
157
+ let chain = turnsNewestFirst.filter((turn) => turn.state.status !== "running");
158
+ if (lastTurnId != null) {
159
+ const anchorIndex = chain.findIndex((turn) => turn.id === lastTurnId);
160
+ chain = anchorIndex === -1 ? [] : chain.slice(anchorIndex);
161
+ }
162
+
163
+ const items: { turnId: string; event: TurnCreatedEvent | TurnDoneEvent | TurnEvent }[] =
164
+ [];
165
+ for (const turn of [...chain].reverse()) {
166
+ items.push({
167
+ turnId: turn.id,
168
+ event: {
169
+ type: "turn.created",
170
+ id: `created-${turn.id}`,
171
+ turnId: turn.id,
172
+ input: turn.input,
173
+ state: { status: "running" },
174
+ createdBy: { subjectId: "u1", subjectType: "user" },
175
+ createdAt: turn.createdAt,
176
+ },
177
+ });
178
+ for (const event of await collectTurnListEvents(turn as Turn)) {
179
+ items.push({ turnId: turn.id, event });
180
+ }
181
+ items.push({
182
+ turnId: turn.id,
183
+ event: {
184
+ type: "turn.done",
185
+ id: `done-${turn.id}`,
186
+ state: turn.state as TurnDoneEvent["state"],
187
+ createdAt: turn.createdAt,
188
+ },
189
+ });
190
+ }
191
+ return items;
192
+ }
193
+
135
194
  function mockSession(turns: ReturnType<typeof mockTurn>[]): AgentSession {
136
195
  return {
137
196
  listTurns: turnsPage(turns as Turn[]) as unknown as AgentSession["listTurns"],
197
+ listEvents: async (opts?: { lastTurnId?: string }) => {
198
+ const items = await sessionEventItemsFromTurns(turns, opts?.lastTurnId);
199
+ return {
200
+ async *[Symbol.asyncIterator]() {
201
+ // session.listEvents yields newest-first.
202
+ for (const item of [...items].reverse()) {
203
+ yield item;
204
+ }
205
+ },
206
+ };
207
+ },
138
208
  } as unknown as AgentSession;
139
209
  }
140
210
 
@@ -1113,6 +1183,44 @@ describe("convertTurnMessages", () => {
1113
1183
  expect(updates).toEqual([{ content: [{ type: "text", text: "streaming" }] }]);
1114
1184
  });
1115
1185
 
1186
+ it("yields folded content after each ingested stream event", async () => {
1187
+ const foldState = new PeerThreadFoldState();
1188
+ const updates = await collectStream(
1189
+ streamTurnEvents(
1190
+ streamFrom([
1191
+ modelMessage({
1192
+ id: "m1",
1193
+ threadId: ROOT_THREAD_ID,
1194
+ content: "first",
1195
+ }),
1196
+ modelMessage({
1197
+ id: "m2",
1198
+ threadId: ROOT_THREAD_ID,
1199
+ content: "second",
1200
+ }),
1201
+ modelMessage({
1202
+ id: "m3",
1203
+ threadId: ROOT_THREAD_ID,
1204
+ content: "third",
1205
+ }),
1206
+ ]),
1207
+ foldState,
1208
+ ),
1209
+ );
1210
+
1211
+ expect(updates).toHaveLength(3);
1212
+ expect(updates[0]?.content).toEqual([{ type: "text", text: "first" }]);
1213
+ expect(updates[1]?.content).toEqual([
1214
+ { type: "text", text: "first" },
1215
+ { type: "text", text: "second" },
1216
+ ]);
1217
+ expect(updates[2]?.content).toEqual([
1218
+ { type: "text", text: "first" },
1219
+ { type: "text", text: "second" },
1220
+ { type: "text", text: "third" },
1221
+ ]);
1222
+ });
1223
+
1116
1224
  it("scopes streamed content to ids after the group baseline", async () => {
1117
1225
  const foldState = new PeerThreadFoldState();
1118
1226
  foldState.getOrCreateBucket(ROOT_THREAD_ID);
@@ -1989,3 +2097,257 @@ describe("convertTurnMessages", () => {
1989
2097
  });
1990
2098
  });
1991
2099
  });
2100
+
2101
+ // ---------------------------------------------------------------------------
2102
+ // buildSnapshotFromSessionEvents
2103
+ // ---------------------------------------------------------------------------
2104
+
2105
+ type SessionEventItem = { turnId: string; event: TurnCreatedEvent | TurnDoneEvent | TurnEvent };
2106
+
2107
+ /** Simulates session.listEvents — returns items in desc order (newest first). */
2108
+ function sessionEventsPage(items: SessionEventItem[]) {
2109
+ return async (opts?: { lastTurnId?: string }) => {
2110
+ let filtered = items;
2111
+ if (opts?.lastTurnId != null) {
2112
+ // Chronological items: keep through lastTurnId, drop later turns.
2113
+ const lastIndex = filtered.findLastIndex(
2114
+ (item) => item.turnId === opts.lastTurnId,
2115
+ );
2116
+ filtered = lastIndex === -1 ? [] : filtered.slice(0, lastIndex + 1);
2117
+ }
2118
+ return {
2119
+ async *[Symbol.asyncIterator]() {
2120
+ for (const item of [...filtered].reverse()) {
2121
+ yield item;
2122
+ }
2123
+ },
2124
+ };
2125
+ };
2126
+ }
2127
+
2128
+ /** Builds a mock AgentSession with listTurns and listEvents. */
2129
+ function mockSessionWithEvents(
2130
+ turns: Turn[],
2131
+ eventItems: SessionEventItem[],
2132
+ ): AgentSession {
2133
+ return {
2134
+ listTurns: turnsPage(turns),
2135
+ listEvents: sessionEventsPage(eventItems),
2136
+ } as unknown as AgentSession;
2137
+ }
2138
+
2139
+ describe("buildSnapshotFromSessionEvents", () => {
2140
+ it("builds a snapshot from a single complete turn", async () => {
2141
+ const items: SessionEventItem[] = [
2142
+ {
2143
+ turnId: "t1",
2144
+ event: {
2145
+ type: "turn.created",
2146
+ id: "evt-c1",
2147
+ turnId: "t1",
2148
+ input: [{ type: "user.message", content: "hello" }],
2149
+ state: { status: "running" },
2150
+ createdBy: { subjectId: "u1", subjectType: "user" },
2151
+ createdAt,
2152
+ },
2153
+ },
2154
+ {
2155
+ turnId: "t1",
2156
+ event: modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "hi there" }),
2157
+ },
2158
+ {
2159
+ turnId: "t1",
2160
+ event: {
2161
+ type: "turn.done",
2162
+ id: "evt-d1",
2163
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
2164
+ createdAt,
2165
+ } as TurnDoneEvent,
2166
+ },
2167
+ ];
2168
+
2169
+ const snapshot = await buildSnapshotFromSessionEvents(mockSessionWithEvents([], items));
2170
+
2171
+ expect(snapshot.turns).toHaveLength(1);
2172
+ expect(snapshot.turns[0]?.id).toBe("t1");
2173
+ expect(snapshot.turns[0]?.userText).toBe("hello");
2174
+ expect(snapshot.turns[0]?.state).toEqual({
2175
+ status: "done",
2176
+ requiredActions: [],
2177
+ completedAt: createdAt,
2178
+ });
2179
+ expect(snapshot.turns[0]?.rootModelMessageIds).toEqual(["m1"]);
2180
+ expect(snapshot.runningTurn).toBeUndefined();
2181
+
2182
+ const messages = projectSessionMessages(snapshot);
2183
+ expect(messages).toHaveLength(2);
2184
+ expect(messages[0]?.role).toBe("user");
2185
+ expect(messages[1]?.role).toBe("assistant");
2186
+ });
2187
+
2188
+ it("detects and attaches the running turn without events", async () => {
2189
+ const runningTurn = {
2190
+ id: "t2",
2191
+ state: { status: "running" },
2192
+ input: [{ type: "user.message", content: "in progress" }],
2193
+ createdAt,
2194
+ } as unknown as Turn;
2195
+
2196
+ const items: SessionEventItem[] = [
2197
+ {
2198
+ turnId: "t1",
2199
+ event: {
2200
+ type: "turn.created",
2201
+ id: "evt-c1",
2202
+ turnId: "t1",
2203
+ input: [{ type: "user.message", content: "first" }],
2204
+ state: { status: "running" },
2205
+ createdBy: { subjectId: "u1", subjectType: "user" },
2206
+ createdAt,
2207
+ },
2208
+ },
2209
+ {
2210
+ turnId: "t1",
2211
+ event: modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "reply 1" }),
2212
+ },
2213
+ {
2214
+ turnId: "t1",
2215
+ event: {
2216
+ type: "turn.done",
2217
+ id: "evt-d1",
2218
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
2219
+ createdAt,
2220
+ } as TurnDoneEvent,
2221
+ },
2222
+ // t2 (running) has no events — it is excluded from session-level listEvents
2223
+ ];
2224
+
2225
+ const snapshot = await buildSnapshotFromSessionEvents(
2226
+ mockSessionWithEvents([runningTurn], items),
2227
+ );
2228
+
2229
+ expect(snapshot.turns).toHaveLength(1);
2230
+ expect(snapshot.turns[0]?.id).toBe("t1");
2231
+ expect(snapshot.runningTurn).toBe(runningTurn);
2232
+ expect(snapshot.unstable_resume).toBe(true);
2233
+ expect(snapshot.groupRootBaseline).toBeDefined();
2234
+ expect(snapshot.pendingUser).toMatchObject({
2235
+ turnId: "t2",
2236
+ content: "in progress",
2237
+ });
2238
+ });
2239
+
2240
+ it("calls onProgress after each completed turn", async () => {
2241
+ const items: SessionEventItem[] = [
2242
+ {
2243
+ turnId: "t1",
2244
+ event: {
2245
+ type: "turn.created",
2246
+ id: "evt-c1",
2247
+ turnId: "t1",
2248
+ input: [{ type: "user.message", content: "first" }],
2249
+ state: { status: "running" },
2250
+ createdBy: { subjectId: "u1", subjectType: "user" },
2251
+ createdAt,
2252
+ },
2253
+ },
2254
+ {
2255
+ turnId: "t1",
2256
+ event: modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "reply 1" }),
2257
+ },
2258
+ {
2259
+ turnId: "t1",
2260
+ event: {
2261
+ type: "turn.done",
2262
+ id: "evt-d1",
2263
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
2264
+ createdAt,
2265
+ } as TurnDoneEvent,
2266
+ },
2267
+ {
2268
+ turnId: "t2",
2269
+ event: {
2270
+ type: "turn.created",
2271
+ id: "evt-c2",
2272
+ turnId: "t2",
2273
+ input: [{ type: "user.message", content: "second" }],
2274
+ state: { status: "running" },
2275
+ createdBy: { subjectId: "u1", subjectType: "user" },
2276
+ createdAt,
2277
+ },
2278
+ },
2279
+ {
2280
+ turnId: "t2",
2281
+ event: modelMessage({ id: "m2", threadId: ROOT_THREAD_ID, content: "reply 2" }),
2282
+ },
2283
+ {
2284
+ turnId: "t2",
2285
+ event: {
2286
+ type: "turn.done",
2287
+ id: "evt-d2",
2288
+ state: { status: "done", requiredActions: [], completedAt: createdAt },
2289
+ createdAt,
2290
+ } as TurnDoneEvent,
2291
+ },
2292
+ ];
2293
+
2294
+ const progressSnapshots: number[] = [];
2295
+ await buildSnapshotFromSessionEvents(
2296
+ mockSessionWithEvents([], items),
2297
+ (snap) => progressSnapshots.push(snap.turns.length),
2298
+ );
2299
+
2300
+ expect(progressSnapshots).toEqual([1, 2]);
2301
+ });
2302
+ });
2303
+
2304
+ describe("buildSnapshotBeforeTurnIndex", () => {
2305
+ it("rewinds via session.listEvents({ lastTurnId }) excluding the branch turn", async () => {
2306
+ const t1 = mockTurn({
2307
+ id: "t1",
2308
+ createdAt,
2309
+ input: [{ type: "user.message", content: "first" }],
2310
+ listEvents: eventsPage([
2311
+ modelMessage({ id: "m1", threadId: ROOT_THREAD_ID, content: "reply 1" }),
2312
+ ]) as unknown as Turn["listEvents"],
2313
+ });
2314
+ const t2 = mockTurn({
2315
+ id: "t2",
2316
+ createdAt,
2317
+ input: [{ type: "user.message", content: "second" }],
2318
+ listEvents: eventsPage([
2319
+ modelMessage({ id: "m2", threadId: ROOT_THREAD_ID, content: "reply 2" }),
2320
+ ]) as unknown as Turn["listEvents"],
2321
+ });
2322
+ const t3 = mockTurn({
2323
+ id: "t3",
2324
+ createdAt,
2325
+ input: [{ type: "user.message", content: "third" }],
2326
+ listEvents: eventsPage([
2327
+ modelMessage({ id: "m3", threadId: ROOT_THREAD_ID, content: "reply 3" }),
2328
+ ]) as unknown as Turn["listEvents"],
2329
+ });
2330
+
2331
+ // listTurns is newest-first.
2332
+ const session = mockSession([t3, t2, t1]);
2333
+ const snapshot = await buildSnapshotBeforeTurnIndex(session, 2);
2334
+
2335
+ expect(snapshot.turns.map((turn) => turn.id)).toEqual(["t1", "t2"]);
2336
+ const messages = projectSessionMessages(snapshot);
2337
+ expect(messages.map((message) => message.id)).toEqual([
2338
+ "t1-user",
2339
+ "t1-assistant",
2340
+ "t2-user",
2341
+ "t2-assistant",
2342
+ ]);
2343
+ });
2344
+
2345
+ it("returns an empty snapshot when branching from the first turn", async () => {
2346
+ const session = mockSession([
2347
+ mockTurn({ id: "t1", createdAt }),
2348
+ ]);
2349
+ const snapshot = await buildSnapshotBeforeTurnIndex(session, 0);
2350
+ expect(snapshot.turns).toHaveLength(0);
2351
+ });
2352
+ });
2353
+