@truefoundry/assistant-ui-runtime 0.1.9 → 0.1.10

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 (33) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/dist/{chunk-CXBZ6WLZ.js → chunk-6ZOGBD7E.js} +99 -17
  3. package/dist/chunk-6ZOGBD7E.js.map +1 -0
  4. package/dist/index.d.ts +26 -5
  5. package/dist/index.js +92 -71
  6. package/dist/index.js.map +1 -1
  7. package/dist/plugins/truefoundry-agent-server-adapter/index.d.ts +1 -1
  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-6yWuWHzK.d.ts → types-Bb8Kaf1h.d.ts} +16 -3
  11. package/package.json +3 -3
  12. package/src/convertTurnMessages.test.ts +23 -1
  13. package/src/convertTurnMessages.ts +9 -3
  14. package/src/draft/truefoundryDraftThreadListAdapter.test.ts +57 -4
  15. package/src/draft/truefoundryDraftThreadListAdapter.ts +18 -22
  16. package/src/hooks.ts +23 -3
  17. package/src/index.ts +1 -0
  18. package/src/messageCustomMetadata.ts +2 -0
  19. package/src/plugins/truefoundry-agent-server-adapter/chatServer.ts +7 -6
  20. package/src/plugins/truefoundry-agent-server-adapter/cp.test.ts +169 -6
  21. package/src/plugins/truefoundry-agent-server-adapter/cp.ts +126 -1
  22. package/src/plugins/truefoundry-agent-server-adapter/normalizeAgentSpec.test.ts +34 -0
  23. package/src/plugins/truefoundry-agent-server-adapter/normalizeAgentSpec.ts +12 -5
  24. package/src/server/types.ts +18 -6
  25. package/src/sessionThreadMetadata.ts +39 -0
  26. package/src/truefoundryExtras.ts +1 -1
  27. package/src/truefoundryOwnedSessionsThreadListAdapter.test.ts +16 -0
  28. package/src/truefoundryOwnedSessionsThreadListAdapter.ts +13 -25
  29. package/src/truefoundryThreadListAdapter.test.ts +17 -9
  30. package/src/truefoundryThreadListAdapter.ts +9 -14
  31. package/src/types.ts +5 -0
  32. package/src/useTrueFoundryAgentRuntime.ts +17 -4
  33. package/dist/chunk-CXBZ6WLZ.js.map +0 -1
@@ -1,6 +1,6 @@
1
1
  import { AgentSessionClient } from 'truefoundry-gateway-sdk/agents';
2
2
  import { PrivateAgentSessionClient } from 'truefoundry-gateway-sdk/agents/private';
3
- import { A as AgentSpec, x as CreateSessionRequest, B as ListSessionsParams, S as Session, d as Turn, a7 as TurnState, a as AgentChatServer, ae as UpdateSessionRequest, ag as AgentSelectorEntry, ah as ConnectorSelectorEntry, ai as ModelSelectorEntry, aj as SkillSelectorEntry, f as AgentBuilderServer } from '../../types-6yWuWHzK.js';
3
+ import { A as AgentSpec, x as CreateSessionRequest, B as ListSessionsParams, S as Session, d as Turn, a7 as TurnState, a as AgentChatServer, ae as UpdateSessionRequest, ag as AgentSelectorEntry, ah as ConnectorSelectorEntry, ai as ModelSelectorEntry, aj as SkillSelectorEntry, f as AgentBuilderServer } from '../../types-Bb8Kaf1h.js';
4
4
  import { TruefoundryGatewayApi } from 'truefoundry-gateway-sdk';
5
5
 
6
6
  /**
@@ -7,7 +7,7 @@ import {
7
7
  isTfyMcpToolInfo,
8
8
  isTfySystemToolInfo,
9
9
  isTfyToolInfo
10
- } from "../../chunk-CXBZ6WLZ.js";
10
+ } from "../../chunk-6ZOGBD7E.js";
11
11
  export {
12
12
  createTrueFoundryAgentUIServer,
13
13
  createTrueFoundryChatServer,
@@ -1,5 +1,5 @@
1
- import { aa as TurnStreamingEvent, ak as DeltaEvents, T as TurnEvent } from '../types-6yWuWHzK.js';
2
- export { al as ActionRequiredEvent, f as AgentBuilderServer, a as AgentChatServer, am as AgentInfo, an as AgentParent, ag as AgentSelectorEntry, A as AgentSpec, g as AgentUIServerPort, ao as ApprovalDecision, C as CatalogServer, ap as ChunkDeltaToolCall, h as ConnectorAuth, i as ConnectorAuthApiKey, j as ConnectorAuthNone, k as ConnectorAuthOAuth, l as ConnectorAuthPublic, m as ConnectorAuthPublicApiKey, n as ConnectorAuthPublicNone, o as ConnectorAuthPublicOAuth, p as ConnectorAuthType, q as ConnectorBase, r as ConnectorCatalogEntry, s as ConnectorCatalogServer, t as ConnectorConfigBase, ah as ConnectorSelectorEntry, u as CreateConnectorRequest, v as CreateModelProviderRequest, w as CreateSandboxRequest, x as CreateSessionRequest, y as CreateSkillRequest, z as CreateSkillRequestBase, D as DefinedSkill, G as GithubSkill, I as ImportGithubSkillRequest, L as ListResult, aq as ListSessionsOrder, B as ListSessionsParams, M as McpAuthRequiredEvent, ar as McpInitializeEvent, as as McpServerAuthInfo, at as McpServerMount, au as Model, E as ModelCatalogServer, F as ModelEntry, av as ModelMessageContentPart, aw as ModelMessageDeltaEvent, H as ModelMessageEvent, ax as ModelParams, J as ModelProviderBase, K as ModelProviderCatalogEntry, N as ModelProviderConfigBase, ai as ModelSelectorEntry, ay as PageParams, P as PreviousTurnIdInput, O as ProviderType, R as RegistrySkill, Q as SandboxBase, V as SandboxCatalogEntry, W as SandboxCatalogServer, X as SandboxConfig, Y as SandboxCreatedEvent, az as SearchAgentSelectorParams, Z as SelectRegistrySkillRequest, S as Session, _ as SessionEventItem, $ as SkillBase, a0 as SkillCatalogEntry, a1 as SkillCatalogServer, a2 as SkillConfigBase, aA as SkillMount, aj as SkillSelectorEntry, b as ThreadCreatedEvent, aB as ThreadDoneEvent, a3 as ToolApprovalRequiredEvent, a4 as ToolBase, a5 as ToolCall, aC as ToolCallFunction, aD as ToolCallRef, aE as ToolInfo, aF as ToolResponseEvent, a6 as ToolResponseRequiredEvent, d as Turn, aG as TurnCreatedEvent, aH as TurnDoneEvent, e as TurnInputItem, a7 as TurnState, aI as TurnStateCancelled, a8 as TurnStateDone, aJ as TurnStateError, aK as TurnStateRunning, a9 as TurnStreamData, ab as UpdateConnectorRequest, ac as UpdateModelProviderRequest, ad as UpdateSandboxRequest, ae as UpdateSessionRequest, af as UserMessage, aL as UserMessageContent, c as UserToolApprovalEvent, U as UserToolResponseEvent } from '../types-6yWuWHzK.js';
1
+ import { aa as TurnStreamingEvent, ak as DeltaEvents, T as TurnEvent } from '../types-Bb8Kaf1h.js';
2
+ export { al as ActionRequiredEvent, f as AgentBuilderServer, a as AgentChatServer, am as AgentInfo, an as AgentParent, ag as AgentSelectorEntry, A as AgentSpec, g as AgentUIServerPort, ao as ApprovalDecision, C as CatalogServer, ap as ChunkDeltaToolCall, h as ConnectorAuth, i as ConnectorAuthApiKey, j as ConnectorAuthNone, k as ConnectorAuthOAuth, l as ConnectorAuthPublic, m as ConnectorAuthPublicApiKey, n as ConnectorAuthPublicNone, o as ConnectorAuthPublicOAuth, p as ConnectorAuthType, q as ConnectorBase, r as ConnectorCatalogEntry, s as ConnectorCatalogServer, t as ConnectorConfigBase, ah as ConnectorSelectorEntry, u as CreateConnectorRequest, v as CreateModelProviderRequest, w as CreateSandboxRequest, x as CreateSessionRequest, y as CreateSkillRequest, z as CreateSkillRequestBase, D as DefinedSkill, G as GithubSkill, I as ImportGithubSkillRequest, L as ListResult, aq as ListSessionsOrder, B as ListSessionsParams, M as McpAuthRequiredEvent, ar as McpInitializeEvent, as as McpServerAuthInfo, at as McpServerMount, au as Model, E as ModelCatalogServer, F as ModelEntry, av as ModelMessageContentPart, aw as ModelMessageDeltaEvent, H as ModelMessageEvent, ax as ModelParams, J as ModelProviderBase, K as ModelProviderCatalogEntry, N as ModelProviderConfigBase, ai as ModelSelectorEntry, ay as PageParams, P as PreviousTurnIdInput, O as ProviderType, R as RegistrySkill, Q as SandboxBase, V as SandboxCatalogEntry, W as SandboxCatalogServer, X as SandboxConfig, Y as SandboxCreatedEvent, az as SearchAgentSelectorParams, Z as SelectRegistrySkillRequest, S as Session, _ as SessionEventItem, $ as SkillBase, a0 as SkillCatalogEntry, a1 as SkillCatalogServer, a2 as SkillConfigBase, aA as SkillMount, aj as SkillSelectorEntry, b as ThreadCreatedEvent, aB as ThreadDoneEvent, a3 as ToolApprovalRequiredEvent, a4 as ToolBase, a5 as ToolCall, aC as ToolCallFunction, aD as ToolCallRef, aE as ToolInfo, aF as ToolResponseEvent, a6 as ToolResponseRequiredEvent, d as Turn, aG as TurnCreatedEvent, aH as TurnDoneEvent, e as TurnInputItem, a7 as TurnState, aI as TurnStateCancelled, a8 as TurnStateDone, aJ as TurnStateError, aK as TurnStateRunning, a9 as TurnStreamData, ab as UpdateConnectorRequest, ac as UpdateModelProviderRequest, ad as UpdateSandboxRequest, ae as UpdateSessionRequest, af as UserMessage, aL as UserMessageContent, c as UserToolApprovalEvent, U as UserToolResponseEvent } from '../types-Bb8Kaf1h.js';
3
3
 
4
4
  /**
5
5
  * Local implementations of streaming delta helpers.
@@ -245,9 +245,13 @@ interface ConnectorSelectorEntry {
245
245
  name: string;
246
246
  description?: string;
247
247
  }
248
- /** Agent selector row — UI shows name only. Host extends for metadata. */
248
+ /** Agent selector row. Host extends for metadata; `agentSpec` enables Edit. */
249
249
  interface AgentSelectorEntry {
250
250
  name: string;
251
+ /** Stable id when distinct from display `name`. Falls back to `name` when omitted. */
252
+ agentId?: string;
253
+ /** Published agent spec — required for Edit; optional for Try-only hosts. */
254
+ agentSpec?: AgentSpec;
251
255
  }
252
256
  type SearchAgentSelectorParams = {
253
257
  query?: string;
@@ -321,7 +325,8 @@ type PageParams = {
321
325
  pageToken?: string;
322
326
  };
323
327
  interface ListSessionsParams extends PageParams {
324
- agentName?: string;
328
+ /** Host-owned agent identity filter. Hosts that key agents by name pass that name here. */
329
+ agentId?: string;
325
330
  /** Host-specific filter (e.g. TFY startTimestamp). */
326
331
  startTimestamp?: string;
327
332
  }
@@ -442,7 +447,15 @@ interface AgentChatServer<TSpec extends AgentSpec = AgentSpec, TSession extends
442
447
  afterSequenceNumber?: number;
443
448
  abortSignal?: AbortSignal;
444
449
  }): AsyncIterable<TurnStreamData>;
445
- downloadSandboxFile?(sandboxId: string, req: {
450
+ /**
451
+ * Reads a file the agent wrote inside its sandbox. Hosts whose download route is scoped to a
452
+ * turn resolve the sandbox from `turnId` and ignore `sandboxId`; hosts addressing sandboxes
453
+ * directly use `sandboxId`.
454
+ */
455
+ downloadSandboxFile?(req: {
456
+ sessionId: string;
457
+ turnId: string;
458
+ sandboxId: string;
446
459
  path: string;
447
460
  }): Promise<Blob>;
448
461
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truefoundry/assistant-ui-runtime",
3
- "version": "0.1.9",
3
+ "version": "0.1.10",
4
4
  "description": "TrueFoundry Gateway agent runtime adapter for assistant-ui",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -54,8 +54,8 @@
54
54
  "typecheck": "tsc --noEmit"
55
55
  },
56
56
  "dependencies": {
57
- "@assistant-ui/core": "^0.2.19",
58
- "@assistant-ui/store": "^0.2.19"
57
+ "@assistant-ui/core": "^0.2.22",
58
+ "@assistant-ui/store": "^0.2.21"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "@types/react": "*",
@@ -461,6 +461,26 @@ describe("convertTurnMessages", () => {
461
461
  expect(updated.id).toBe(existing.id);
462
462
  expect(updated.content).toEqual([{ type: "text", text: "second" }]);
463
463
  });
464
+
465
+ it("stamps the streaming turn id onto the message metadata", () => {
466
+ const message = turnStreamUpdateToAssistantMessage("turn-1", {
467
+ content: [{ type: "text", text: "hi" }],
468
+ });
469
+ expect(message.metadata.custom.turnId).toBe("turn-1");
470
+ });
471
+
472
+ it("advances the turn id when a continuation reuses the existing message", () => {
473
+ const existing = turnStreamUpdateToAssistantMessage("turn-1", {
474
+ content: [{ type: "text", text: "first" }],
475
+ });
476
+ const updated = turnStreamUpdateToAssistantMessage(
477
+ "turn-2",
478
+ { content: [{ type: "text", text: "second" }] },
479
+ existing,
480
+ );
481
+ expect(updated.id).toBe("turn-1-assistant");
482
+ expect(updated.metadata.custom.turnId).toBe("turn-2");
483
+ });
464
484
  });
465
485
 
466
486
  describe("repositoryItemsFromMessages", () => {
@@ -546,7 +566,7 @@ describe("convertTurnMessages", () => {
546
566
 
547
567
  expect(result.messages[1]).toMatchObject({
548
568
  role: "assistant",
549
- metadata: { custom: { sandboxId: "sbx-123" } },
569
+ metadata: { custom: { sandboxId: "sbx-123", turnId: "turn-1" } },
550
570
  });
551
571
  });
552
572
 
@@ -601,6 +621,8 @@ describe("convertTurnMessages", () => {
601
621
  { type: "text", text: "first chunk" },
602
622
  { type: "text", text: " after approval" },
603
623
  ]);
624
+ // Artifacts on a merged message belong to the turn that wrote them last.
625
+ expect(assistant.metadata.custom.turnId).toBe("turn-2");
604
626
  });
605
627
 
606
628
  it("preserves requires-action on reload when a turn ends awaiting tool approval", async () => {
@@ -1074,8 +1074,14 @@ function projectHistoryTurns(
1074
1074
  content,
1075
1075
  record,
1076
1076
  );
1077
- const custom =
1078
- record.sandboxId != null ? { ...baseCustom, sandboxId: record.sandboxId } : baseCustom;
1077
+ // Continuation turns fold into the previous assistant message and overwrite this custom,
1078
+ // so a folded message reports the latest turn that contributed to it — the one whose
1079
+ // sandbox wrote the newest artifacts.
1080
+ const custom = {
1081
+ ...baseCustom,
1082
+ turnId: record.id,
1083
+ ...(record.sandboxId != null ? { sandboxId: record.sandboxId } : {}),
1084
+ };
1079
1085
 
1080
1086
  if (record.userText) {
1081
1087
  messages.push(
@@ -1732,7 +1738,7 @@ export function turnStreamUpdateToAssistantMessage(
1732
1738
  unstable_annotations: [],
1733
1739
  unstable_data: [],
1734
1740
  steps: [],
1735
- custom: update.metadata?.custom ?? {},
1741
+ custom: { ...update.metadata?.custom, turnId },
1736
1742
  },
1737
1743
  };
1738
1744
  }
@@ -10,7 +10,12 @@ const defaultAgentSpec: AgentSpec = {
10
10
  instructions: "You are helpful.",
11
11
  };
12
12
 
13
- function mockDraft(id: string, title: string | undefined, updatedAt: string): Session {
13
+ function mockDraft(
14
+ id: string,
15
+ title: string | undefined,
16
+ updatedAt: string,
17
+ extras?: Partial<Session>,
18
+ ): Session {
14
19
  return {
15
20
  id,
16
21
  agentSpec: defaultAgentSpec,
@@ -18,6 +23,7 @@ function mockDraft(id: string, title: string | undefined, updatedAt: string): Se
18
23
  createdAt: updatedAt,
19
24
  updatedAt,
20
25
  isMutable: true,
26
+ ...extras,
21
27
  };
22
28
  }
23
29
 
@@ -33,10 +39,22 @@ function mockServer(partial: Partial<AgentChatServer>): AgentChatServer {
33
39
  }
34
40
 
35
41
  describe("createTrueFoundryDraftThreadListAdapter", () => {
36
- it("lists draft sessions with pagination cursor", async () => {
42
+ it("lists sessions with pagination cursor and does not filter isMutable client-side", async () => {
37
43
  const listSessions = vi.fn().mockResolvedValue(
38
44
  mockDraftListPage(
39
- [mockDraft("d1", "My draft", "2026-06-30T10:00:00.000Z")],
45
+ [
46
+ mockDraft("d1", "My draft", "2026-06-30T10:00:00.000Z"),
47
+ mockDraft("s1", "Named", "2026-06-30T09:00:00.000Z", {
48
+ isMutable: false,
49
+ agentName: "from-sdk",
50
+ agentSpec: undefined,
51
+ }),
52
+ mockDraft("s2", undefined, "2026-06-30T08:00:00.000Z", {
53
+ isMutable: false,
54
+ agentName: "untitled-agent",
55
+ agentSpec: undefined,
56
+ }),
57
+ ],
40
58
  "page-2",
41
59
  ),
42
60
  );
@@ -61,7 +79,7 @@ describe("createTrueFoundryDraftThreadListAdapter", () => {
61
79
  }),
62
80
  );
63
81
  expect(listSessions).toHaveBeenCalledWith(
64
- expect.not.objectContaining({ agentName: expect.anything() }),
82
+ expect.not.objectContaining({ agentId: expect.anything() }),
65
83
  );
66
84
  expect(result.threads).toEqual([
67
85
  {
@@ -70,10 +88,45 @@ describe("createTrueFoundryDraftThreadListAdapter", () => {
70
88
  title: "My draft",
71
89
  lastMessageAt: new Date("2026-06-30T10:00:00.000Z"),
72
90
  },
91
+ {
92
+ status: "regular",
93
+ remoteId: "s1",
94
+ title: "Named",
95
+ lastMessageAt: new Date("2026-06-30T09:00:00.000Z"),
96
+ custom: { agentName: "from-sdk" },
97
+ },
98
+ {
99
+ status: "regular",
100
+ remoteId: "s2",
101
+ title: "untitled-agent",
102
+ lastMessageAt: new Date("2026-06-30T08:00:00.000Z"),
103
+ custom: { agentName: "untitled-agent" },
104
+ },
73
105
  ]);
74
106
  expect(result.nextCursor).toBe("page-2");
75
107
  });
76
108
 
109
+ it("forwards listSessionsAgentId as agentId", async () => {
110
+ const listSessions = vi.fn().mockResolvedValue(mockDraftListPage([]));
111
+ const server = mockServer({
112
+ listSessions,
113
+ createSession: vi.fn(),
114
+ getSession: vi.fn(),
115
+ });
116
+
117
+ const adapter = createTrueFoundryDraftThreadListAdapter({
118
+ server,
119
+ defaultAgentSpec,
120
+ listSessionsAgentId: "agent-x",
121
+ });
122
+
123
+ await adapter.list();
124
+
125
+ expect(listSessions).toHaveBeenCalledWith(
126
+ expect.objectContaining({ agentId: "agent-x" }),
127
+ );
128
+ });
129
+
77
130
  it("creates a draft session on initialize", async () => {
78
131
  const createSession = vi.fn().mockResolvedValue(
79
132
  mockDraft("d-new", undefined, "2026-06-30T12:00:00.000Z"),
@@ -2,8 +2,11 @@ import type { RemoteThreadListAdapter } from "@assistant-ui/core";
2
2
 
3
3
  import type { AgentChatServer } from "../server/types.js";
4
4
  import type { AgentSpec } from "../server/types.js";
5
- import { draftSessionTitle } from "./agentSpec.js";
6
5
  import { sessionListStartTimestamp } from "../sessionListStartTimestamp.js";
6
+ import {
7
+ sessionDisplayTitle,
8
+ sessionToThreadMetadata,
9
+ } from "../sessionThreadMetadata.js";
7
10
 
8
11
  const THREAD_LIST_PAGE_SIZE = 20;
9
12
 
@@ -11,27 +14,25 @@ export function createTrueFoundryDraftThreadListAdapter(options: {
11
14
  server: AgentChatServer;
12
15
  defaultAgentSpec: AgentSpec;
13
16
  getAgentSpec?: () => AgentSpec;
17
+ /** When set, filters `listSessions` by this agent id. Omit for all chats. */
18
+ listSessionsAgentId?: string;
14
19
  }): RemoteThreadListAdapter {
15
- const { server, defaultAgentSpec, getAgentSpec } = options;
20
+ const { server, defaultAgentSpec, getAgentSpec, listSessionsAgentId } = options;
16
21
 
17
22
  return {
18
23
  async list({ after } = {}) {
19
24
  const page = await server.listSessions({
25
+ ...(listSessionsAgentId != null ? { agentId: listSessionsAgentId } : {}),
20
26
  limit: THREAD_LIST_PAGE_SIZE,
21
27
  pageToken: after,
22
28
  startTimestamp: sessionListStartTimestamp(),
23
29
  });
24
- const threads = page.data
25
- .filter((session) => session.isMutable)
26
- .map((draft) => ({
27
- status: "regular" as const,
28
- remoteId: draft.id,
29
- title: draftSessionTitle({
30
- title: draft.title,
31
- agentSpec: draft.agentSpec ?? defaultAgentSpec,
32
- }),
33
- lastMessageAt: new Date(draft.updatedAt),
34
- }));
30
+ const threads = page.data.map((session) =>
31
+ sessionToThreadMetadata(
32
+ session,
33
+ sessionDisplayTitle(session, defaultAgentSpec),
34
+ ),
35
+ );
35
36
  return {
36
37
  threads,
37
38
  nextCursor: page.nextPageToken ?? undefined,
@@ -47,15 +48,10 @@ export function createTrueFoundryDraftThreadListAdapter(options: {
47
48
 
48
49
  async fetch(remoteId) {
49
50
  const draft = await server.getSession({ sessionId: remoteId });
50
- return {
51
- status: "regular" as const,
52
- remoteId: draft.id,
53
- title: draftSessionTitle({
54
- title: draft.title,
55
- agentSpec: draft.agentSpec ?? defaultAgentSpec,
56
- }),
57
- lastMessageAt: new Date(draft.updatedAt),
58
- };
51
+ return sessionToThreadMetadata(
52
+ draft,
53
+ sessionDisplayTitle(draft, defaultAgentSpec),
54
+ );
59
55
  },
60
56
 
61
57
  async rename() {},
package/src/hooks.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  "use client";
2
2
 
3
3
  import { useMemo } from "react";
4
- import { useAui } from "@assistant-ui/store";
4
+ import { useAui, useAuiState } from "@assistant-ui/store";
5
5
 
6
+ import type { TrueFoundryMessageCustomMetadata } from "./messageCustomMetadata.js";
6
7
  import {
7
8
  EMPTY_DRAFT_EXTRAS,
8
9
  trueFoundryExtras,
@@ -86,10 +87,29 @@ export const useTrueFoundryResumeMcpAuth = () => {
86
87
  export const useTrueFoundrySandboxId = (): string | undefined =>
87
88
  trueFoundryExtras.use((e) => e.sandboxId, undefined);
88
89
 
89
- /** Returns a function to download a sandbox file by path from any render context. */
90
+ /** Turn that produced the message being rendered. Only defined inside a message scope. */
91
+ export const useTrueFoundryTurnId = (): string | undefined =>
92
+ useAuiState(
93
+ (state) =>
94
+ (state.message.metadata?.custom as TrueFoundryMessageCustomMetadata | undefined)
95
+ ?.turnId,
96
+ );
97
+
98
+ /**
99
+ * Returns a function to download a file the current turn wrote to its sandbox. Must be called
100
+ * from a message scope, since the artifact belongs to the turn that rendered it.
101
+ */
90
102
  export const useTrueFoundryDownloadSandboxFile = () => {
91
103
  const aui = useAui();
92
- return (path: string) => trueFoundryExtras.get(aui).downloadSandboxFile(path);
104
+ const turnId = useTrueFoundryTurnId();
105
+ return (path: string) => {
106
+ if (turnId == null) {
107
+ throw new Error(
108
+ "Downloading a sandbox file requires a message scope to resolve its turn.",
109
+ );
110
+ }
111
+ return trueFoundryExtras.get(aui).downloadSandboxFile({ turnId, path });
112
+ };
93
113
  };
94
114
 
95
115
  /** Returns a function to cancel the current run from any render context. */
package/src/index.ts CHANGED
@@ -49,6 +49,7 @@ export {
49
49
  useTrueFoundryRespondToToolResponse,
50
50
  useTrueFoundryResumeMcpAuth,
51
51
  useTrueFoundrySandboxId,
52
+ useTrueFoundryTurnId,
52
53
  useTrueFoundryDownloadSandboxFile,
53
54
  useTrueFoundryCancel,
54
55
  useTrueFoundryResetFromTurn,
@@ -10,6 +10,8 @@ export type TrueFoundryMessageCustomMetadata = {
10
10
  pendingMcpAuth?: true;
11
11
  mcpServers?: McpAuthRequiredEvent["mcpServers"];
12
12
  sandboxId?: string;
13
+ /** Turn that produced this message. Scopes artifact downloads to their own turn. */
14
+ turnId?: string;
13
15
  [TOOL_APPROVAL_THREAD_ID_CUSTOM_KEY]?: string;
14
16
  [TOOL_RESPONSE_THREAD_ID_CUSTOM_KEY]?: string;
15
17
  };
@@ -227,7 +227,7 @@ export function createTrueFoundryChatServer<
227
227
  pageToken: req?.pageToken,
228
228
  startTimestamp: req?.startTimestamp,
229
229
  endTimestamp: req?.endTimestamp,
230
- ...(req?.agentName != null ? { agentName: req.agentName } : {}),
230
+ ...(req?.agentId != null ? { agentName: req.agentId } : {}),
231
231
  });
232
232
  const result = await toListResult(page, (s) => toSession<TSpec>(s));
233
233
  for (const session of result.data) {
@@ -350,11 +350,12 @@ export function createTrueFoundryChatServer<
350
350
  ) as AsyncIterable<TurnStreamData>;
351
351
  },
352
352
 
353
- async downloadSandboxFile(sandboxId, req) {
354
- const response = await privateClient.downloadSandboxFile(
355
- sandboxId,
356
- req,
357
- );
353
+ // The gateway addresses sandboxes directly, so the turn is carried for hosts that need it
354
+ // but is not part of this route.
355
+ async downloadSandboxFile({ sandboxId, path }) {
356
+ const response = await privateClient.downloadSandboxFile(sandboxId, {
357
+ path,
358
+ });
358
359
  return await response.blob();
359
360
  },
360
361
 
@@ -1,6 +1,7 @@
1
1
  import { afterEach, describe, expect, it, vi } from "vitest";
2
2
 
3
3
  import {
4
+ agentSpecFromCpManifest,
4
5
  buildSaveAgentManifest,
5
6
  normalizeAgents,
6
7
  normalizeAgentSkills,
@@ -10,6 +11,7 @@ import {
10
11
  saveAgent,
11
12
  SAVE_AGENT_COLLABORATORS,
12
13
  SAVE_AGENT_METADATA_TAGS,
14
+ toCamelCaseDeep,
13
15
  toSnakeCaseDeep,
14
16
  } from "./cp.js";
15
17
 
@@ -273,16 +275,175 @@ describe("normalizeMcpServers", () => {
273
275
  });
274
276
 
275
277
  describe("normalizeAgents", () => {
276
- it("maps name only", () => {
278
+ it("maps name, agentId, and agentSpec from latestVersionDetails.manifest", () => {
277
279
  expect(
278
280
  normalizeAgents({
279
281
  data: [
280
- { name: "ask-ai-agent", id: "ag_1" },
282
+ {
283
+ id: "ag_1",
284
+ name: "ask-ai-agent",
285
+ latestVersionDetails: {
286
+ manifest: {
287
+ type: "truefoundry-agent",
288
+ name: "ask-ai-agent",
289
+ model: {
290
+ name: "openai-main/gpt-4.1",
291
+ params: {
292
+ max_tokens: 8192,
293
+ reasoning_effort: "medium",
294
+ },
295
+ },
296
+ instructions: "Be helpful",
297
+ config: {
298
+ iteration_limit: 50,
299
+ ask_user_questions: { enabled: true },
300
+ },
301
+ skills: [
302
+ {
303
+ type: "truefoundry-skills-registry",
304
+ fqn: "agent-skill:tfy/skills/web:1",
305
+ preload: true,
306
+ config: { timeout_ms: 1000 },
307
+ },
308
+ ],
309
+ mcp_servers: [
310
+ {
311
+ name: "gmail",
312
+ enable_tools: ["@read-only"],
313
+ preload: true,
314
+ config: { locale: "en" },
315
+ },
316
+ ],
317
+ },
318
+ },
319
+ },
281
320
  { name: "" },
282
321
  {},
322
+ {
323
+ name: "try-only",
324
+ // no manifest → Try still works; Edit hidden
325
+ },
283
326
  ],
284
327
  }),
285
- ).toEqual([{ name: "ask-ai-agent" }]);
328
+ ).toEqual([
329
+ {
330
+ name: "ask-ai-agent",
331
+ agentId: "ag_1",
332
+ agentSpec: {
333
+ model: {
334
+ name: "openai-main/gpt-4.1",
335
+ params: { maxTokens: 8192, reasoningEffort: "medium" },
336
+ },
337
+ instructions: "Be helpful",
338
+ config: {
339
+ iterationLimit: 50,
340
+ askUserQuestions: { enabled: true },
341
+ },
342
+ skills: [
343
+ {
344
+ id: "agent-skill:tfy/skills/web:1",
345
+ name: "agent-skill:tfy/skills/web:1",
346
+ preload: true,
347
+ config: { timeoutMs: 1000 },
348
+ },
349
+ ],
350
+ mcpServers: [
351
+ {
352
+ id: "gmail",
353
+ name: "gmail",
354
+ enableTools: ["@read-only"],
355
+ preload: true,
356
+ config: { locale: "en" },
357
+ },
358
+ ],
359
+ },
360
+ },
361
+ { name: "try-only", agentId: "try-only" },
362
+ ]);
363
+ });
364
+ });
365
+
366
+ describe("toCamelCaseDeep", () => {
367
+ it("converts nested snake_case keys", () => {
368
+ expect(
369
+ toCamelCaseDeep({
370
+ max_tokens: 8192,
371
+ reasoning_effort: "medium",
372
+ nested: { iteration_limit: 50 },
373
+ }),
374
+ ).toEqual({
375
+ maxTokens: 8192,
376
+ reasoningEffort: "medium",
377
+ nested: { iterationLimit: 50 },
378
+ });
379
+ });
380
+ });
381
+
382
+ describe("agentSpecFromCpManifest", () => {
383
+ it("returns undefined when model.name is missing", () => {
384
+ expect(agentSpecFromCpManifest({ type: "truefoundry-agent" })).toBeUndefined();
385
+ expect(agentSpecFromCpManifest(null)).toBeUndefined();
386
+ });
387
+
388
+ it("preserves mount runtime fields and agent config for Edit → Save", () => {
389
+ const spec = agentSpecFromCpManifest({
390
+ type: "truefoundry-agent",
391
+ name: "saved",
392
+ model: { name: "openai-main/gpt-4.1" },
393
+ config: { iteration_limit: 25 },
394
+ skills: [
395
+ {
396
+ type: "truefoundry-skills-registry",
397
+ fqn: "agent-skill:tfy/skills/web:1",
398
+ preload: false,
399
+ },
400
+ ],
401
+ mcp_servers: [
402
+ {
403
+ name: "gmail",
404
+ enable_tools: ["@read-only"],
405
+ preload: true,
406
+ },
407
+ ],
408
+ });
409
+
410
+ expect(spec).toEqual({
411
+ model: { name: "openai-main/gpt-4.1" },
412
+ config: { iterationLimit: 25 },
413
+ skills: [
414
+ {
415
+ id: "agent-skill:tfy/skills/web:1",
416
+ name: "agent-skill:tfy/skills/web:1",
417
+ preload: false,
418
+ },
419
+ ],
420
+ mcpServers: [
421
+ {
422
+ id: "gmail",
423
+ name: "gmail",
424
+ enableTools: ["@read-only"],
425
+ preload: true,
426
+ },
427
+ ],
428
+ });
429
+
430
+ const saved = buildSaveAgentManifest("saved", spec!);
431
+ expect(saved.config).toEqual({ iteration_limit: 25 });
432
+ expect(saved.mcp_servers).toEqual([
433
+ {
434
+ type: "truefoundry-mcp-registry",
435
+ name: "gmail",
436
+ enable_tools: ["@read-only"],
437
+ preload: true,
438
+ },
439
+ ]);
440
+ expect(saved.skills).toEqual([
441
+ {
442
+ type: "truefoundry-skills-registry",
443
+ fqn: "agent-skill:tfy/skills/web:1",
444
+ preload: false,
445
+ },
446
+ ]);
286
447
  });
287
448
  });
288
449
 
@@ -432,9 +593,11 @@ describe("saveAgent", () => {
432
593
  }),
433
594
  }),
434
595
  );
435
- const body = JSON.parse(
436
- (fetchMock.mock.calls[0]?.[1] as RequestInit).body as string,
437
- );
596
+ const [, init] = fetchMock.mock.calls[0] as unknown as [
597
+ string,
598
+ RequestInit,
599
+ ];
600
+ const body = JSON.parse(String(init.body));
438
601
  expect(body.manifest.type).toBe("truefoundry-agent");
439
602
  expect(body.manifest.name).toBe("my-agent");
440
603
  expect(body.manifest.model).toEqual({ name: "openai-main/gpt-4.1" });