@jskit-ai/assistant-core 0.1.142 → 0.1.144
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/fixtures/responsive-assistant/App.vue +106 -0
- package/fixtures/responsive-assistant/index.html +12 -0
- package/fixtures/responsive-assistant/main.js +24 -0
- package/fixtures/responsive-assistant/vite.config.mjs +18 -0
- package/package.json +5 -5
- package/src/client/components/AssistantClientElement.vue +30 -20
- package/src/server/lib/serviceToolCatalog.js +531 -80
- package/test/assistantScroll.browser.test.js +139 -0
- package/test/componentContracts.test.js +11 -0
- package/test/serviceToolCatalog.test.js +318 -9
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import test from "node:test";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { chromium, expect } from "@playwright/test";
|
|
7
|
+
import {
|
|
8
|
+
createChromiumLaunchOptions,
|
|
9
|
+
startViteFixture,
|
|
10
|
+
stopProcess
|
|
11
|
+
} from "../../../tooling/testUtils/browserFixture.mjs";
|
|
12
|
+
|
|
13
|
+
const TEST_DIRECTORY = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const FIXTURE_ROOT = path.resolve(TEST_DIRECTORY, "../fixtures/responsive-assistant");
|
|
15
|
+
const RUN_BROWSER_TEST = process.env.JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION === "1";
|
|
16
|
+
const VIEWPORTS = Object.freeze([
|
|
17
|
+
Object.freeze({ name: "phone", width: 390, height: 844, sidePanelsVisible: false }),
|
|
18
|
+
Object.freeze({ name: "compact", width: 800, height: 900, sidePanelsVisible: false }),
|
|
19
|
+
Object.freeze({ name: "medium", width: 1224, height: 900, sidePanelsVisible: true }),
|
|
20
|
+
Object.freeze({ name: "expanded", width: 1365, height: 900, sidePanelsVisible: true })
|
|
21
|
+
]);
|
|
22
|
+
|
|
23
|
+
async function readLayoutMetrics(page) {
|
|
24
|
+
return page.evaluate(() => {
|
|
25
|
+
const root = document.querySelector(".assistant-client-element");
|
|
26
|
+
const layout = document.querySelector(".assistant-layout");
|
|
27
|
+
const mainColumn = document.querySelector(".assistant-main-col");
|
|
28
|
+
const sideColumn = document.querySelector(".assistant-side-col");
|
|
29
|
+
const messages = document.querySelector(".messages-panel");
|
|
30
|
+
const composer = document.querySelector(".assistant-composer-shell");
|
|
31
|
+
const history = document.querySelector(".assistant-history-card");
|
|
32
|
+
const tools = document.querySelector(".assistant-tools-card");
|
|
33
|
+
const rect = (element) => element?.getBoundingClientRect() || null;
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
viewportHeight: window.innerHeight,
|
|
37
|
+
documentOverflow: document.documentElement.scrollWidth - document.documentElement.clientWidth,
|
|
38
|
+
root: rect(root),
|
|
39
|
+
layout: rect(layout),
|
|
40
|
+
mainColumn: rect(mainColumn),
|
|
41
|
+
sideColumn: rect(sideColumn),
|
|
42
|
+
messages: {
|
|
43
|
+
...rect(messages),
|
|
44
|
+
clientHeight: messages?.clientHeight || 0,
|
|
45
|
+
scrollHeight: messages?.scrollHeight || 0,
|
|
46
|
+
scrollTop: messages?.scrollTop || 0
|
|
47
|
+
},
|
|
48
|
+
composer: rect(composer),
|
|
49
|
+
sideColumnDisplay: sideColumn ? window.getComputedStyle(sideColumn).display : "missing",
|
|
50
|
+
historyDisplay: history ? window.getComputedStyle(history).display : "missing",
|
|
51
|
+
toolsDisplay: tools ? window.getComputedStyle(tools).display : "missing"
|
|
52
|
+
};
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function assertResponsiveAssistant(page, viewport) {
|
|
57
|
+
await page.setViewportSize({ width: viewport.width, height: viewport.height });
|
|
58
|
+
await page.goto("/", { waitUntil: "networkidle" });
|
|
59
|
+
|
|
60
|
+
const root = page.getByTestId("assistant-client-element");
|
|
61
|
+
const messages = page.getByTestId("assistant-messages-panel");
|
|
62
|
+
const compactConversationControl = page.getByRole("button", { name: "Conversations", exact: true });
|
|
63
|
+
await root.waitFor({ state: "visible" });
|
|
64
|
+
|
|
65
|
+
const metrics = await readLayoutMetrics(page);
|
|
66
|
+
assert.ok(
|
|
67
|
+
metrics.messages.scrollHeight > metrics.messages.clientHeight,
|
|
68
|
+
`${viewport.name}: messages did not become an internal scroll container.`
|
|
69
|
+
);
|
|
70
|
+
assert.ok(metrics.composer, `${viewport.name}: composer was not rendered.`);
|
|
71
|
+
assert.ok(
|
|
72
|
+
metrics.composer.bottom <= metrics.viewportHeight + 1,
|
|
73
|
+
`${viewport.name}: composer bottom ${metrics.composer.bottom}px exceeded the ${metrics.viewportHeight}px viewport.`
|
|
74
|
+
);
|
|
75
|
+
assert.ok(
|
|
76
|
+
metrics.root.bottom <= metrics.viewportHeight + 1,
|
|
77
|
+
`${viewport.name}: assistant root escaped the viewport.`
|
|
78
|
+
);
|
|
79
|
+
assert.ok(
|
|
80
|
+
metrics.mainColumn.bottom <= metrics.root.bottom + 1,
|
|
81
|
+
`${viewport.name}: main column escaped its bounded root.`
|
|
82
|
+
);
|
|
83
|
+
assert.ok(metrics.documentOverflow <= 1, `${viewport.name}: page overflowed horizontally.`);
|
|
84
|
+
|
|
85
|
+
if (viewport.sidePanelsVisible) {
|
|
86
|
+
assert.equal(metrics.sideColumnDisplay, "flex", `${viewport.name}: sidebar column was hidden.`);
|
|
87
|
+
assert.equal(metrics.historyDisplay, "flex", `${viewport.name}: conversation history was hidden.`);
|
|
88
|
+
assert.equal(metrics.toolsDisplay, "flex", `${viewport.name}: tool timeline was hidden.`);
|
|
89
|
+
await expect(compactConversationControl).toBeHidden();
|
|
90
|
+
} else {
|
|
91
|
+
assert.equal(metrics.sideColumnDisplay, "none", `${viewport.name}: sidebar column still occupied the layout.`);
|
|
92
|
+
await expect(compactConversationControl).toBeVisible();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await messages.evaluate((element) => {
|
|
96
|
+
element.scrollTop = 0;
|
|
97
|
+
element.dispatchEvent(new Event("scroll"));
|
|
98
|
+
});
|
|
99
|
+
await messages.hover();
|
|
100
|
+
await page.mouse.wheel(0, 420);
|
|
101
|
+
await expect.poll(() => messages.evaluate((element) => element.scrollTop)).toBeGreaterThan(0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
test("AssistantClientElement keeps long conversations scrollable across responsive layouts", {
|
|
105
|
+
skip: RUN_BROWSER_TEST
|
|
106
|
+
? false
|
|
107
|
+
: "set JSKIT_ASSISTANT_CORE_BROWSER_INTEGRATION=1 to run assistant-core browser integration",
|
|
108
|
+
timeout: 180_000
|
|
109
|
+
}, async () => {
|
|
110
|
+
const vite = await startViteFixture({ fixtureRoot: FIXTURE_ROOT });
|
|
111
|
+
let browser = null;
|
|
112
|
+
|
|
113
|
+
try {
|
|
114
|
+
browser = await chromium.launch(createChromiumLaunchOptions());
|
|
115
|
+
const context = await browser.newContext({
|
|
116
|
+
baseURL: vite.baseURL,
|
|
117
|
+
locale: "en-US"
|
|
118
|
+
});
|
|
119
|
+
const page = await context.newPage();
|
|
120
|
+
|
|
121
|
+
for (const viewport of VIEWPORTS) {
|
|
122
|
+
await assertResponsiveAssistant(page, viewport);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
await context.close();
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const fixtureOutput = vite.readOutput().trim();
|
|
128
|
+
if (!fixtureOutput) {
|
|
129
|
+
throw error;
|
|
130
|
+
}
|
|
131
|
+
throw new Error(
|
|
132
|
+
`${error.message}\n\nAssistant fixture output:\n${fixtureOutput}`,
|
|
133
|
+
{ cause: error }
|
|
134
|
+
);
|
|
135
|
+
} finally {
|
|
136
|
+
await browser?.close();
|
|
137
|
+
await stopProcess(vite);
|
|
138
|
+
}
|
|
139
|
+
});
|
|
@@ -13,3 +13,14 @@ test("AssistantClientElement unwraps viewer refs before reading avatar fields",
|
|
|
13
13
|
const source = await readPackageFile("src/client/components/AssistantClientElement.vue");
|
|
14
14
|
assert.match(source, /normalizeObject\(unref\(props\.viewer\)\)/);
|
|
15
15
|
});
|
|
16
|
+
|
|
17
|
+
test("AssistantClientElement keeps its responsive scroll ancestors height-bounded", async () => {
|
|
18
|
+
const source = await readPackageFile("src/client/components/AssistantClientElement.vue");
|
|
19
|
+
|
|
20
|
+
assert.match(source, /<v-row class="assistant-layout h-100 flex-grow-1 flex-nowrap my-0">/u);
|
|
21
|
+
assert.match(source, /<v-col cols="12" md="8" class="assistant-main-col/u);
|
|
22
|
+
assert.match(source, /<v-col cols="12" md="4" class="assistant-side-col d-none d-md-flex/u);
|
|
23
|
+
assert.match(source, /class="d-md-none"/u);
|
|
24
|
+
assert.match(source, /\.assistant-main-col,\s*\.assistant-side-col,[\s\S]*height: 100%;[\s\S]*max-height: 100%;/u);
|
|
25
|
+
assert.match(source, /\.messages-panel \{[\s\S]*flex: 1 1 auto;[\s\S]*min-height: 0;[\s\S]*overflow: auto;/u);
|
|
26
|
+
});
|
|
@@ -5,8 +5,8 @@ import { createSchema } from "json-rest-schema";
|
|
|
5
5
|
import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
|
|
6
6
|
import { createServiceToolCatalog } from "../src/server/lib/serviceToolCatalog.js";
|
|
7
7
|
|
|
8
|
-
function schema(fields = {}) {
|
|
9
|
-
return { schema: createSchema(fields), mode
|
|
8
|
+
function schema(fields = {}, mode = "patch") {
|
|
9
|
+
return { schema: createSchema(fields), mode };
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
function createActions(definitions = []) {
|
|
@@ -21,6 +21,8 @@ function createActions(definitions = []) {
|
|
|
21
21
|
|
|
22
22
|
function action({
|
|
23
23
|
id = "demo.books.list",
|
|
24
|
+
version = 1,
|
|
25
|
+
kind = "query",
|
|
24
26
|
channels = ["automation"],
|
|
25
27
|
surfaces = ["admin"],
|
|
26
28
|
permission = { require: "authenticated" },
|
|
@@ -31,8 +33,8 @@ function action({
|
|
|
31
33
|
} = {}) {
|
|
32
34
|
return {
|
|
33
35
|
id,
|
|
34
|
-
version
|
|
35
|
-
kind
|
|
36
|
+
version,
|
|
37
|
+
kind,
|
|
36
38
|
channels,
|
|
37
39
|
surfaces,
|
|
38
40
|
permission,
|
|
@@ -94,19 +96,94 @@ test("assistant tools honor barred ids, prefixes, schemas, and explicit descript
|
|
|
94
96
|
assert.equal(catalog.toOpenAiToolSchema(tool).function.name, tool.name);
|
|
95
97
|
});
|
|
96
98
|
|
|
97
|
-
test("assistant tools require
|
|
99
|
+
test("assistant tools require complete action or explicit assistant contracts", () => {
|
|
98
100
|
const actions = createActions([
|
|
99
101
|
action({ id: "demo.complete" }),
|
|
100
|
-
action({ id: "demo.no-output", output: null })
|
|
102
|
+
action({ id: "demo.no-output", output: null }),
|
|
103
|
+
action({
|
|
104
|
+
id: "demo.assistant-output",
|
|
105
|
+
output: null,
|
|
106
|
+
extensions: {
|
|
107
|
+
assistant: {
|
|
108
|
+
output: schema({ ok: { type: "boolean", required: true } }, "replace")
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
})
|
|
101
112
|
]);
|
|
102
113
|
const tools = createServiceToolCatalog(actions)
|
|
103
114
|
.resolveToolSet({ actor: { id: "7" }, surface: "admin" })
|
|
104
115
|
.tools;
|
|
105
116
|
|
|
106
|
-
assert.deepEqual(tools.map((entry) => entry.actionId), ["demo.complete"]);
|
|
117
|
+
assert.deepEqual(tools.map((entry) => entry.actionId), ["demo.assistant-output", "demo.complete"]);
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
test("assistant-specific result transforms are validated against their explicit contract", async () => {
|
|
121
|
+
const actions = createActions([action({
|
|
122
|
+
id: "demo.books.transformed",
|
|
123
|
+
output: null,
|
|
124
|
+
extensions: {
|
|
125
|
+
assistant: {
|
|
126
|
+
output: schema({
|
|
127
|
+
id: { type: "string", required: true },
|
|
128
|
+
title: { type: "string", required: true }
|
|
129
|
+
}, "replace"),
|
|
130
|
+
transformResult(result) {
|
|
131
|
+
return {
|
|
132
|
+
id: result.data.id,
|
|
133
|
+
title: result.data.attributes.title
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
},
|
|
138
|
+
execute: async () => ({
|
|
139
|
+
data: {
|
|
140
|
+
id: "42",
|
|
141
|
+
attributes: { title: "Kindred" }
|
|
142
|
+
}
|
|
143
|
+
})
|
|
144
|
+
})]);
|
|
145
|
+
const catalog = createServiceToolCatalog(actions);
|
|
146
|
+
const context = { actor: { id: "7" }, surface: "admin" };
|
|
147
|
+
const toolSet = catalog.resolveToolSet(context);
|
|
148
|
+
|
|
149
|
+
assert.equal(toolSet.tools[0].outputSchema.properties.title.type, "string");
|
|
150
|
+
assert.deepEqual(await catalog.executeToolCall({
|
|
151
|
+
toolName: toolSet.tools[0].name,
|
|
152
|
+
context,
|
|
153
|
+
toolSet
|
|
154
|
+
}), {
|
|
155
|
+
ok: true,
|
|
156
|
+
result: { id: "42", title: "Kindred" }
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
const invalidCatalog = createServiceToolCatalog(createActions([action({
|
|
160
|
+
id: "demo.books.invalid-transform",
|
|
161
|
+
output: null,
|
|
162
|
+
extensions: {
|
|
163
|
+
assistant: {
|
|
164
|
+
output: schema({ title: { type: "string", required: true } }, "replace"),
|
|
165
|
+
transformResult() {
|
|
166
|
+
return {};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
})]));
|
|
171
|
+
const invalidToolSet = invalidCatalog.resolveToolSet(context);
|
|
172
|
+
assert.deepEqual(await invalidCatalog.executeToolCall({
|
|
173
|
+
toolName: invalidToolSet.tools[0].name,
|
|
174
|
+
context,
|
|
175
|
+
toolSet: invalidToolSet
|
|
176
|
+
}), {
|
|
177
|
+
ok: false,
|
|
178
|
+
error: {
|
|
179
|
+
code: "assistant_tool_output_invalid",
|
|
180
|
+
message: "Tool call failed.",
|
|
181
|
+
status: 500
|
|
182
|
+
}
|
|
183
|
+
});
|
|
107
184
|
});
|
|
108
185
|
|
|
109
|
-
test("assistant tools hide workspaceSlug
|
|
186
|
+
test("assistant tools hide workspaceSlug and overwrite model values with trusted workspace context", async () => {
|
|
110
187
|
let executed = null;
|
|
111
188
|
const actions = createActions([action({
|
|
112
189
|
input: schema({
|
|
@@ -129,7 +206,7 @@ test("assistant tools hide workspaceSlug after workspace context is resolved and
|
|
|
129
206
|
assert.equal(Object.hasOwn(toolSet.tools[0].parameters.properties, "workspaceSlug"), false);
|
|
130
207
|
const response = await catalog.executeToolCall({
|
|
131
208
|
toolName: toolSet.tools[0].name,
|
|
132
|
-
argumentsText: JSON.stringify({ title: "Kindred" }),
|
|
209
|
+
argumentsText: JSON.stringify({ workspaceSlug: "other", title: "Kindred" }),
|
|
133
210
|
context,
|
|
134
211
|
toolSet
|
|
135
212
|
});
|
|
@@ -139,6 +216,238 @@ test("assistant tools hide workspaceSlug after workspace context is resolved and
|
|
|
139
216
|
assert.equal(executed.context.channel, "automation");
|
|
140
217
|
});
|
|
141
218
|
|
|
219
|
+
test("assistant tools expose safe field-level input guidance to the model", async () => {
|
|
220
|
+
const actions = createActions([action({
|
|
221
|
+
input: schema({
|
|
222
|
+
include: {
|
|
223
|
+
type: "string",
|
|
224
|
+
required: false,
|
|
225
|
+
messages: {
|
|
226
|
+
default: "include expects a comma-separated string such as \"pet,service\"."
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
})
|
|
230
|
+
})]);
|
|
231
|
+
const catalog = createServiceToolCatalog(actions);
|
|
232
|
+
const context = { actor: { id: "7" }, surface: "admin" };
|
|
233
|
+
const toolSet = catalog.resolveToolSet(context);
|
|
234
|
+
|
|
235
|
+
assert.deepEqual(await catalog.executeToolCall({
|
|
236
|
+
toolName: toolSet.tools[0].name,
|
|
237
|
+
argumentsText: JSON.stringify({ include: ["pet"] }),
|
|
238
|
+
context,
|
|
239
|
+
toolSet
|
|
240
|
+
}), {
|
|
241
|
+
ok: false,
|
|
242
|
+
error: {
|
|
243
|
+
code: "ACTION_VALIDATION_FAILED",
|
|
244
|
+
message: "Validation failed. include: include expects a comma-separated string such as \"pet,service\".",
|
|
245
|
+
status: 400
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
test("large authorized catalogs use compact paged discovery, exact contracts, and gated execution", async () => {
|
|
251
|
+
const executions = [];
|
|
252
|
+
const workspaceInput = schema({
|
|
253
|
+
workspaceSlug: { type: "string", required: true },
|
|
254
|
+
q: { type: "string", required: false }
|
|
255
|
+
});
|
|
256
|
+
const definitions = [
|
|
257
|
+
action({
|
|
258
|
+
id: "demo.books.list",
|
|
259
|
+
input: workspaceInput,
|
|
260
|
+
execute: async (input, context) => {
|
|
261
|
+
executions.push({ input, context });
|
|
262
|
+
return { ok: true };
|
|
263
|
+
}
|
|
264
|
+
}),
|
|
265
|
+
action({ id: "demo.books.view", input: workspaceInput }),
|
|
266
|
+
action({
|
|
267
|
+
id: "demo.books.delete",
|
|
268
|
+
permission: { require: "all", permissions: ["books.delete"] }
|
|
269
|
+
}),
|
|
270
|
+
action({ id: "demo.books.other-surface", surfaces: ["app"] }),
|
|
271
|
+
action({ id: "demo.books.internal", channels: ["internal"] })
|
|
272
|
+
];
|
|
273
|
+
const catalog = createServiceToolCatalog(createActions(definitions), {
|
|
274
|
+
maxDirectTools: 1,
|
|
275
|
+
discoveryPageSize: 1
|
|
276
|
+
});
|
|
277
|
+
const context = {
|
|
278
|
+
actor: { id: "7" },
|
|
279
|
+
surface: "admin",
|
|
280
|
+
workspace: { slug: "library" }
|
|
281
|
+
};
|
|
282
|
+
const toolSet = catalog.resolveToolSet(context);
|
|
283
|
+
|
|
284
|
+
assert.deepEqual(toolSet.tools.map((entry) => entry.name), [
|
|
285
|
+
"assistant_action_search",
|
|
286
|
+
"assistant_action_contract",
|
|
287
|
+
"assistant_action_execute"
|
|
288
|
+
]);
|
|
289
|
+
|
|
290
|
+
const firstPage = await catalog.executeToolCall({
|
|
291
|
+
toolName: "assistant_action_search",
|
|
292
|
+
argumentsText: JSON.stringify({ query: "books", limit: 1 }),
|
|
293
|
+
context,
|
|
294
|
+
toolSet
|
|
295
|
+
});
|
|
296
|
+
assert.equal(firstPage.ok, true);
|
|
297
|
+
assert.equal(firstPage.result.total, 2);
|
|
298
|
+
assert.equal(firstPage.result.items.length, 1);
|
|
299
|
+
assert.equal(Object.hasOwn(firstPage.result.items[0], "inputSchema"), false);
|
|
300
|
+
assert.equal(typeof firstPage.result.nextCursor, "string");
|
|
301
|
+
|
|
302
|
+
const secondPage = await catalog.executeToolCall({
|
|
303
|
+
toolName: "assistant_action_search",
|
|
304
|
+
argumentsText: JSON.stringify({
|
|
305
|
+
query: "books",
|
|
306
|
+
cursor: firstPage.result.nextCursor,
|
|
307
|
+
limit: 1
|
|
308
|
+
}),
|
|
309
|
+
context,
|
|
310
|
+
toolSet
|
|
311
|
+
});
|
|
312
|
+
assert.equal(secondPage.result.items.length, 1);
|
|
313
|
+
assert.equal(secondPage.result.nextCursor, null);
|
|
314
|
+
|
|
315
|
+
assert.deepEqual(await catalog.executeToolCall({
|
|
316
|
+
toolName: "assistant_action_contract",
|
|
317
|
+
argumentsText: JSON.stringify({ actionId: "demo.books.delete" }),
|
|
318
|
+
context,
|
|
319
|
+
toolSet
|
|
320
|
+
}), {
|
|
321
|
+
ok: false,
|
|
322
|
+
error: {
|
|
323
|
+
code: "assistant_action_unknown",
|
|
324
|
+
message: "Action is not available.",
|
|
325
|
+
status: 404
|
|
326
|
+
}
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
assert.deepEqual(await catalog.executeToolCall({
|
|
330
|
+
toolName: "assistant_action_execute",
|
|
331
|
+
argumentsText: JSON.stringify({
|
|
332
|
+
actionId: "demo.books.list",
|
|
333
|
+
version: 1,
|
|
334
|
+
input: { workspaceSlug: "other", q: "octavia" }
|
|
335
|
+
}),
|
|
336
|
+
context,
|
|
337
|
+
toolSet
|
|
338
|
+
}), {
|
|
339
|
+
ok: false,
|
|
340
|
+
error: {
|
|
341
|
+
code: "assistant_action_contract_required",
|
|
342
|
+
message: "Load this action's exact contract before executing it.",
|
|
343
|
+
status: 409
|
|
344
|
+
}
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
const contract = await catalog.executeToolCall({
|
|
348
|
+
toolName: "assistant_action_contract",
|
|
349
|
+
argumentsText: JSON.stringify({ actionId: "demo.books.list", version: 1 }),
|
|
350
|
+
context,
|
|
351
|
+
toolSet
|
|
352
|
+
});
|
|
353
|
+
assert.equal(contract.ok, true);
|
|
354
|
+
assert.equal(contract.result.actionId, "demo.books.list");
|
|
355
|
+
assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
|
|
356
|
+
assert.equal(contract.result.outputSchema.properties.ok["x-json-rest-schema"].castType, "boolean");
|
|
357
|
+
|
|
358
|
+
assert.deepEqual(await catalog.executeToolCall({
|
|
359
|
+
toolName: "assistant_action_execute",
|
|
360
|
+
argumentsText: JSON.stringify({
|
|
361
|
+
actionId: "demo.books.list",
|
|
362
|
+
version: 1,
|
|
363
|
+
input: { workspaceSlug: "other", q: "octavia" }
|
|
364
|
+
}),
|
|
365
|
+
context,
|
|
366
|
+
toolSet
|
|
367
|
+
}), {
|
|
368
|
+
ok: true,
|
|
369
|
+
result: {
|
|
370
|
+
actionId: "demo.books.list",
|
|
371
|
+
version: 1,
|
|
372
|
+
result: { ok: true }
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
assert.deepEqual(executions[0].input, {
|
|
376
|
+
workspaceSlug: "library",
|
|
377
|
+
q: "octavia"
|
|
378
|
+
});
|
|
379
|
+
assert.equal(executions[0].context.channel, "automation");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("discovery pages and persisted tool results stay within configured bounds", async () => {
|
|
383
|
+
const definitions = Array.from({ length: 25 }, (_, index) => action({
|
|
384
|
+
id: `demo.items.action-${String(index + 1).padStart(2, "0")}`
|
|
385
|
+
}));
|
|
386
|
+
const discoveryCatalog = createServiceToolCatalog(createActions(definitions), {
|
|
387
|
+
maxDirectTools: 1
|
|
388
|
+
});
|
|
389
|
+
const context = { actor: { id: "7" }, surface: "admin" };
|
|
390
|
+
const discoveryToolSet = discoveryCatalog.resolveToolSet(context);
|
|
391
|
+
const page = await discoveryCatalog.executeToolCall({
|
|
392
|
+
toolName: "assistant_action_search",
|
|
393
|
+
argumentsText: JSON.stringify({ limit: 100 }),
|
|
394
|
+
context,
|
|
395
|
+
toolSet: discoveryToolSet
|
|
396
|
+
});
|
|
397
|
+
assert.equal(page.ok, true);
|
|
398
|
+
assert.equal(page.result.items.length, 20);
|
|
399
|
+
assert.equal(typeof page.result.nextCursor, "string");
|
|
400
|
+
|
|
401
|
+
const largeContractFields = Object.fromEntries(
|
|
402
|
+
Array.from({ length: 30 }, (_, index) => [
|
|
403
|
+
`field${index}`,
|
|
404
|
+
{ type: "string", required: false, description: "x".repeat(40) }
|
|
405
|
+
])
|
|
406
|
+
);
|
|
407
|
+
const contractCatalog = createServiceToolCatalog(createActions([action({
|
|
408
|
+
id: "demo.large.contract",
|
|
409
|
+
input: schema(largeContractFields)
|
|
410
|
+
})]), {
|
|
411
|
+
maxDirectTools: 0,
|
|
412
|
+
maxToolResultBytes: 500
|
|
413
|
+
});
|
|
414
|
+
const contractToolSet = contractCatalog.resolveToolSet(context);
|
|
415
|
+
assert.deepEqual(await contractCatalog.executeToolCall({
|
|
416
|
+
toolName: "assistant_action_contract",
|
|
417
|
+
argumentsText: JSON.stringify({ actionId: "demo.large.contract" }),
|
|
418
|
+
context,
|
|
419
|
+
toolSet: contractToolSet
|
|
420
|
+
}), {
|
|
421
|
+
ok: false,
|
|
422
|
+
error: {
|
|
423
|
+
code: "assistant_tool_contract_too_large",
|
|
424
|
+
message: "Action contract exceeds the assistant size limit. Narrow the request and try again.",
|
|
425
|
+
status: 413
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
const resultCatalog = createServiceToolCatalog(createActions([action({
|
|
430
|
+
id: "demo.large.read",
|
|
431
|
+
output: schema({ value: { type: "string", required: true } }, "replace"),
|
|
432
|
+
execute: async () => ({ value: "x".repeat(1000) })
|
|
433
|
+
})]), {
|
|
434
|
+
maxToolResultBytes: 200
|
|
435
|
+
});
|
|
436
|
+
const resultToolSet = resultCatalog.resolveToolSet(context);
|
|
437
|
+
assert.deepEqual(await resultCatalog.executeToolCall({
|
|
438
|
+
toolName: resultToolSet.tools[0].name,
|
|
439
|
+
context,
|
|
440
|
+
toolSet: resultToolSet
|
|
441
|
+
}), {
|
|
442
|
+
ok: false,
|
|
443
|
+
error: {
|
|
444
|
+
code: "assistant_tool_result_too_large",
|
|
445
|
+
message: "Tool result exceeds the assistant size limit. Narrow the request and try again.",
|
|
446
|
+
status: 413
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
});
|
|
450
|
+
|
|
142
451
|
test("assistant tools reject unknown tools and return safe action failures", async () => {
|
|
143
452
|
const actions = createActions([action({
|
|
144
453
|
execute: async () => {
|