@truefoundry/assistant-ui-runtime 0.1.2 → 0.1.3-rc.2

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.
@@ -0,0 +1,77 @@
1
+ import type { RemoteThreadListAdapter } from "@assistant-ui/core";
2
+ import type { AgentSession } from "truefoundry-gateway-sdk/agents";
3
+ import type {
4
+ AgentDraftSession,
5
+ PrivateAgentSessionClient,
6
+ } from "truefoundry-gateway-sdk/agents/private";
7
+
8
+ import { draftSessionTitle } from "./private/agentSpec.js";
9
+ import { sessionListStartTimestamp } from "./sessionListStartTimestamp.js";
10
+
11
+ const THREAD_LIST_PAGE_SIZE = 20;
12
+
13
+ function ownedSessionTitle(session: AgentSession | AgentDraftSession): string {
14
+ if (session.type === "session/draft") {
15
+ return draftSessionTitle(session);
16
+ }
17
+ return session.title ?? session.agentName;
18
+ }
19
+
20
+ /**
21
+ * Read-only thread-list adapter backed by `PrivateAgentSessionClient.listOwnedSessions`.
22
+ * Returns every session the caller owns (named + draft), newest first.
23
+ */
24
+ export function createTrueFoundryOwnedSessionsThreadListAdapter(options: {
25
+ privateClient: PrivateAgentSessionClient;
26
+ }): RemoteThreadListAdapter {
27
+ const { privateClient } = options;
28
+
29
+ return {
30
+ async list({ after } = {}) {
31
+ const page = await privateClient.listOwnedSessions({
32
+ limit: THREAD_LIST_PAGE_SIZE,
33
+ pageToken: after,
34
+ startTimestamp: sessionListStartTimestamp(),
35
+ });
36
+ const threads = page.data.map((session) => ({
37
+ status: "regular" as const,
38
+ remoteId: session.id,
39
+ title: ownedSessionTitle(session),
40
+ lastMessageAt: new Date(session.updatedAt),
41
+ }));
42
+ return {
43
+ threads,
44
+ nextCursor: page.response.pagination.nextPageToken ?? undefined,
45
+ };
46
+ },
47
+
48
+ async initialize() {
49
+ throw new Error(
50
+ "Owned sessions history adapter is read-only; create sessions via a named or draft runtime.",
51
+ );
52
+ },
53
+
54
+ async fetch(remoteId) {
55
+ // PrivateAgentSessionClient only exposes get for drafts. Named sessions
56
+ // surface through listOwnedSessions; fetch falls back to draft get.
57
+ const draft = await privateClient.getDraftSession({
58
+ draftSessionId: remoteId,
59
+ });
60
+ return {
61
+ status: "regular" as const,
62
+ remoteId: draft.id,
63
+ title: draftSessionTitle(draft),
64
+ lastMessageAt: new Date(draft.updatedAt),
65
+ };
66
+ },
67
+
68
+ async rename() {},
69
+ async archive() {},
70
+ async unarchive() {},
71
+ async delete() {},
72
+
73
+ async generateTitle() {
74
+ return new ReadableStream();
75
+ },
76
+ };
77
+ }
package/src/types.ts CHANGED
@@ -7,7 +7,7 @@ import type {
7
7
  SpeechSynthesisAdapter,
8
8
  } from "@assistant-ui/core";
9
9
  import type { AgentSessionClient } from "truefoundry-gateway-sdk/agents";
10
- import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
10
+ import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
11
11
 
12
12
  import type { AgentSpec } from "./private/agentSpec.js";
13
13
 
@@ -47,13 +47,16 @@ export type UseTrueFoundryAgentRuntimeOptions = TrueFoundryAgentRuntimeBaseOptio
47
47
  agent?: TrueFoundryAgentConfig | undefined;
48
48
  /** Legacy named-agent shorthand. Prefer `agent: { mode: "named", agentName }`. */
49
49
  agentName?: string | undefined;
50
- /** Required when `agent.mode === "draft"`. */
51
- gateway?: TrueFoundryGateway | undefined;
50
+ /**
51
+ * Required when `agent.mode === "draft"`. Also used for sandbox file downloads
52
+ * (named or draft).
53
+ */
54
+ privateClient?: PrivateAgentSessionClient | undefined;
52
55
  };
53
56
 
54
57
  export type ResolvedTrueFoundryAgentRuntimeOptions = TrueFoundryAgentRuntimeBaseOptions & {
55
58
  agent: TrueFoundryAgentConfig;
56
- gateway?: TrueFoundryGateway | undefined;
59
+ privateClient?: PrivateAgentSessionClient | undefined;
57
60
  };
58
61
 
59
62
  export function resolveTrueFoundryAgentConfig(
@@ -78,15 +81,15 @@ export function resolveTrueFoundryAgentRuntimeOptions(
78
81
  ): ResolvedTrueFoundryAgentRuntimeOptions {
79
82
  const agent = resolveTrueFoundryAgentConfig(options);
80
83
 
81
- if (agent.mode === "draft" && options.gateway == null) {
84
+ if (agent.mode === "draft" && options.privateClient == null) {
82
85
  throw new Error(
83
- "Draft agent mode requires a `gateway` TrueFoundryGateway client.",
86
+ "Draft agent mode requires a `privateClient` PrivateAgentSessionClient.",
84
87
  );
85
88
  }
86
89
 
87
90
  return {
88
91
  ...options,
89
92
  agent,
90
- gateway: options.gateway,
93
+ privateClient: options.privateClient,
91
94
  };
92
95
  }
@@ -3,7 +3,7 @@ import type { ThreadMessage } from "@assistant-ui/core";
3
3
  import { act, renderHook, waitFor } from "@testing-library/react";
4
4
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5
5
  import type { AgentSessionClient, Turn } from "truefoundry-gateway-sdk/agents";
6
- import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
6
+ import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
7
7
 
8
8
  import { ROOT_THREAD_ID } from "./constants.js";
9
9
  import { collectPendingToolResponses } from "./collectPending.js";
@@ -971,16 +971,16 @@ describe("useTrueFoundryAgentMessages", () => {
971
971
  expect(loadSessionSnapshot).toHaveBeenCalledTimes(1);
972
972
  });
973
973
 
974
- it("cancel resolves the session through the draft gateway in draft mode", async () => {
974
+ it("cancel resolves the session through the private client in draft mode", async () => {
975
975
  const cancel = vi.fn().mockResolvedValue(undefined);
976
976
  vi.mocked(getSession).mockResolvedValue({ cancel } as never);
977
- const draftGateway = {} as TrueFoundryGateway;
977
+ const privateClient = {} as PrivateAgentSessionClient;
978
978
 
979
979
  const { result } = renderHook(() =>
980
980
  useTrueFoundryAgentMessages({
981
981
  client: mockClient,
982
982
  sessionId: "draft-session-1",
983
- draftGateway,
983
+ privateClient,
984
984
  }),
985
985
  );
986
986
  await waitFor(() => expect(result.current.isLoading).toBe(false));
@@ -991,7 +991,7 @@ describe("useTrueFoundryAgentMessages", () => {
991
991
 
992
992
  expect(cancel).toHaveBeenCalled();
993
993
  expect(getSession).toHaveBeenCalledWith(mockClient, "draft-session-1", {
994
- draftGateway,
994
+ privateClient,
995
995
  });
996
996
  });
997
997
  });
@@ -11,7 +11,7 @@ import type {
11
11
  TurnInputItem,
12
12
  TurnStateDone,
13
13
  } from "truefoundry-gateway-sdk/agents";
14
- import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
14
+ import type { PrivateAgentSessionClient } from "truefoundry-gateway-sdk/agents/private";
15
15
 
16
16
  import { ROOT_THREAD_ID } from "./constants.js";
17
17
  import {
@@ -68,8 +68,8 @@ export type UseTrueFoundryAgentMessagesOptions = {
68
68
  }>;
69
69
  /** Maps a thread `remoteId` to the gateway session id used for turns. */
70
70
  resolveConversationSessionId?: (remoteId: string) => Promise<string>;
71
- /** When set, turns bind to `/agents/sessions/{draftSessionId}/turns` after draft validation. */
72
- draftGateway?: TrueFoundryGateway;
71
+ /** When set, turns bind via PrivateAgentSessionClient.getDraftSession. */
72
+ privateClient?: PrivateAgentSessionClient;
73
73
  /**
74
74
  * Optional per-turn headers for createTurn. Invoked once per `sendTurn` after
75
75
  * the session is resolved; return value is forwarded to `turn.execute`.
@@ -294,13 +294,13 @@ export function useTrueFoundryAgentMessages({
294
294
  onError,
295
295
  initializeSession,
296
296
  resolveConversationSessionId,
297
- draftGateway,
297
+ privateClient,
298
298
  getTurnHeaders,
299
299
  }: UseTrueFoundryAgentMessagesOptions) {
300
300
  const sessionOptions = useMemo<GetSessionOptions | undefined>(
301
301
  () =>
302
- draftGateway != null ? { draftGateway } : undefined,
303
- [draftGateway],
302
+ privateClient != null ? { privateClient } : undefined,
303
+ [privateClient],
304
304
  );
305
305
 
306
306
  const [snapshot, setSnapshot] = useState<SessionSnapshot>(createEmptySessionSnapshot);
@@ -353,7 +353,14 @@ export function useTrueFoundryAgentMessages({
353
353
  const runStream = useCallback(
354
354
  (
355
355
  createStream: (signal: AbortSignal) => AsyncGenerator<TurnStreamUpdate>,
356
- turnId: string,
356
+ /**
357
+ * A mutable ref whose `.current` is the turn ID to use for
358
+ * `activeStream.turnId`. Callers that capture the gateway turn ID
359
+ * via `onTurnIdAvailable` update this ref in-place so that both the
360
+ * pending-update flush and `commitActiveStream` always see the real
361
+ * gateway ID rather than the locally-generated optimistic one.
362
+ */
363
+ turnIdRef: { current: string },
357
364
  isContinuation: boolean,
358
365
  ): Promise<void> => {
359
366
  const streamGeneration = ++streamGenerationRef.current;
@@ -368,7 +375,6 @@ export function useTrueFoundryAgentMessages({
368
375
  // message tree (UI hang). The buffer belongs to this stream only.
369
376
  let pendingStreamUpdate: {
370
377
  update: TurnStreamUpdate;
371
- turnId: string;
372
378
  isContinuation: boolean;
373
379
  } | null = null;
374
380
  let streamUpdateRaf: number | null = null;
@@ -383,12 +389,14 @@ export function useTrueFoundryAgentMessages({
383
389
  ) {
384
390
  return;
385
391
  }
386
- const { update, turnId: pendingTurnId, isContinuation: pendingIsContinuation } =
387
- pending;
392
+ const { update, isContinuation: pendingIsContinuation } = pending;
388
393
  setSnapshot((prev) =>
389
394
  replaceSessionSnapshot(prev, {
390
395
  activeStream: {
391
- turnId: pendingTurnId,
396
+ // Read from the ref so we always use the latest ID,
397
+ // including any gateway ID that arrived after the RAf
398
+ // was scheduled.
399
+ turnId: turnIdRef.current,
392
400
  update,
393
401
  isContinuation: pendingIsContinuation,
394
402
  },
@@ -397,7 +405,7 @@ export function useTrueFoundryAgentMessages({
397
405
  };
398
406
 
399
407
  const applyStreamUpdate = (update: TurnStreamUpdate) => {
400
- pendingStreamUpdate = { update, turnId, isContinuation };
408
+ pendingStreamUpdate = { update, isContinuation };
401
409
  if (streamUpdateRaf == null) {
402
410
  streamUpdateRaf = requestAnimationFrame(flushPendingStreamUpdate);
403
411
  }
@@ -540,7 +548,7 @@ export function useTrueFoundryAgentMessages({
540
548
  undefined,
541
549
  loadedSnapshot.groupRootBaseline,
542
550
  ),
543
- turn.id,
551
+ { current: turn.id },
544
552
  isContinuation,
545
553
  ).catch(() => undefined);
546
554
  }
@@ -589,6 +597,11 @@ export function useTrueFoundryAgentMessages({
589
597
  ? continuationTurnId
590
598
  : generateId();
591
599
 
600
+ // Mutable ref so runStream always reads the latest ID. For new
601
+ // user-message turns the local `generateId()` value is replaced
602
+ // with the gateway-assigned ID once the first SSE event arrives.
603
+ const turnIdRef = { current: turnId };
604
+
592
605
  if ("inputs" in options) {
593
606
  applyUserToolResponsesToFold(
594
607
  snapshotRef.current.fold,
@@ -681,9 +694,26 @@ export function useTrueFoundryAgentMessages({
681
694
  },
682
695
  signal,
683
696
  groupRootBaseline,
697
+ // Rename the optimistic local ID to the gateway turn ID
698
+ // so that edit/retry can resolve the turn via the gateway.
699
+ (gatewayTurnId) => {
700
+ const oldId = turnIdRef.current;
701
+ if (gatewayTurnId === oldId) return;
702
+ turnIdRef.current = gatewayTurnId;
703
+ // Rename in the ref immediately so any synchronous read
704
+ // (e.g. commitActiveStream) sees the correct ID.
705
+ const renamePendingUser = (prev: SessionSnapshot): SessionSnapshot => {
706
+ if (prev.pendingUser?.turnId !== oldId) return prev;
707
+ return replaceSessionSnapshot(prev, {
708
+ pendingUser: { ...prev.pendingUser, turnId: gatewayTurnId },
709
+ });
710
+ };
711
+ snapshotRef.current = renamePendingUser(snapshotRef.current);
712
+ setSnapshot(renamePendingUser);
713
+ },
684
714
  );
685
715
  },
686
- turnId,
716
+ turnIdRef,
687
717
  isContinuation,
688
718
  );
689
719
  },
@@ -784,7 +814,7 @@ export function useTrueFoundryAgentMessages({
784
814
  undefined,
785
815
  snapshotRef.current.groupRootBaseline,
786
816
  ),
787
- turn.id,
817
+ { current: turn.id },
788
818
  true,
789
819
  );
790
820
  }, [runStream]);
@@ -30,6 +30,7 @@ import {
30
30
  createDraftSessionBridge,
31
31
  DRAFT_SESSION_LAST_UPDATED_AT_HEADER,
32
32
  } from "./private/draftSessionBridge.js";
33
+ import { getGatewayFromPrivateClient } from "./private/getGatewayFromPrivateClient.js";
33
34
  import { MCP_AUTH_RESUME_RUN_CUSTOM_KEY } from "./mcpAuth.js";
34
35
  import { createTrueFoundryDraftThreadListAdapter } from "./private/truefoundryDraftThreadListAdapter.js";
35
36
  import { trueFoundryExtras } from "./truefoundryExtras.js";
@@ -46,7 +47,7 @@ function useTrueFoundryAgentRuntimeImpl(
46
47
  const {
47
48
  client,
48
49
  agent,
49
- gateway,
50
+ privateClient,
50
51
  adapters,
51
52
  onError,
52
53
  listEventsConcurrency,
@@ -54,8 +55,8 @@ function useTrueFoundryAgentRuntimeImpl(
54
55
  } = options;
55
56
 
56
57
  const draftBridgeRef = useRef(
57
- agent.mode === "draft" && gateway != null
58
- ? createDraftSessionBridge(gateway)
58
+ agent.mode === "draft" && privateClient != null
59
+ ? createDraftSessionBridge(privateClient)
59
60
  : null,
60
61
  );
61
62
 
@@ -125,7 +126,7 @@ function useTrueFoundryAgentRuntimeImpl(
125
126
  listEventsConcurrency,
126
127
  onError,
127
128
  initializeSession,
128
- draftGateway: agent.mode === "draft" ? gateway : undefined,
129
+ privateClient: agent.mode === "draft" ? privateClient : undefined,
129
130
  getTurnHeaders: agent.mode === "draft" ? getTurnHeaders : undefined,
130
131
  });
131
132
 
@@ -151,9 +152,9 @@ function useTrueFoundryAgentRuntimeImpl(
151
152
 
152
153
  const downloadSandboxFile = useCallback(
153
154
  async (path: string) => {
154
- if (gateway == null) {
155
+ if (privateClient == null) {
155
156
  const error = new Error(
156
- "Downloading a sandbox file requires a `gateway` TrueFoundryGateway client.",
157
+ "Downloading a sandbox file requires a `privateClient` PrivateAgentSessionClient.",
157
158
  );
158
159
  onError?.(error);
159
160
  throw error;
@@ -163,10 +164,12 @@ function useTrueFoundryAgentRuntimeImpl(
163
164
  onError?.(error);
164
165
  throw error;
165
166
  }
167
+ // Same gateway.agents.downloadSandboxFile path as before, via the wrapped client.
168
+ const gateway = getGatewayFromPrivateClient(privateClient);
166
169
  const response = await gateway.agents.downloadSandboxFile(sandboxId, { path });
167
170
  return await response.blob();
168
171
  },
169
- [gateway, sandboxId, onError],
172
+ [privateClient, sandboxId, onError],
170
173
  );
171
174
 
172
175
  const draftExtras = useMemo(() => {
@@ -264,7 +267,7 @@ export function useTrueFoundryAgentRuntime(options: UseTrueFoundryAgentRuntimeOp
264
267
  // causing `threadListAdapter` to be a new object each render. We compute `resolved`
265
268
  // unconditionally and stabilize individual downstream values with primitives/refs.
266
269
  const resolved = resolveTrueFoundryAgentRuntimeOptions(options);
267
- const { client, agent, gateway } = resolved;
270
+ const { client, agent, privateClient } = resolved;
268
271
 
269
272
  const pendingAgentSpecRef = useRef<AgentSpec | undefined>(
270
273
  agent.mode === "draft" ? agent.defaultAgentSpec : undefined,
@@ -276,14 +279,14 @@ export function useTrueFoundryAgentRuntime(options: UseTrueFoundryAgentRuntimeOp
276
279
  const namedAgentName = agent.mode === "named" ? agent.agentName : undefined;
277
280
  const threadListAdapter = useMemo(() => {
278
281
  if (agentMode === "draft") {
279
- if (gateway == null) {
282
+ if (privateClient == null) {
280
283
  throw new Error(
281
- "Draft agent mode requires a `gateway` TrueFoundryGateway client.",
284
+ "Draft agent mode requires a `privateClient` PrivateAgentSessionClient.",
282
285
  );
283
286
  }
284
287
  const draftAgent = agent as Extract<typeof agent, { mode: "draft" }>;
285
288
  return createTrueFoundryDraftThreadListAdapter({
286
- gateway,
289
+ privateClient,
287
290
  defaultAgentSpec: draftAgent.defaultAgentSpec,
288
291
  getAgentSpec: () => pendingAgentSpecRef.current ?? draftAgent.defaultAgentSpec,
289
292
  });
@@ -293,7 +296,7 @@ export function useTrueFoundryAgentRuntime(options: UseTrueFoundryAgentRuntimeOp
293
296
  agentName: namedAgentName!,
294
297
  });
295
298
  // eslint-disable-next-line react-hooks/exhaustive-deps
296
- }, [agentMode, namedAgentName, client, gateway]);
299
+ }, [agentMode, namedAgentName, client, privateClient]);
297
300
 
298
301
  return useRemoteThreadListRuntime({
299
302
  allowNesting: true,
@@ -1,37 +0,0 @@
1
- declare module "truefoundry-gateway-sdk/dist/esm/agents/AgentSession.mjs" {
2
- import type { TrueFoundryGateway } from "truefoundry-gateway-sdk";
3
- import type * as TrueFoundryGatewayApi from "truefoundry-gateway-sdk";
4
- import type { PreparedTurn } from "truefoundry-gateway-sdk/agents";
5
- import type * as core from "truefoundry-gateway-sdk/core";
6
-
7
- export class AgentSession implements TrueFoundryGatewayApi.Session {
8
- readonly id: string;
9
- readonly agentName: string;
10
- readonly title?: string;
11
- readonly createdBySubject: TrueFoundryGatewayApi.Subject;
12
- readonly createdAt: string;
13
- readonly updatedAt: string;
14
- constructor(session: TrueFoundryGatewayApi.Session, client: TrueFoundryGateway);
15
- prepareTurn(opts?: {
16
- input?: TrueFoundryGatewayApi.TurnInputItem[];
17
- previousTurnId?: TrueFoundryGatewayApi.PreviousTurnIdInput;
18
- }): PreparedTurn;
19
- listTurns(
20
- opts?: TrueFoundryGatewayApi.agents.SessionsListTurnsRequest,
21
- requestOptions?: unknown,
22
- ): Promise<core.Page<unknown, TrueFoundryGatewayApi.ListTurnsResponse>>;
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>;
36
- }
37
- }