@truefoundry/assistant-ui-runtime 0.1.17 → 0.1.18
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.
- package/CHANGELOG.md +13 -0
- package/README.md +4 -2
- package/dist/{chunk-S4SH2KT2.js → chunk-N6LJ3LR2.js} +160 -21
- package/dist/chunk-N6LJ3LR2.js.map +1 -0
- package/dist/index.d.ts +26 -5
- package/dist/index.js +147 -58
- package/dist/index.js.map +1 -1
- package/dist/plugins/truefoundry-agent-server-adapter/index.d.ts +6 -2
- package/dist/plugins/truefoundry-agent-server-adapter/index.js +1 -1
- package/dist/server/index.d.ts +2 -2
- package/dist/{types-CctJojlz.d.ts → types-BRrQpoVy.d.ts} +1 -0
- package/package.json +2 -2
- package/src/hooks.ts +19 -19
- package/src/index.ts +5 -1
- package/src/plugins/truefoundry-agent-server-adapter/README.md +1 -1
- package/src/plugins/truefoundry-agent-server-adapter/cp.test.ts +3 -3
- package/src/plugins/truefoundry-agent-server-adapter/cp.ts +51 -10
- package/src/plugins/truefoundry-agent-server-adapter/modelReasoningEffort.test.ts +171 -0
- package/src/plugins/truefoundry-agent-server-adapter/modelReasoningEffort.ts +145 -0
- package/src/plugins/truefoundry-agent-server-adapter/normalizeAgentSpec.test.ts +85 -4
- package/src/plugins/truefoundry-agent-server-adapter/normalizeAgentSpec.ts +67 -17
- package/src/server/types.ts +1 -0
- package/src/truefoundryExtras.test.ts +161 -0
- package/src/truefoundryExtras.ts +136 -1
- package/dist/chunk-S4SH2KT2.js.map +0 -1
|
@@ -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("
|
|
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("
|
|
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("
|
|
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
|
-
{
|
|
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
|
-
|
|
23
|
-
|
|
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
|
-
(
|
|
27
|
-
(
|
|
28
|
-
|
|
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
|
-
...(
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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
|
-
|
|
55
|
-
|
|
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
|
-
...(
|
|
66
|
-
...(raw.config != null ? { config: raw.config } : {}),
|
|
116
|
+
...skillOptionalFields(raw),
|
|
67
117
|
} as unknown as TruefoundryGatewayApi.SkillMount;
|
|
68
118
|
}
|
|
69
119
|
|
package/src/server/types.ts
CHANGED
|
@@ -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
|
+
});
|