@truefoundry/assistant-ui-runtime 0.1.17 → 0.1.19

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,145 @@
1
+ /**
2
+ * CP provider-account metadata → ModelSelectorEntry.properties.reasoningEfforts.
3
+ *
4
+ * Source: GET /api/svc/v1/provider-accounts/providers
5
+ * Join key: `${providerSlug}/${modelId}` (NOT model_fqn).
6
+ * Host (ai.tf) uses the same rules for the draft reasoning-effort picker.
7
+ */
8
+
9
+ export type ModelParamDef = {
10
+ key: string;
11
+ type?: string;
12
+ defaultValue?: string | number | boolean | null;
13
+ maxValue?: number;
14
+ minValue?: number;
15
+ /** Legacy — prefer `supportedValues` from the real API. */
16
+ options?: string[];
17
+ supportedValues?: string[];
18
+ };
19
+
20
+ export type ModelMetadata = {
21
+ thinking?: boolean;
22
+ removeParams?: string[];
23
+ params?: ModelParamDef[];
24
+ features?: string[];
25
+ limits?: {
26
+ context_window?: number;
27
+ max_output_tokens?: number;
28
+ max_tokens?: number;
29
+ };
30
+ /** Provider-wide defaults; reasoning_effort often lives only here. */
31
+ defaultProviderParams?: {
32
+ params?: ModelParamDef[];
33
+ };
34
+ };
35
+
36
+ const DEFAULT_EFFORT_LEVELS: readonly string[] = [
37
+ "minimal",
38
+ "low",
39
+ "medium",
40
+ "high",
41
+ ] as const;
42
+
43
+ const EFFORT_SET = new Set([
44
+ "minimal",
45
+ "low",
46
+ "medium",
47
+ "high",
48
+ "xhigh",
49
+ "max",
50
+ ]);
51
+
52
+ /** Canonical metadata key: `<providerSlug>/<modelId>` (NOT model_fqn). */
53
+ export function metadataKey(providerSlug: string, modelId: string): string {
54
+ return `${providerSlug}/${modelId}`;
55
+ }
56
+
57
+ function providerSlugFromType(type: string | undefined): string | null {
58
+ if (type == null) return null;
59
+ const prefix = "provider-account/";
60
+ if (!type.startsWith(prefix)) return null;
61
+ const slug = type.slice(prefix.length).trim();
62
+ return slug.length > 0 ? slug : null;
63
+ }
64
+
65
+ function findReasoningEffortParam(
66
+ meta: ModelMetadata | undefined,
67
+ ): ModelParamDef | undefined {
68
+ const fromModel = meta?.params?.find((p) => p.key === "reasoning_effort");
69
+ if (fromModel != null) return fromModel;
70
+ return meta?.defaultProviderParams?.params?.find(
71
+ (p) => p.key === "reasoning_effort",
72
+ );
73
+ }
74
+
75
+ /**
76
+ * Show efforts when thinking is on, reasoning_effort is not removed, and the
77
+ * provider slug is not exact `"openai"` (azure-openai / openai-main still show).
78
+ */
79
+ export function showsReasoningEffort(
80
+ meta: ModelMetadata | undefined,
81
+ providerSlug: string,
82
+ ): boolean {
83
+ if (providerSlug === "openai") return false;
84
+ if (meta == null) return false;
85
+ if (meta.thinking !== true) return false;
86
+ if (meta.removeParams?.includes("reasoning_effort")) return false;
87
+ return true;
88
+ }
89
+
90
+ export function getEffortOptions(meta: ModelMetadata | undefined): string[] {
91
+ const param = findReasoningEffortParam(meta);
92
+ const raw = param?.supportedValues ?? param?.options;
93
+ if (raw == null || raw.length === 0) return [...DEFAULT_EFFORT_LEVELS];
94
+ const filtered = raw.filter((v) => EFFORT_SET.has(v));
95
+ return filtered.length > 0 ? filtered : [...DEFAULT_EFFORT_LEVELS];
96
+ }
97
+
98
+ /** Options for the selector, or undefined when the picker should stay hidden. */
99
+ export function reasoningEffortsForModel(
100
+ meta: ModelMetadata | undefined,
101
+ providerSlug: string,
102
+ ): string[] | undefined {
103
+ if (!showsReasoningEffort(meta, providerSlug)) return undefined;
104
+ return getEffortOptions(meta);
105
+ }
106
+
107
+ type RawIntegration = {
108
+ type?: string;
109
+ metadata?: Record<string, ModelMetadata>;
110
+ };
111
+
112
+ type RawProvider = {
113
+ type?: string;
114
+ integrations?: RawIntegration[];
115
+ };
116
+
117
+ function providersFromJson(json: unknown): RawProvider[] {
118
+ if (Array.isArray(json)) return json as RawProvider[];
119
+ if (json != null && typeof json === "object") {
120
+ const obj = json as Record<string, unknown>;
121
+ for (const key of ["data", "providers", "result"] as const) {
122
+ const value = obj[key];
123
+ if (Array.isArray(value)) return value as RawProvider[];
124
+ }
125
+ }
126
+ return [];
127
+ }
128
+
129
+ /** Build a map keyed by `<providerSlug>/<modelId>` from the providers API. */
130
+ export function buildMetadataMap(raw: unknown): Map<string, ModelMetadata> {
131
+ const map = new Map<string, ModelMetadata>();
132
+ for (const provider of providersFromJson(raw)) {
133
+ const slug = providerSlugFromType(provider.type);
134
+ if (slug == null) continue;
135
+ for (const integration of provider.integrations ?? []) {
136
+ const metadata = integration.metadata;
137
+ if (metadata == null) continue;
138
+ for (const [modelId, meta] of Object.entries(metadata)) {
139
+ if (!modelId.trim()) continue;
140
+ map.set(metadataKey(slug, modelId), meta);
141
+ }
142
+ }
143
+ }
144
+ return map;
145
+ }
@@ -7,11 +7,13 @@ import {
7
7
  } from "./normalizeAgentSpec.js";
8
8
 
9
9
  describe("normalizeMcpMount", () => {
10
- it("passes through registry mounts", () => {
10
+ it("rebuilds registry mounts and strips FE id / url", () => {
11
11
  expect(
12
12
  normalizeMcpMount({
13
13
  type: "truefoundry-mcp-registry",
14
14
  name: "g-calendar",
15
+ id: "fe-row-id",
16
+ url: "https://should-not-leak.example",
15
17
  enableTools: ["@all"],
16
18
  }),
17
19
  ).toEqual({
@@ -21,12 +23,27 @@ describe("normalizeMcpMount", () => {
21
23
  });
22
24
  });
23
25
 
24
- it("maps UI {id,name} catalog rows to registry mounts", () => {
26
+ it("defaults enableTools to [@all] when missing on typed registry mounts", () => {
27
+ expect(
28
+ normalizeMcpMount({
29
+ type: "truefoundry-mcp-registry",
30
+ name: "github",
31
+ id: "fe-row-id",
32
+ }),
33
+ ).toEqual({
34
+ type: "truefoundry-mcp-registry",
35
+ name: "github",
36
+ enableTools: ["@all"],
37
+ });
38
+ });
39
+
40
+ it("maps UI {id,name} catalog rows to registry mounts with enableTools [@all]", () => {
25
41
  expect(
26
42
  normalizeMcpMount({ id: "deepwiki-mcp", name: "deepwiki-mcp" }),
27
43
  ).toEqual({
28
44
  type: "truefoundry-mcp-registry",
29
45
  name: "deepwiki-mcp",
46
+ enableTools: ["@all"],
30
47
  });
31
48
  });
32
49
 
@@ -47,14 +64,53 @@ describe("normalizeMcpMount", () => {
47
64
  config: { locale: "en" },
48
65
  });
49
66
  });
67
+
68
+ it("forwards optional tool selectors without widening enableTools", () => {
69
+ expect(
70
+ normalizeMcpMount({
71
+ type: "truefoundry-mcp-registry",
72
+ name: "github",
73
+ enableTools: ["list_issues"],
74
+ disableTools: ["delete_repo"],
75
+ preloadTools: ["@read-only"],
76
+ requireApprovalForTools: ["@destructive"],
77
+ }),
78
+ ).toEqual({
79
+ type: "truefoundry-mcp-registry",
80
+ name: "github",
81
+ enableTools: ["list_issues"],
82
+ disableTools: ["delete_repo"],
83
+ preloadTools: ["@read-only"],
84
+ requireApprovalForTools: ["@destructive"],
85
+ });
86
+ });
87
+
88
+ it("rebuilds inline mounts without FE id", () => {
89
+ expect(
90
+ normalizeMcpMount({
91
+ type: "inline",
92
+ name: "custom",
93
+ url: "https://example.com/mcp",
94
+ id: "fe-row-id",
95
+ enableTools: ["@all"],
96
+ }),
97
+ ).toEqual({
98
+ type: "inline",
99
+ name: "custom",
100
+ url: "https://example.com/mcp",
101
+ enableTools: ["@all"],
102
+ });
103
+ });
50
104
  });
51
105
 
52
106
  describe("normalizeSkillMount", () => {
53
- it("passes through registry mounts", () => {
107
+ it("rebuilds registry mounts and strips FE id / display name", () => {
54
108
  expect(
55
109
  normalizeSkillMount({
56
110
  type: "truefoundry-skills-registry",
57
111
  fqn: "agent-skill:truefoundry/skills/web:1",
112
+ id: "fe-row-id",
113
+ name: "Web Search",
58
114
  preload: false,
59
115
  }),
60
116
  ).toEqual({
@@ -91,6 +147,27 @@ describe("normalizeSkillMount", () => {
91
147
  config: { timeoutMs: 1000 },
92
148
  });
93
149
  });
150
+
151
+ it("rebuilds git mounts without FE id", () => {
152
+ expect(
153
+ normalizeSkillMount({
154
+ type: "git",
155
+ url: "https://github.com/acme/skills",
156
+ name: "reviewer",
157
+ ref: "main",
158
+ path: "skills/reviewer",
159
+ id: "fe-row-id",
160
+ preload: true,
161
+ }),
162
+ ).toEqual({
163
+ type: "git",
164
+ url: "https://github.com/acme/skills",
165
+ name: "reviewer",
166
+ ref: "main",
167
+ path: "skills/reviewer",
168
+ preload: true,
169
+ });
170
+ });
94
171
  });
95
172
 
96
173
  describe("normalizeAgentSpecForGateway", () => {
@@ -107,7 +184,11 @@ describe("normalizeAgentSpecForGateway", () => {
107
184
  });
108
185
 
109
186
  expect(next.mcpServers).toEqual([
110
- { type: "truefoundry-mcp-registry", name: "gmail" },
187
+ {
188
+ type: "truefoundry-mcp-registry",
189
+ name: "gmail",
190
+ enableTools: ["@all"],
191
+ },
111
192
  ]);
112
193
  expect(next.skills).toEqual([
113
194
  {
@@ -6,12 +6,39 @@ function isRecord(value: unknown): value is Record<string, unknown> {
6
6
  return value != null && typeof value === "object" && !Array.isArray(value);
7
7
  }
8
8
 
9
+ function nonEmptyString(value: unknown): string | null {
10
+ return typeof value === "string" && value !== "" ? value : null;
11
+ }
12
+
13
+ /** Optional BaseMcpServer fields shared by registry + inline mounts. */
14
+ function mcpOptionalFields(raw: Record<string, unknown>): Record<string, unknown> {
15
+ return {
16
+ ...(Array.isArray(raw.enableTools) ? { enableTools: raw.enableTools } : {}),
17
+ ...(Array.isArray(raw.disableTools) ? { disableTools: raw.disableTools } : {}),
18
+ ...(Array.isArray(raw.preloadTools) ? { preloadTools: raw.preloadTools } : {}),
19
+ ...(Array.isArray(raw.requireApprovalForTools)
20
+ ? { requireApprovalForTools: raw.requireApprovalForTools }
21
+ : {}),
22
+ ...(typeof raw.preload === "boolean" ? { preload: raw.preload } : {}),
23
+ ...(raw.config != null ? { config: raw.config } : {}),
24
+ };
25
+ }
26
+
27
+ function skillOptionalFields(raw: Record<string, unknown>): Record<string, unknown> {
28
+ return {
29
+ ...(typeof raw.preload === "boolean" ? { preload: raw.preload } : {}),
30
+ ...(raw.config != null ? { config: raw.config } : {}),
31
+ };
32
+ }
33
+
9
34
  /**
10
35
  * agent-ui-sdk DraftCompositeSelector writes catalog rows as `{ id, name }`.
11
36
  * Gateway AgentSpec mounts need discriminated registry shapes — without
12
37
  * `type` (and skill `fqn`), Fern serialization throws JsonError before PATCH.
13
38
  *
14
39
  * Our CP catalog sets skill `id` = version fqn and MCP `id` = server name.
40
+ * Always rebuild allowlisted fields so FE `id` / display `name` / registry
41
+ * `url` never leak onto the wire.
15
42
  */
16
43
  export function normalizeMcpMount(
17
44
  raw: unknown,
@@ -19,26 +46,37 @@ export function normalizeMcpMount(
19
46
  if (!isRecord(raw)) {
20
47
  throw new Error("mcpServers entry must be an object");
21
48
  }
22
- if (raw.type === "truefoundry-mcp-registry" || raw.type === "inline") {
23
- return raw as unknown as TruefoundryGatewayApi.McpServer;
49
+
50
+ if (raw.type === "inline") {
51
+ const name = nonEmptyString(raw.name);
52
+ const url = nonEmptyString(raw.url);
53
+ if (name == null || url == null) {
54
+ throw new Error("inline mcpServers entry needs name and url");
55
+ }
56
+ return {
57
+ type: "inline",
58
+ name,
59
+ url,
60
+ ...mcpOptionalFields(raw),
61
+ } as unknown as TruefoundryGatewayApi.McpServer;
24
62
  }
63
+
25
64
  const name =
26
- (typeof raw.name === "string" && raw.name !== "" ? raw.name : null) ??
27
- (typeof raw.mcpName === "string" && raw.mcpName !== ""
28
- ? raw.mcpName
29
- : null) ??
30
- (typeof raw.id === "string" && raw.id !== "" ? raw.id : null);
65
+ nonEmptyString(raw.name) ??
66
+ nonEmptyString(raw.mcpName) ??
67
+ nonEmptyString(raw.id);
31
68
  if (name == null) {
32
69
  throw new Error(
33
70
  "mcpServers entry needs name, mcpName, or id to mount as registry MCP",
34
71
  );
35
72
  }
73
+
36
74
  return {
37
75
  type: "truefoundry-mcp-registry",
38
76
  name,
39
- ...(Array.isArray(raw.enableTools) ? { enableTools: raw.enableTools } : {}),
40
- ...(typeof raw.preload === "boolean" ? { preload: raw.preload } : {}),
41
- ...(raw.config != null ? { config: raw.config } : {}),
77
+ ...mcpOptionalFields(raw),
78
+ // Always set after the spread so missing enableTools defaults to [@all].
79
+ enableTools: Array.isArray(raw.enableTools) ? raw.enableTools : ["@all"],
42
80
  } as unknown as TruefoundryGatewayApi.McpServer;
43
81
  }
44
82
 
@@ -48,12 +86,25 @@ export function normalizeSkillMount(
48
86
  if (!isRecord(raw)) {
49
87
  throw new Error("skills entry must be an object");
50
88
  }
51
- if (raw.type === "truefoundry-skills-registry" || raw.type === "git") {
52
- return raw as unknown as TruefoundryGatewayApi.SkillMount;
89
+
90
+ if (raw.type === "git") {
91
+ const url = nonEmptyString(raw.url);
92
+ const name = nonEmptyString(raw.name);
93
+ const ref = nonEmptyString(raw.ref);
94
+ if (url == null || name == null || ref == null) {
95
+ throw new Error("git skills entry needs url, name, and ref");
96
+ }
97
+ return {
98
+ type: "git",
99
+ url,
100
+ name,
101
+ ref,
102
+ ...(nonEmptyString(raw.path) != null ? { path: raw.path } : {}),
103
+ ...skillOptionalFields(raw),
104
+ } as unknown as TruefoundryGatewayApi.SkillMount;
53
105
  }
54
- const fqn =
55
- (typeof raw.fqn === "string" && raw.fqn !== "" ? raw.fqn : null) ??
56
- (typeof raw.id === "string" && raw.id !== "" ? raw.id : null);
106
+
107
+ const fqn = nonEmptyString(raw.fqn) ?? nonEmptyString(raw.id);
57
108
  if (fqn == null) {
58
109
  throw new Error(
59
110
  "skills entry needs fqn or id to mount as registry skill",
@@ -62,8 +113,7 @@ export function normalizeSkillMount(
62
113
  return {
63
114
  type: "truefoundry-skills-registry",
64
115
  fqn,
65
- ...(typeof raw.preload === "boolean" ? { preload: raw.preload } : {}),
66
- ...(raw.config != null ? { config: raw.config } : {}),
116
+ ...skillOptionalFields(raw),
67
117
  } as unknown as TruefoundryGatewayApi.SkillMount;
68
118
  }
69
119
 
@@ -523,6 +523,7 @@ export interface ConnectorConfigBase<
523
523
  TAuth extends ConnectorAuth = ConnectorAuth,
524
524
  > {
525
525
  name: string;
526
+ description: string;
526
527
  url: string;
527
528
  auth: TAuth;
528
529
  }
@@ -0,0 +1,161 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import type { AssistantClient } from "@assistant-ui/store";
3
+
4
+ import {
5
+ getTrueFoundryExtras,
6
+ trueFoundryExtras,
7
+ tryGetTrueFoundryExtras,
8
+ type TrueFoundryRuntimeExtras,
9
+ } from "./truefoundryExtras.js";
10
+
11
+ function clientWithExtras(
12
+ extras: unknown,
13
+ ): AssistantClient {
14
+ return {
15
+ thread: () => ({
16
+ getState: () => ({ extras }),
17
+ }),
18
+ } as unknown as AssistantClient;
19
+ }
20
+
21
+ describe("tryGetTrueFoundryExtras", () => {
22
+ it("reads extras from the current client", () => {
23
+ const extras = trueFoundryExtras.provide({
24
+ pendingApprovals: [],
25
+ pendingToolResponses: [],
26
+ pendingMcpAuth: null,
27
+ resumeUnavailable: false,
28
+ sandboxId: undefined,
29
+ respondToToolApproval: vi.fn(),
30
+ respondToToolResponse: vi.fn(),
31
+ resumeMcpAuth: vi.fn(),
32
+ downloadSandboxFile: vi.fn(),
33
+ cancel: vi.fn(),
34
+ resetFromTurn: vi.fn(),
35
+ reload: vi.fn(),
36
+ hasOlderHistory: false,
37
+ isLoadingOlderHistory: false,
38
+ loadOlderHistory: vi.fn(),
39
+ draft: null,
40
+ } satisfies TrueFoundryRuntimeExtras);
41
+
42
+ expect(tryGetTrueFoundryExtras(clientWithExtras(extras))).toBe(extras);
43
+ });
44
+
45
+ it("walks nested readonly clients (Object.create parent) to find root extras", () => {
46
+ const respondToToolApproval = vi.fn();
47
+ const extras = trueFoundryExtras.provide({
48
+ pendingApprovals: [{ approvalId: "a1", threadId: "child-1", toolName: "bash", args: {}, argsText: "{}" }],
49
+ pendingToolResponses: [],
50
+ pendingMcpAuth: null,
51
+ resumeUnavailable: false,
52
+ sandboxId: undefined,
53
+ respondToToolApproval,
54
+ respondToToolResponse: vi.fn(),
55
+ resumeMcpAuth: vi.fn(),
56
+ downloadSandboxFile: vi.fn(),
57
+ cancel: vi.fn(),
58
+ resetFromTurn: vi.fn(),
59
+ reload: vi.fn(),
60
+ hasOlderHistory: false,
61
+ isLoadingOlderHistory: false,
62
+ loadOlderHistory: vi.fn(),
63
+ draft: null,
64
+ } satisfies TrueFoundryRuntimeExtras);
65
+
66
+ const root = clientWithExtras(extras);
67
+ // Mirrors ReadonlyThreadProvider: nested AUI is Object.create(parent) with
68
+ // thread overwritten to a readonly client that has no extras.
69
+ const nested = Object.assign(Object.create(root), {
70
+ thread: () => ({
71
+ getState: () => ({ extras: undefined }),
72
+ }),
73
+ }) as AssistantClient;
74
+
75
+ expect(tryGetTrueFoundryExtras(nested)).toBe(extras);
76
+ // Namespace .get must walk too (not only the named helper).
77
+ expect(trueFoundryExtras.get(nested)).toBe(extras);
78
+ getTrueFoundryExtras(nested).respondToToolApproval({
79
+ approvalId: "a1",
80
+ approved: true,
81
+ });
82
+ expect(respondToToolApproval).toHaveBeenCalledWith({
83
+ approvalId: "a1",
84
+ approved: true,
85
+ });
86
+ });
87
+
88
+ it("returns undefined when no ancestor has TrueFoundry extras", () => {
89
+ expect(tryGetTrueFoundryExtras(clientWithExtras(undefined))).toBeUndefined();
90
+ });
91
+
92
+ it("survives RootAssistantClient-style proxies that throw on missing accessors", () => {
93
+ const extras = trueFoundryExtras.provide({
94
+ pendingApprovals: [],
95
+ pendingToolResponses: [],
96
+ pendingMcpAuth: null,
97
+ resumeUnavailable: false,
98
+ sandboxId: undefined,
99
+ respondToToolApproval: vi.fn(),
100
+ respondToToolResponse: vi.fn(),
101
+ resumeMcpAuth: vi.fn(),
102
+ downloadSandboxFile: vi.fn(),
103
+ cancel: vi.fn(),
104
+ resetFromTurn: vi.fn(),
105
+ reload: vi.fn(),
106
+ hasOlderHistory: false,
107
+ isLoadingOlderHistory: false,
108
+ loadOlderHistory: vi.fn(),
109
+ draft: null,
110
+ } satisfies TrueFoundryRuntimeExtras);
111
+
112
+ // Mirrors @assistant-ui/store createRootAssistantClient: empty proxy that
113
+ // throws on any get (e.g. "subscribe" / "thread").
114
+ const rootProto = new Proxy(
115
+ {},
116
+ {
117
+ get(_, prop) {
118
+ throw new Error(
119
+ `The current scope does not have a "${String(prop)}" property.`,
120
+ );
121
+ },
122
+ },
123
+ );
124
+ const root = Object.assign(Object.create(rootProto), {
125
+ subscribe: (cb: () => void) => {
126
+ cb();
127
+ return () => {};
128
+ },
129
+ thread: () => ({
130
+ getState: () => ({ extras }),
131
+ }),
132
+ }) as AssistantClient;
133
+
134
+ expect(tryGetTrueFoundryExtras(root)).toBe(extras);
135
+
136
+ // useTrueFoundryRuntimeExtras walks .subscribe up the same chain.
137
+ const nested = Object.assign(Object.create(root), {
138
+ thread: () => ({
139
+ getState: () => ({ extras: undefined }),
140
+ }),
141
+ }) as AssistantClient;
142
+ expect(tryGetTrueFoundryExtras(nested)).toBe(extras);
143
+ expect(() => {
144
+ let current: object | null = nested;
145
+ const seen = new Set<object>();
146
+ while (current != null && !seen.has(current)) {
147
+ seen.add(current);
148
+ try {
149
+ const subscribe = (current as { subscribe?: (cb: () => void) => () => void })
150
+ .subscribe;
151
+ if (typeof subscribe === "function") {
152
+ subscribe(() => {});
153
+ }
154
+ } catch {
155
+ break;
156
+ }
157
+ current = Object.getPrototypeOf(current);
158
+ }
159
+ }).not.toThrow();
160
+ });
161
+ });