@jskit-ai/assistant-core 0.1.142 → 0.1.143

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,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: "patch" };
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: 1,
35
- kind: "query",
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 both input and output contracts", () => {
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 after workspace context is resolved and inject it on execution", async () => {
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,207 @@ 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("large authorized catalogs use compact paged discovery, exact contracts, and gated execution", async () => {
220
+ const executions = [];
221
+ const workspaceInput = schema({
222
+ workspaceSlug: { type: "string", required: true },
223
+ q: { type: "string", required: false }
224
+ });
225
+ const definitions = [
226
+ action({
227
+ id: "demo.books.list",
228
+ input: workspaceInput,
229
+ execute: async (input, context) => {
230
+ executions.push({ input, context });
231
+ return { ok: true };
232
+ }
233
+ }),
234
+ action({ id: "demo.books.view", input: workspaceInput }),
235
+ action({
236
+ id: "demo.books.delete",
237
+ permission: { require: "all", permissions: ["books.delete"] }
238
+ }),
239
+ action({ id: "demo.books.other-surface", surfaces: ["app"] }),
240
+ action({ id: "demo.books.internal", channels: ["internal"] })
241
+ ];
242
+ const catalog = createServiceToolCatalog(createActions(definitions), {
243
+ maxDirectTools: 1,
244
+ discoveryPageSize: 1
245
+ });
246
+ const context = {
247
+ actor: { id: "7" },
248
+ surface: "admin",
249
+ workspace: { slug: "library" }
250
+ };
251
+ const toolSet = catalog.resolveToolSet(context);
252
+
253
+ assert.deepEqual(toolSet.tools.map((entry) => entry.name), [
254
+ "assistant_action_search",
255
+ "assistant_action_contract",
256
+ "assistant_action_execute"
257
+ ]);
258
+
259
+ const firstPage = await catalog.executeToolCall({
260
+ toolName: "assistant_action_search",
261
+ argumentsText: JSON.stringify({ query: "books", limit: 1 }),
262
+ context,
263
+ toolSet
264
+ });
265
+ assert.equal(firstPage.ok, true);
266
+ assert.equal(firstPage.result.total, 2);
267
+ assert.equal(firstPage.result.items.length, 1);
268
+ assert.equal(Object.hasOwn(firstPage.result.items[0], "inputSchema"), false);
269
+ assert.equal(typeof firstPage.result.nextCursor, "string");
270
+
271
+ const secondPage = await catalog.executeToolCall({
272
+ toolName: "assistant_action_search",
273
+ argumentsText: JSON.stringify({
274
+ query: "books",
275
+ cursor: firstPage.result.nextCursor,
276
+ limit: 1
277
+ }),
278
+ context,
279
+ toolSet
280
+ });
281
+ assert.equal(secondPage.result.items.length, 1);
282
+ assert.equal(secondPage.result.nextCursor, null);
283
+
284
+ assert.deepEqual(await catalog.executeToolCall({
285
+ toolName: "assistant_action_contract",
286
+ argumentsText: JSON.stringify({ actionId: "demo.books.delete" }),
287
+ context,
288
+ toolSet
289
+ }), {
290
+ ok: false,
291
+ error: {
292
+ code: "assistant_action_unknown",
293
+ message: "Action is not available.",
294
+ status: 404
295
+ }
296
+ });
297
+
298
+ assert.deepEqual(await catalog.executeToolCall({
299
+ toolName: "assistant_action_execute",
300
+ argumentsText: JSON.stringify({
301
+ actionId: "demo.books.list",
302
+ version: 1,
303
+ input: { workspaceSlug: "other", q: "octavia" }
304
+ }),
305
+ context,
306
+ toolSet
307
+ }), {
308
+ ok: false,
309
+ error: {
310
+ code: "assistant_action_contract_required",
311
+ message: "Load this action's exact contract before executing it.",
312
+ status: 409
313
+ }
314
+ });
315
+
316
+ const contract = await catalog.executeToolCall({
317
+ toolName: "assistant_action_contract",
318
+ argumentsText: JSON.stringify({ actionId: "demo.books.list", version: 1 }),
319
+ context,
320
+ toolSet
321
+ });
322
+ assert.equal(contract.ok, true);
323
+ assert.equal(contract.result.actionId, "demo.books.list");
324
+ assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
325
+ assert.equal(contract.result.outputSchema.properties.ok["x-json-rest-schema"].castType, "boolean");
326
+
327
+ assert.deepEqual(await catalog.executeToolCall({
328
+ toolName: "assistant_action_execute",
329
+ argumentsText: JSON.stringify({
330
+ actionId: "demo.books.list",
331
+ version: 1,
332
+ input: { workspaceSlug: "other", q: "octavia" }
333
+ }),
334
+ context,
335
+ toolSet
336
+ }), {
337
+ ok: true,
338
+ result: {
339
+ actionId: "demo.books.list",
340
+ version: 1,
341
+ result: { ok: true }
342
+ }
343
+ });
344
+ assert.deepEqual(executions[0].input, {
345
+ workspaceSlug: "library",
346
+ q: "octavia"
347
+ });
348
+ assert.equal(executions[0].context.channel, "automation");
349
+ });
350
+
351
+ test("discovery pages and persisted tool results stay within configured bounds", async () => {
352
+ const definitions = Array.from({ length: 25 }, (_, index) => action({
353
+ id: `demo.items.action-${String(index + 1).padStart(2, "0")}`
354
+ }));
355
+ const discoveryCatalog = createServiceToolCatalog(createActions(definitions), {
356
+ maxDirectTools: 1
357
+ });
358
+ const context = { actor: { id: "7" }, surface: "admin" };
359
+ const discoveryToolSet = discoveryCatalog.resolveToolSet(context);
360
+ const page = await discoveryCatalog.executeToolCall({
361
+ toolName: "assistant_action_search",
362
+ argumentsText: JSON.stringify({ limit: 100 }),
363
+ context,
364
+ toolSet: discoveryToolSet
365
+ });
366
+ assert.equal(page.ok, true);
367
+ assert.equal(page.result.items.length, 20);
368
+ assert.equal(typeof page.result.nextCursor, "string");
369
+
370
+ const largeContractFields = Object.fromEntries(
371
+ Array.from({ length: 30 }, (_, index) => [
372
+ `field${index}`,
373
+ { type: "string", required: false, description: "x".repeat(40) }
374
+ ])
375
+ );
376
+ const contractCatalog = createServiceToolCatalog(createActions([action({
377
+ id: "demo.large.contract",
378
+ input: schema(largeContractFields)
379
+ })]), {
380
+ maxDirectTools: 0,
381
+ maxToolResultBytes: 500
382
+ });
383
+ const contractToolSet = contractCatalog.resolveToolSet(context);
384
+ assert.deepEqual(await contractCatalog.executeToolCall({
385
+ toolName: "assistant_action_contract",
386
+ argumentsText: JSON.stringify({ actionId: "demo.large.contract" }),
387
+ context,
388
+ toolSet: contractToolSet
389
+ }), {
390
+ ok: false,
391
+ error: {
392
+ code: "assistant_tool_contract_too_large",
393
+ message: "Action contract exceeds the assistant size limit. Narrow the request and try again.",
394
+ status: 413
395
+ }
396
+ });
397
+
398
+ const resultCatalog = createServiceToolCatalog(createActions([action({
399
+ id: "demo.large.read",
400
+ output: schema({ value: { type: "string", required: true } }, "replace"),
401
+ execute: async () => ({ value: "x".repeat(1000) })
402
+ })]), {
403
+ maxToolResultBytes: 200
404
+ });
405
+ const resultToolSet = resultCatalog.resolveToolSet(context);
406
+ assert.deepEqual(await resultCatalog.executeToolCall({
407
+ toolName: resultToolSet.tools[0].name,
408
+ context,
409
+ toolSet: resultToolSet
410
+ }), {
411
+ ok: false,
412
+ error: {
413
+ code: "assistant_tool_result_too_large",
414
+ message: "Tool result exceeds the assistant size limit. Narrow the request and try again.",
415
+ status: 413
416
+ }
417
+ });
418
+ });
419
+
142
420
  test("assistant tools reject unknown tools and return safe action failures", async () => {
143
421
  const actions = createActions([action({
144
422
  execute: async () => {