@truefoundry/assistant-ui-runtime 0.1.10-rc.1 → 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.
@@ -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-CgZUnILu.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-BRPTG6VA.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-CgZUnILu.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-CgZUnILu.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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truefoundry/assistant-ui-runtime",
3
- "version": "0.1.10-rc.1",
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": "*",
@@ -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() {},
@@ -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) {
@@ -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" });
@@ -335,9 +335,126 @@ export async function listMcpServers(
335
335
  // ---------------------------------------------------------------------------
336
336
 
337
337
  type RawAgent = {
338
+ id?: string;
338
339
  name?: string;
340
+ latestVersionDetails?: {
341
+ manifest?: unknown;
342
+ };
343
+ /** Defensive: some payloads nest manifest at the top level. */
344
+ manifest?: unknown;
339
345
  };
340
346
 
347
+ function isRecord(value: unknown): value is Record<string, unknown> {
348
+ return value != null && typeof value === "object" && !Array.isArray(value);
349
+ }
350
+
351
+ function snakeToCamelKey(key: string): string {
352
+ return key.replace(/_([a-z])/g, (_, letter: string) => letter.toUpperCase());
353
+ }
354
+
355
+ /** Deep snake_case → camelCase (inverse of {@link toSnakeCaseDeep}). */
356
+ export function toCamelCaseDeep(value: unknown): unknown {
357
+ if (Array.isArray(value)) {
358
+ return value.map(toCamelCaseDeep);
359
+ }
360
+ if (isRecord(value)) {
361
+ const out: Record<string, unknown> = {};
362
+ for (const [k, v] of Object.entries(value)) {
363
+ out[snakeToCamelKey(k)] = toCamelCaseDeep(v);
364
+ }
365
+ return out;
366
+ }
367
+ return value;
368
+ }
369
+
370
+ /**
371
+ * Map a CP AgentManifest (snake_case wire) → FE AgentSpec for Edit seeding.
372
+ * Skills/MCP keep `{ id, name }` for draft pickers plus runtime fields
373
+ * (`enableTools`, `preload`, `config`) so Edit → Save does not widen tool
374
+ * access or wipe settings. Gateway registry shapes are restored on save via
375
+ * normalizeAgentSpecForGateway.
376
+ */
377
+ export function agentSpecFromCpManifest(manifest: unknown): TfyAgentSpec | undefined {
378
+ if (!isRecord(manifest)) return undefined;
379
+ const modelRaw = manifest.model;
380
+ if (!isRecord(modelRaw) || typeof modelRaw.name !== "string" || modelRaw.name === "") {
381
+ return undefined;
382
+ }
383
+ const model = toCamelCaseDeep(modelRaw) as TfyAgentSpec["model"];
384
+
385
+ // Catalog-shaped mounts (`id`/`name` + runtime fields). Not yet gateway
386
+ // registry unions — normalizeAgentSpecForGateway restores those on save.
387
+ const skills: Array<Record<string, unknown>> = [];
388
+ if (Array.isArray(manifest.skills)) {
389
+ for (const row of manifest.skills) {
390
+ if (!isRecord(row)) continue;
391
+ const fqn =
392
+ typeof row.fqn === "string" && row.fqn !== ""
393
+ ? row.fqn
394
+ : typeof row.id === "string" && row.id !== ""
395
+ ? row.id
396
+ : null;
397
+ if (fqn == null) continue;
398
+ const camel = toCamelCaseDeep(row) as Record<string, unknown>;
399
+ const name =
400
+ typeof camel.name === "string" && camel.name !== ""
401
+ ? camel.name
402
+ : fqn;
403
+ skills.push({
404
+ id: fqn,
405
+ name,
406
+ ...(typeof camel.preload === "boolean"
407
+ ? { preload: camel.preload }
408
+ : {}),
409
+ ...(camel.config != null ? { config: camel.config } : {}),
410
+ });
411
+ }
412
+ }
413
+
414
+ const mcpServers: Array<Record<string, unknown>> = [];
415
+ const mcpRaw = Array.isArray(manifest.mcp_servers)
416
+ ? manifest.mcp_servers
417
+ : Array.isArray(manifest.mcpServers)
418
+ ? manifest.mcpServers
419
+ : [];
420
+ for (const row of mcpRaw) {
421
+ if (!isRecord(row)) continue;
422
+ const camel = toCamelCaseDeep(row) as Record<string, unknown>;
423
+ const name =
424
+ typeof camel.name === "string" && camel.name !== ""
425
+ ? camel.name
426
+ : typeof camel.id === "string" && camel.id !== ""
427
+ ? camel.id
428
+ : null;
429
+ if (name == null) continue;
430
+ mcpServers.push({
431
+ id: name,
432
+ name,
433
+ ...(Array.isArray(camel.enableTools)
434
+ ? { enableTools: camel.enableTools }
435
+ : {}),
436
+ ...(typeof camel.preload === "boolean"
437
+ ? { preload: camel.preload }
438
+ : {}),
439
+ ...(camel.config != null ? { config: camel.config } : {}),
440
+ });
441
+ }
442
+
443
+ const configRaw = manifest.config;
444
+ const config =
445
+ configRaw != null ? (toCamelCaseDeep(configRaw) as TfyAgentSpec["config"]) : undefined;
446
+
447
+ return {
448
+ model,
449
+ ...(typeof manifest.instructions === "string"
450
+ ? { instructions: manifest.instructions }
451
+ : {}),
452
+ ...(config != null ? { config } : {}),
453
+ ...(skills.length > 0 ? { skills } : {}),
454
+ ...(mcpServers.length > 0 ? { mcpServers } : {}),
455
+ } as TfyAgentSpec;
456
+ }
457
+
341
458
  export function normalizeAgents(raw: unknown): TfyAgentSelectorEntry[] {
342
459
  const data =
343
460
  raw != null &&
@@ -348,7 +465,15 @@ export function normalizeAgents(raw: unknown): TfyAgentSelectorEntry[] {
348
465
  const out: TfyAgentSelectorEntry[] = [];
349
466
  for (const row of data) {
350
467
  if (row.name == null || row.name === "") continue;
351
- out.push({ name: row.name });
468
+ const manifest = row.latestVersionDetails?.manifest ?? row.manifest;
469
+ const agentSpec = agentSpecFromCpManifest(manifest);
470
+ const agentId =
471
+ typeof row.id === "string" && row.id !== "" ? row.id : row.name;
472
+ out.push({
473
+ name: row.name,
474
+ agentId,
475
+ ...(agentSpec != null ? { agentSpec } : {}),
476
+ });
352
477
  }
353
478
  return out;
354
479
  }
@@ -29,6 +29,24 @@ describe("normalizeMcpMount", () => {
29
29
  name: "deepwiki-mcp",
30
30
  });
31
31
  });
32
+
33
+ it("forwards enableTools / preload / config from catalog rows", () => {
34
+ expect(
35
+ normalizeMcpMount({
36
+ id: "gmail",
37
+ name: "gmail",
38
+ enableTools: ["@read-only"],
39
+ preload: true,
40
+ config: { locale: "en" },
41
+ }),
42
+ ).toEqual({
43
+ type: "truefoundry-mcp-registry",
44
+ name: "gmail",
45
+ enableTools: ["@read-only"],
46
+ preload: true,
47
+ config: { locale: "en" },
48
+ });
49
+ });
32
50
  });
33
51
 
34
52
  describe("normalizeSkillMount", () => {
@@ -57,6 +75,22 @@ describe("normalizeSkillMount", () => {
57
75
  fqn: "agent-skill:truefoundry/skills/web:1",
58
76
  });
59
77
  });
78
+
79
+ it("forwards preload / config from catalog rows, including preload false", () => {
80
+ expect(
81
+ normalizeSkillMount({
82
+ id: "agent-skill:truefoundry/skills/web:1",
83
+ name: "web",
84
+ preload: false,
85
+ config: { timeoutMs: 1000 },
86
+ }),
87
+ ).toEqual({
88
+ type: "truefoundry-skills-registry",
89
+ fqn: "agent-skill:truefoundry/skills/web:1",
90
+ preload: false,
91
+ config: { timeoutMs: 1000 },
92
+ });
93
+ });
60
94
  });
61
95
 
62
96
  describe("normalizeAgentSpecForGateway", () => {