@jskit-ai/crud-core 0.1.178 → 0.1.179

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/crud-core",
3
- "version": "0.1.178",
3
+ "version": "0.1.179",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "test": "node --test"
@@ -23,11 +23,9 @@
23
23
  "./server/routeContracts": "./src/server/routeContracts.js"
24
24
  },
25
25
  "dependencies": {
26
- "@jskit-ai/database-runtime": "0.1.167",
27
- "@jskit-ai/http-runtime": "0.1.165",
28
- "@jskit-ai/json-rest-api-core": "0.1.111",
29
- "@jskit-ai/kernel": "0.1.167",
30
- "@jskit-ai/resource-crud-core": "0.1.109",
26
+ "@jskit-ai/database-runtime": "0.1.168",
27
+ "@jskit-ai/json-rest-api-core": "0.1.112",
28
+ "@jskit-ai/resource-crud-core": "0.1.110",
31
29
  "json-rest-schema": "^1.0.17"
32
30
  },
33
31
  "description": "Shared server-side CRUD service, repository, route, and query helpers.",
@@ -45,5 +43,9 @@
45
43
  "providers": []
46
44
  }
47
45
  }
46
+ },
47
+ "peerDependencies": {
48
+ "@jskit-ai/http-runtime": "0.1.166",
49
+ "@jskit-ai/kernel": "0.1.168"
48
50
  }
49
51
  }
@@ -8,8 +8,8 @@
8
8
  "./shared": "./src/shared/index.js"
9
9
  },
10
10
  "dependencies": {
11
- "@jskit-ai/crud-core": "0.1.178",
12
- "@jskit-ai/resource-crud-core": "0.1.109"
11
+ "@jskit-ai/crud-core": "0.1.179",
12
+ "@jskit-ai/resource-crud-core": "0.1.110"
13
13
  },
14
14
  "jskit": {
15
15
  "kind": "runtime",
@@ -1,14 +1,16 @@
1
1
  import {
2
2
  composeSchemaDefinitions,
3
+ createSchema,
3
4
  recordIdParamsValidator
4
5
  } from "@jskit-ai/kernel/shared/validators";
5
6
  import { createEntityChangedActionEvent } from "@jskit-ai/kernel/server/actions";
6
7
  import {
8
+ decodeJsonApiResourceResponse,
7
9
  normalizeJsonApiDocument,
8
- simplifyJsonApiDocument,
9
10
  unwrapJsonApiResult
10
11
  } from "@jskit-ai/http-runtime/shared";
11
12
  import { resolveCrudRecordChangedEvent } from "@jskit-ai/resource-crud-core/shared/crudNamespaceSupport";
13
+ import { resolveJsonApiRelationshipEntries } from "../routeContracts.js";
12
14
  import {
13
15
  createStandardCrudListQueryValidators,
14
16
  createStandardCrudViewQueryValidators
@@ -37,12 +39,6 @@ function normalizeOptionalCursor(value) {
37
39
  return normalized || null;
38
40
  }
39
41
 
40
- function resolveDocumentNextCursor(document = {}) {
41
- return normalizeOptionalCursor(
42
- document?.meta?.page?.nextCursor ?? document?.meta?.pagination?.cursor?.next
43
- );
44
- }
45
-
46
42
  function resolveAssistantResultValue(result) {
47
43
  const taggedResult = unwrapJsonApiResult(result);
48
44
  const value = taggedResult ? taggedResult.value : result;
@@ -53,7 +49,129 @@ function resolveAssistantResultValue(result) {
53
49
  };
54
50
  }
55
51
 
56
- function transformCrudAssistantResult(operation, result, { input = {} } = {}) {
52
+ function resolveSchemaFieldDefinitions(definition = null) {
53
+ const definitions = definition?.schema?.getFieldDefinitions?.();
54
+ return isRecord(definitions) ? definitions : {};
55
+ }
56
+
57
+ function createProjectionRecordSchema(recordSchema, {
58
+ lookupContainerKey = "",
59
+ relationshipEntries = []
60
+ } = {}) {
61
+ const definitions = recordSchema?.getFieldDefinitions?.();
62
+ if (!isRecord(definitions)) {
63
+ return recordSchema;
64
+ }
65
+
66
+ const fields = Object.fromEntries(
67
+ Object.entries(definitions).map(([fieldKey, fieldDefinition]) => [
68
+ fieldKey,
69
+ {
70
+ ...fieldDefinition,
71
+ required: fieldKey === "id" && fieldDefinition?.required === true
72
+ }
73
+ ])
74
+ );
75
+ if (lookupContainerKey && isRecord(fields[lookupContainerKey])) {
76
+ const createRelatedRecordSchema = (entry) => createSchema({
77
+ id: {
78
+ type: "string",
79
+ required: true
80
+ },
81
+ ...(entry.labelKey && entry.labelKey !== "id"
82
+ ? {
83
+ [entry.labelKey]: {
84
+ type: "string",
85
+ required: false,
86
+ nullable: true
87
+ }
88
+ }
89
+ : {})
90
+ });
91
+ const lookupFields = Object.fromEntries(
92
+ relationshipEntries.map((entry) => [
93
+ entry.relationshipName,
94
+ entry.many === true
95
+ ? {
96
+ type: "array",
97
+ required: false,
98
+ items: {
99
+ type: "object",
100
+ schema: createRelatedRecordSchema(entry),
101
+ additionalProperties: true
102
+ }
103
+ }
104
+ : {
105
+ type: "object",
106
+ required: false,
107
+ schema: createRelatedRecordSchema(entry),
108
+ additionalProperties: true
109
+ }
110
+ ])
111
+ );
112
+ fields[lookupContainerKey] = {
113
+ ...fields[lookupContainerKey],
114
+ type: "object",
115
+ required: false,
116
+ ...(Object.keys(lookupFields).length > 0 ? { schema: createSchema(lookupFields) } : {}),
117
+ additionalProperties: true
118
+ };
119
+ }
120
+
121
+ return createSchema(fields);
122
+ }
123
+
124
+ function createProjectionOutputDefinition(output, operation, relationshipEntries, lookupContainerKey) {
125
+ if (!output || (operation !== "list" && operation !== "view")) {
126
+ return output;
127
+ }
128
+
129
+ if (operation === "view") {
130
+ return Object.freeze({
131
+ schema: createProjectionRecordSchema(output.schema, { lookupContainerKey, relationshipEntries }),
132
+ mode: "replace"
133
+ });
134
+ }
135
+
136
+ const listFields = resolveSchemaFieldDefinitions(output);
137
+ const items = listFields.items;
138
+ if (!isRecord(items) || items.type !== "array" || typeof items.items?.getFieldDefinitions !== "function") {
139
+ return output;
140
+ }
141
+
142
+ return Object.freeze({
143
+ schema: createSchema({
144
+ ...listFields,
145
+ items: {
146
+ ...items,
147
+ items: createProjectionRecordSchema(items.items, { lookupContainerKey, relationshipEntries })
148
+ }
149
+ }),
150
+ mode: "replace"
151
+ });
152
+ }
153
+
154
+ function createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey) {
155
+ const lookupFieldMap = Object.fromEntries(
156
+ relationshipEntries
157
+ .filter((entry) => entry.many !== true)
158
+ .map((entry) => [entry.relationshipName, entry.attributeKey])
159
+ );
160
+ return Object.freeze({
161
+ kind: "jsonapi-resource",
162
+ responseType: resource.namespace,
163
+ responseKind: operation === "list" ? "collection" : "record",
164
+ ...(lookupContainerKey ? { lookupContainerKey } : {}),
165
+ ...(Object.keys(lookupFieldMap).length > 0 ? { lookupFieldMap } : {})
166
+ });
167
+ }
168
+
169
+ function transformCrudAssistantResult(operation, result, {
170
+ input = {},
171
+ resource,
172
+ relationshipEntries = [],
173
+ lookupContainerKey = ""
174
+ } = {}) {
57
175
  if (operation === "delete") {
58
176
  const resolved = resolveAssistantResultValue(result);
59
177
  if (isRecord(resolved.value) && resolved.value.deleted === true && resolved.value.id != null) {
@@ -68,9 +186,13 @@ function transformCrudAssistantResult(operation, result, { input = {} } = {}) {
68
186
  const resolved = resolveAssistantResultValue(result);
69
187
  if (operation === "list") {
70
188
  if (resolved.document.kind === "collection") {
189
+ const decoded = decodeJsonApiResourceResponse(
190
+ resolved.value,
191
+ createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
192
+ );
71
193
  return {
72
- items: simplifyJsonApiDocument(resolved.value),
73
- nextCursor: resolveDocumentNextCursor(resolved.document)
194
+ items: decoded.items,
195
+ nextCursor: normalizeOptionalCursor(decoded.nextCursor)
74
196
  };
75
197
  }
76
198
  if (Array.isArray(resolved.value)) {
@@ -89,18 +211,44 @@ function transformCrudAssistantResult(operation, result, { input = {} } = {}) {
89
211
  }
90
212
 
91
213
  if (resolved.document.kind === "resource") {
92
- return simplifyJsonApiDocument(resolved.value);
214
+ return decodeJsonApiResourceResponse(
215
+ resolved.value,
216
+ createCrudAssistantTransport(resource, operation, relationshipEntries, lookupContainerKey)
217
+ );
93
218
  }
94
219
  return resolved.value;
95
220
  }
96
221
 
97
222
  function createCrudAssistantExtension(resource, namespace, operation) {
98
223
  const resourceOperation = CRUD_ASSISTANT_RESOURCE_OPERATION[operation];
99
- const output = resource?.operations?.[resourceOperation]?.output || null;
224
+ const nativeOutput = resource?.operations?.[resourceOperation]?.output || null;
225
+ const recordOutput = operation === "list"
226
+ ? Object.freeze({
227
+ schema: resolveSchemaFieldDefinitions(nativeOutput).items?.items,
228
+ mode: "replace"
229
+ })
230
+ : nativeOutput;
231
+ const relationshipEntries = resolveJsonApiRelationshipEntries(recordOutput);
232
+ const lookupContainerKey = String(resource?.contract?.lookup?.containerKey || "").trim();
233
+ const output = createProjectionOutputDefinition(
234
+ nativeOutput,
235
+ operation,
236
+ relationshipEntries,
237
+ lookupContainerKey
238
+ );
239
+ const firstRelationship = relationshipEntries[0] || null;
240
+ const fieldsExample = firstRelationship
241
+ ? ` fields can be {\"${namespace}\":[\"${firstRelationship.attributeKey}\"],` +
242
+ `\"${firstRelationship.relationshipType}\":[\"${firstRelationship.labelKey || "id"}\"]}.`
243
+ : ` fields can be {\"${namespace}\":[\"id\"]}.`;
244
+ const lookupExample = firstRelationship && lookupContainerKey
245
+ ? ` Included fields are returned under ${operation === "list" ? "items[]." : ""}${lookupContainerKey}.` +
246
+ `${firstRelationship.relationshipName}.${firstRelationship.labelKey || "<selectedField>"}.`
247
+ : "";
100
248
  const actionLabel = operation === "list"
101
- ? `List ${namespace} records.`
249
+ ? `List ${namespace} records. include must be a comma-separated string such as \"pet,service\";${fieldsExample}${lookupExample}`
102
250
  : operation === "view"
103
- ? `View a ${namespace} record.`
251
+ ? `View a ${namespace} record. include must be a comma-separated string such as \"pet,service\";${fieldsExample}${lookupExample}`
104
252
  : operation === "create"
105
253
  ? `Create a ${namespace} record.`
106
254
  : operation === "update"
@@ -111,7 +259,12 @@ function createCrudAssistantExtension(resource, namespace, operation) {
111
259
  description: actionLabel,
112
260
  output,
113
261
  transformResult(result, context) {
114
- return transformCrudAssistantResult(operation, result, context);
262
+ return transformCrudAssistantResult(operation, result, {
263
+ ...context,
264
+ resource,
265
+ relationshipEntries,
266
+ lookupContainerKey
267
+ });
115
268
  }
116
269
  });
117
270
  }
@@ -19,7 +19,10 @@ const lookupIncludeQueryValidator = Object.freeze({
19
19
  schema: createSchema({
20
20
  include: {
21
21
  type: "string",
22
- required: false
22
+ required: false,
23
+ messages: {
24
+ default: "include expects a comma-separated string such as \"pet,service\"."
25
+ }
23
26
  }
24
27
  }),
25
28
  mode: "patch"
@@ -30,6 +33,9 @@ const jsonApiFieldsetsQueryValidator = Object.freeze({
30
33
  fields: {
31
34
  type: "object",
32
35
  required: false,
36
+ messages: {
37
+ default: "fields expects an object such as {\"bookings\":[\"petId\"],\"pets\":[\"name\"]}."
38
+ },
33
39
  values: {
34
40
  type: "array",
35
41
  items: {
@@ -44,6 +44,7 @@ function resolveJsonApiRelationshipEntries(definition = null) {
44
44
  attributeKey: fieldKey,
45
45
  relationshipName,
46
46
  relationshipType,
47
+ labelKey: String(normalizedFieldDefinition?.relation?.labelKey || "").trim(),
47
48
  required: normalizedFieldDefinition.required === true,
48
49
  nullable: normalizedFieldDefinition.nullable === true
49
50
  }));
@@ -75,6 +76,7 @@ function resolveJsonApiRelationshipEntries(definition = null) {
75
76
  attributeKey: fieldKey,
76
77
  relationshipName: collectionRelationshipName,
77
78
  relationshipType: collectionRelationshipType,
79
+ labelKey: String(relation.labelKey || "").trim(),
78
80
  many: true,
79
81
  required: normalizedFieldDefinition.required === true,
80
82
  nullable: normalizedFieldDefinition.nullable === true
@@ -574,4 +576,4 @@ function createCrudJsonApiRouteContracts({
574
576
  });
575
577
  }
576
578
 
577
- export { createCrudJsonApiRouteContracts };
579
+ export { createCrudJsonApiRouteContracts, resolveJsonApiRelationshipEntries };
@@ -0,0 +1,344 @@
1
+ import assert from "node:assert/strict";
2
+ import test from "node:test";
3
+ import { createServiceToolCatalog } from "@jskit-ai/assistant-core/server";
4
+ import { createActionCatalogue } from "@jskit-ai/kernel/server/actions";
5
+ import {
6
+ createSchema,
7
+ validateSchemaPayload
8
+ } from "@jskit-ai/kernel/shared/validators";
9
+ import { returnJsonApiDocument } from "@jskit-ai/http-runtime/shared";
10
+ import { defineCrudResource } from "@jskit-ai/resource-crud-core/shared/crudResource";
11
+ import { createCrudJsonApiActions } from "../src/server/jsonApiModule/actions.js";
12
+
13
+ const BOOKINGS = Object.freeze([
14
+ Object.freeze({ id: "41", petId: "7", summary: "Wash and trim" }),
15
+ Object.freeze({ id: "42", petId: "8", summary: "Nail trim" })
16
+ ]);
17
+ const PETS = Object.freeze({
18
+ "7": Object.freeze({ id: "7", name: "Fido", species: "dog" }),
19
+ "8": Object.freeze({ id: "8", name: "Mabel", species: "cat" })
20
+ });
21
+
22
+ function createBookingsResource() {
23
+ return defineCrudResource({
24
+ namespace: "bookings",
25
+ tableName: "bookings",
26
+ crudOperations: ["list", "view"],
27
+ contract: {
28
+ lookup: {
29
+ containerKey: "lookups"
30
+ }
31
+ },
32
+ schema: {
33
+ petId: {
34
+ type: "id",
35
+ required: true,
36
+ belongsTo: "pets",
37
+ as: "pet",
38
+ relation: {
39
+ kind: "lookup",
40
+ namespace: "pets",
41
+ valueKey: "id",
42
+ labelKey: "name"
43
+ },
44
+ operations: {
45
+ output: { required: true }
46
+ }
47
+ },
48
+ summary: {
49
+ type: "string",
50
+ required: true,
51
+ operations: {
52
+ output: { required: true }
53
+ }
54
+ }
55
+ }
56
+ });
57
+ }
58
+
59
+ function selectFields(source, selectedFields = null) {
60
+ if (!Array.isArray(selectedFields)) {
61
+ return { ...source };
62
+ }
63
+ return Object.fromEntries(
64
+ selectedFields
65
+ .filter((field) => Object.hasOwn(source, field))
66
+ .map((field) => [field, source[field]])
67
+ );
68
+ }
69
+
70
+ function createBookingDocument(query = {}) {
71
+ const limit = Math.min(Number(query.limit) || BOOKINGS.length, BOOKINGS.length);
72
+ const selectedBookings = query.fields?.bookings || null;
73
+ const selectedPets = query.fields?.pets || null;
74
+ const includesPet = String(query.include || "")
75
+ .split(",")
76
+ .map((entry) => entry.trim())
77
+ .includes("pet");
78
+ const rows = BOOKINGS.slice(0, limit);
79
+ const data = rows.map((booking) => {
80
+ const includePetLinkage = !selectedBookings || selectedBookings.includes("petId");
81
+ return {
82
+ type: "bookings",
83
+ id: booking.id,
84
+ attributes: selectFields({ summary: booking.summary }, selectedBookings),
85
+ ...(includePetLinkage
86
+ ? {
87
+ relationships: {
88
+ pet: {
89
+ data: { type: "pets", id: booking.petId }
90
+ }
91
+ }
92
+ }
93
+ : {})
94
+ };
95
+ });
96
+ const included = includesPet
97
+ ? rows.map((booking) => ({
98
+ type: "pets",
99
+ id: booking.petId,
100
+ attributes: selectFields({
101
+ name: PETS[booking.petId].name,
102
+ species: PETS[booking.petId].species
103
+ }, selectedPets)
104
+ }))
105
+ : [];
106
+
107
+ return {
108
+ data,
109
+ ...(included.length > 0 ? { included } : {}),
110
+ meta: {
111
+ page: {
112
+ nextCursor: limit < BOOKINGS.length ? `after-${rows.at(-1)?.id}` : null
113
+ }
114
+ }
115
+ };
116
+ }
117
+
118
+ function createFixture() {
119
+ const resource = createBookingsResource();
120
+ const calls = [];
121
+ const service = {
122
+ async queryDocuments(query, { context }) {
123
+ calls.push({ query, context });
124
+ return returnJsonApiDocument(createBookingDocument(query));
125
+ },
126
+ async getDocumentById() {
127
+ return returnJsonApiDocument({ data: createBookingDocument().data[0] });
128
+ }
129
+ };
130
+ const definitions = createCrudJsonApiActions({
131
+ namespace: "bookings",
132
+ resource,
133
+ service,
134
+ surface: "admin",
135
+ operations: ["list", "view"],
136
+ scopeInputValidator: Object.freeze({
137
+ schema: createSchema({
138
+ workspaceSlug: { type: "string", required: true }
139
+ }),
140
+ mode: "patch"
141
+ }),
142
+ scopeInputKeys: ["workspaceSlug"],
143
+ permissionForOperation: () => ({
144
+ require: "all",
145
+ permissions: ["bookings.list"]
146
+ })
147
+ });
148
+ const actions = createActionCatalogue();
149
+ actions.register({
150
+ contributorId: "test.generated-bookings",
151
+ domain: "bookings",
152
+ actions: definitions
153
+ });
154
+ const catalog = createServiceToolCatalog(actions, { maxDirectTools: 0 });
155
+ const context = Object.freeze({
156
+ actor: Object.freeze({ id: "user-7" }),
157
+ permissions: Object.freeze(["bookings.list"]),
158
+ surface: "admin",
159
+ workspace: Object.freeze({ slug: "north-clinic" })
160
+ });
161
+ return { actions, calls, catalog, context, definitions, resource };
162
+ }
163
+
164
+ async function loadListContract(fixture, context = fixture.context) {
165
+ const toolSet = fixture.catalog.resolveToolSet(context);
166
+ const search = await fixture.catalog.executeToolCall({
167
+ toolName: "assistant_action_search",
168
+ argumentsText: JSON.stringify({ query: "bookings list" }),
169
+ context,
170
+ toolSet
171
+ });
172
+ const contract = await fixture.catalog.executeToolCall({
173
+ toolName: "assistant_action_contract",
174
+ argumentsText: JSON.stringify({ actionId: "crud.bookings.list", version: 1 }),
175
+ context,
176
+ toolSet
177
+ });
178
+ return { contract, search, toolSet };
179
+ }
180
+
181
+ async function executeList(fixture, toolSet, input, context = fixture.context) {
182
+ return fixture.catalog.executeToolCall({
183
+ toolName: "assistant_action_execute",
184
+ argumentsText: JSON.stringify({
185
+ actionId: "crud.bookings.list",
186
+ version: 1,
187
+ input
188
+ }),
189
+ context,
190
+ toolSet
191
+ });
192
+ }
193
+
194
+ function resolveLocalSchemaReference(schema, node) {
195
+ const reference = node?.allOf?.[0]?.$ref;
196
+ assert.match(reference || "", /^#\/definitions\//u);
197
+ return schema.definitions[reference.slice("#/definitions/".length)];
198
+ }
199
+
200
+ test("generated CRUD list contracts conform through native assistant discovery and execution", async (t) => {
201
+ const fixture = createFixture();
202
+ const { contract, search, toolSet } = await loadListContract(fixture);
203
+ const listDefinition = fixture.definitions.find((entry) => entry.id === "crud.bookings.list");
204
+
205
+ assert.equal(search.ok, true);
206
+ assert.deepEqual(search.result.items.map((entry) => entry.actionId), ["crud.bookings.list"]);
207
+ assert.equal(contract.ok, true);
208
+ assert.equal(contract.result.inputSchema.properties.include.type, "string");
209
+ assert.equal(contract.result.inputSchema.properties.fields.type, "object");
210
+ assert.equal(Object.hasOwn(contract.result.inputSchema.properties, "workspaceSlug"), false);
211
+ assert.match(contract.result.description, /include must be a comma-separated string/u);
212
+ assert.match(contract.result.description, /\{"bookings":\["petId"\],"pets":\["name"\]\}/u);
213
+ assert.match(contract.result.description, /items\[\]\.lookups\.pet\.name/u);
214
+ const primaryOutputSchema = resolveLocalSchemaReference(
215
+ contract.result.outputSchema,
216
+ contract.result.outputSchema.properties.items.items
217
+ );
218
+ const lookupOutputSchema = resolveLocalSchemaReference(
219
+ contract.result.outputSchema,
220
+ primaryOutputSchema.properties.lookups
221
+ );
222
+ const petOutputSchema = resolveLocalSchemaReference(
223
+ contract.result.outputSchema,
224
+ lookupOutputSchema.properties.pet
225
+ );
226
+ assert.ok(petOutputSchema.properties.name.anyOf.some((entry) => entry.type === "string"));
227
+
228
+ await t.test("full list without fields or include", async () => {
229
+ const response = await executeList(fixture, toolSet, { limit: 5 });
230
+ assert.equal(response.ok, true);
231
+ assert.deepEqual(response.result.result.items, [
232
+ { id: "41", petId: 7, summary: "Wash and trim" },
233
+ { id: "42", petId: 8, summary: "Nail trim" }
234
+ ]);
235
+ assert.equal(response.result.result.nextCursor, null);
236
+ });
237
+
238
+ await t.test("sparse primary fields only", async () => {
239
+ const response = await executeList(fixture, toolSet, {
240
+ fields: { bookings: ["petId"] },
241
+ limit: 5
242
+ });
243
+ assert.equal(response.ok, true);
244
+ assert.deepEqual(response.result.result.items, [
245
+ { id: "41", petId: 7 },
246
+ { id: "42", petId: 8 }
247
+ ]);
248
+ });
249
+
250
+ await t.test("included relationship without sparse fields", async () => {
251
+ const response = await executeList(fixture, toolSet, { include: "pet", limit: 5 });
252
+ assert.equal(response.ok, true);
253
+ assert.equal(response.result.result.items[0].lookups.pet.name, "Fido");
254
+ assert.equal(response.result.result.items[0].lookups.pet.species, "dog");
255
+ assert.equal(response.result.result.items[1].lookups.pet.name, "Mabel");
256
+ });
257
+
258
+ await t.test("included relationship plus sparse primary and related fields", async () => {
259
+ const response = await executeList(fixture, toolSet, {
260
+ workspaceSlug: "model-controlled-workspace",
261
+ include: "pet",
262
+ fields: { bookings: ["petId"], pets: ["name"] },
263
+ limit: 5
264
+ });
265
+ assert.equal(response.ok, true);
266
+ assert.notEqual(response.error?.code, "assistant_tool_output_invalid");
267
+ assert.deepEqual(JSON.parse(JSON.stringify(response.result.result.items)), [
268
+ {
269
+ id: "41",
270
+ petId: 7,
271
+ lookups: {
272
+ petId: { id: "7", name: "Fido" },
273
+ pet: { id: "7", name: "Fido" }
274
+ }
275
+ },
276
+ {
277
+ id: "42",
278
+ petId: 8,
279
+ lookups: {
280
+ petId: { id: "8", name: "Mabel" },
281
+ pet: { id: "8", name: "Mabel" }
282
+ }
283
+ }
284
+ ]);
285
+ assert.equal(Object.hasOwn(response.result.result.items[0], "summary"), false);
286
+ assert.equal(Object.hasOwn(response.result.result.items[0].lookups.pet, "species"), false);
287
+ assert.deepEqual(
288
+ validateSchemaPayload(listDefinition.extensions.assistant.output, response.result.result, {
289
+ phase: "output"
290
+ }),
291
+ response.result.result
292
+ );
293
+ assert.equal(fixture.calls.at(-1).query.workspaceSlug, undefined);
294
+ assert.equal(fixture.calls.at(-1).context.workspace.slug, "north-clinic");
295
+ });
296
+
297
+ await t.test("limit and cursor truncation remain bounded", async () => {
298
+ const response = await executeList(fixture, toolSet, { limit: 1 });
299
+ assert.equal(response.ok, true);
300
+ assert.equal(response.result.result.items.length, 1);
301
+ assert.equal(response.result.result.nextCursor, "after-41");
302
+ assert.equal(fixture.calls.at(-1).query.limit, 1);
303
+ });
304
+
305
+ await t.test("invalid array-shaped include gives an actionable validation result", async () => {
306
+ const response = await executeList(fixture, toolSet, { include: ["pet"] });
307
+ assert.equal(response.ok, false);
308
+ assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
309
+ assert.equal(response.error.status, 400);
310
+ assert.match(response.error.message, /include expects a comma-separated string such as "pet,service"/u);
311
+ });
312
+
313
+ await t.test("invalid array-shaped fields gives an actionable validation result", async () => {
314
+ const response = await executeList(fixture, toolSet, { fields: ["pet.name"] });
315
+ assert.equal(response.ok, false);
316
+ assert.equal(response.error.code, "ACTION_VALIDATION_FAILED");
317
+ assert.equal(response.error.status, 400);
318
+ assert.match(
319
+ response.error.message,
320
+ /fields expects an object such as \{"bookings":\["petId"\],"pets":\["name"\]\}/u
321
+ );
322
+ });
323
+
324
+ await t.test("permission denial removes the generated action from native discovery", async () => {
325
+ const deniedContext = {
326
+ ...fixture.context,
327
+ permissions: []
328
+ };
329
+ const deniedToolSet = fixture.catalog.resolveToolSet(deniedContext);
330
+ assert.deepEqual(deniedToolSet.tools, []);
331
+ assert.deepEqual(await fixture.catalog.executeToolCall({
332
+ toolName: "crud_bookings_list",
333
+ argumentsText: "{}",
334
+ context: deniedContext,
335
+ toolSet: deniedToolSet
336
+ }), {
337
+ ok: false,
338
+ error: {
339
+ code: "assistant_tool_unknown",
340
+ message: "Unknown tool."
341
+ }
342
+ });
343
+ });
344
+ });
@@ -227,11 +227,18 @@ test("generated CRUD actions expose truthful assistant contracts without changin
227
227
  const byOperation = new Map(actions.map((entry) => [entry.id.split(".").at(-1), entry]));
228
228
 
229
229
  assert.ok(actions.every((entry) => entry.output === null));
230
- assert.equal(byOperation.get("list").extensions.assistant.output, resource.operations.list.output);
231
- assert.equal(byOperation.get("view").extensions.assistant.output, resource.operations.view.output);
230
+ assert.notEqual(byOperation.get("list").extensions.assistant.output, resource.operations.list.output);
231
+ assert.notEqual(byOperation.get("view").extensions.assistant.output, resource.operations.view.output);
232
232
  assert.equal(byOperation.get("create").extensions.assistant.output, resource.operations.create.output);
233
233
  assert.equal(byOperation.get("update").extensions.assistant.output, resource.operations.patch.output);
234
234
  assert.equal(byOperation.get("delete").extensions.assistant.output, resource.operations.delete.output);
235
+ const sparseListContract = byOperation.get("list").extensions.assistant.output.schema.toJsonSchema({ mode: "replace" });
236
+ const sparseListItemContract = Object.values(sparseListContract.definitions)[0];
237
+ assert.deepEqual(sparseListItemContract.required, ["id"]);
238
+ assert.deepEqual(
239
+ byOperation.get("view").extensions.assistant.output.schema.toJsonSchema({ mode: "replace" }).required,
240
+ ["id"]
241
+ );
235
242
 
236
243
  const transform = async (operation, input = {}) => {
237
244
  const definition = byOperation.get(operation);