@cosmicdrift/kumiko-bundled-features 0.235.3 → 0.236.0

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.
@@ -1,179 +1,391 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import {
3
3
  createEntity,
4
+ createRegistry,
4
5
  createSelectField,
5
6
  createTextField,
7
+ defineFeature,
6
8
  } from "@cosmicdrift/kumiko-framework/engine";
7
- import type {
8
- EntityDefinition,
9
- QueryHandlerDef,
10
- ReferenceFieldDef,
11
- } from "@cosmicdrift/kumiko-framework/engine/types";
12
- import { buildToolCatalog } from "../tool-catalog";
13
- import type { RegistrySearchView } from "../types";
14
-
15
- const vendorEntity = createEntity({
9
+ import { z } from "zod";
10
+ import { buildAgentManifest } from "../agent-manifest";
11
+ import { buildToolCatalog, toolNameForQn } from "../tool-catalog";
12
+ import type { AgentManifest, AgentToolMode, RegistrySearchView } from "../types";
13
+
14
+ function isRecord(value: unknown): value is Record<string, unknown> {
15
+ return typeof value === "object" && value !== null && !Array.isArray(value);
16
+ }
17
+
18
+ const widgetEntity = createEntity({
16
19
  fields: {
17
20
  name: createTextField({ searchable: true, filterable: true }),
18
- iban: createTextField({ filterable: true }),
21
+ status: createSelectField({ options: ["open", "closed"] as const, filterable: true }),
19
22
  notes: createTextField(),
20
- status: createSelectField({ options: ["active", "archived"] as const, filterable: true }),
21
23
  },
22
24
  });
23
25
 
24
- const invoiceEntity = createEntity({
25
- fields: {
26
- vendorId: { type: "reference", entity: "vendor", filterable: true } satisfies ReferenceFieldDef,
27
- description: createTextField({ searchable: true }),
28
- },
29
- });
26
+ const orphanEntity = createEntity({ fields: { label: createTextField() } });
30
27
 
31
- const FAKE_HANDLER = {} as QueryHandlerDef;
28
+ function buildCatalogTestFeature() {
29
+ return defineFeature("catalog-test", (r) => {
30
+ // create/update excluded from the generated set: EntityHandlerOptions has no
31
+ // `description`, so a CRUD-generated create/update is never agent-exposed
32
+ // (resolveAgentExposure needs description or agent.expose) — registered
33
+ // explicitly below instead, so open_form's entityEdit-mapping has something
34
+ // to find in manifest.handlers.
35
+ r.crud("widget", widgetEntity, {
36
+ write: { access: { roles: ["Admin"] } },
37
+ read: { access: { roles: ["Admin", "Reader"] } },
38
+ verbs: { create: false, update: false },
39
+ });
40
+ r.writeHandler(
41
+ "widget:create",
42
+ z.object({ name: z.string(), status: z.enum(["open", "closed"]).optional() }),
43
+ async () => ({ isSuccess: true as const, data: { id: "w1", version: 1 } }),
44
+ { access: { roles: ["Admin"] }, description: "Create a widget." },
45
+ );
46
+ r.writeHandler(
47
+ "widget:update",
48
+ z.object({ id: z.string(), version: z.number(), changes: z.record(z.string(), z.unknown()) }),
49
+ async () => ({ isSuccess: true as const, data: { id: "w1", version: 2 } }),
50
+ { access: { roles: ["Admin"] }, description: "Update a widget." },
51
+ );
32
52
 
33
- type EntityFixture = {
34
- readonly qn: string;
35
- readonly entityName: string;
36
- readonly entity: EntityDefinition;
37
- readonly searchableFields: readonly string[];
38
- };
53
+ // Entity with no mounted handlers — the "skip when nothing callable" case.
54
+ r.entity("orphan", orphanEntity);
39
55
 
40
- /** Handler-first fake: one entry per MOUNTED `:list` handler, mirroring what
41
- * `registry.getAllQueryHandlers()` + `getHandlerEntity()` would return for a real app. There is
42
- * no `getAllEntities()` here on purpose — an entity with no fixture is exactly "no list handler
43
- * mounted", the case `buildToolCatalog` must skip. */
44
- function fakeRegistry(fixtures: readonly EntityFixture[]): RegistrySearchView {
45
- const byQn = new Map(fixtures.map((f) => [f.qn, FAKE_HANDLER]));
46
- const entityByQn = new Map(fixtures.map((f) => [f.qn, f.entityName]));
47
- const entityByName = new Map(fixtures.map((f) => [f.entityName, f.entity]));
48
- const searchableByName = new Map(fixtures.map((f) => [f.entityName, f.searchableFields]));
49
-
50
- return {
51
- getAllQueryHandlers: () => byQn,
52
- getHandlerEntity: (qn) => entityByQn.get(qn),
53
- getEntity: (entityName) => entityByName.get(entityName),
54
- getSearchableFields: (entityName) => searchableByName.get(entityName) ?? [],
55
- };
56
- }
56
+ r.writeHandler(
57
+ "widget:approve",
58
+ z.object({ id: z.string(), version: z.number(), note: z.string() }),
59
+ async () => ({ isSuccess: true as const, data: { id: "w1", version: 2 } }),
60
+ { access: { roles: ["Admin"] }, description: "Approve a widget." },
61
+ );
57
62
 
58
- function vendorFixture(searchableFields: readonly string[] = []): EntityFixture {
59
- return {
60
- qn: "vendor-feature:query:vendor:list",
61
- entityName: "vendor",
62
- entity: vendorEntity,
63
- searchableFields,
64
- };
65
- }
63
+ // No entity mapping (bare name, not a CRUD verb) — exercises the detailQn-less write path.
64
+ r.writeHandler("ping", z.object({}), async () => ({ isSuccess: true as const, data: {} }), {
65
+ access: { roles: ["Admin"] },
66
+ description: "Ping the service.",
67
+ });
66
68
 
67
- function invoiceFixture(searchableFields: readonly string[] = []): EntityFixture {
68
- return {
69
- qn: "invoice-feature:query:invoice:list",
70
- entityName: "invoice",
71
- entity: invoiceEntity,
72
- searchableFields,
73
- };
74
- }
69
+ r.queryHandler("widget:summary", z.object({ id: z.string() }), async () => ({ count: 0 }), {
70
+ access: { roles: ["Admin"] },
71
+ description: "Widget summary stats.",
72
+ });
75
73
 
76
- describe("buildToolCatalog", () => {
77
- test("generates a search_<entity> tool only when searchable fields exist", () => {
78
- const registry = fakeRegistry([vendorFixture(["name"]), invoiceFixture(["description"])]);
79
- const catalog = buildToolCatalog(registry);
80
- const names = catalog.tools.map((t) => t.name);
74
+ // Two valid-but-distinct QNs whose sanitized tool name collides: every run of
75
+ // non-identifier characters collapses to a single "_", so a single vs. a double
76
+ // hyphen both normalize to "..._approve_x". QN segments can't contain "_" themselves
77
+ // (registry validation), so the collision has to come from hyphen-collapsing instead.
78
+ r.writeHandler(
79
+ "widget:approve--x",
80
+ z.object({ id: z.string() }),
81
+ async () => ({ isSuccess: true as const, data: {} }),
82
+ { access: { roles: ["Admin"] }, description: "Approve X (double hyphen)." },
83
+ );
84
+ r.writeHandler(
85
+ "widget:approve-x",
86
+ z.object({ id: z.string() }),
87
+ async () => ({ isSuccess: true as const, data: {} }),
88
+ { access: { roles: ["Admin"] }, description: "Approve X." },
89
+ );
81
90
 
82
- expect(names).toContain("search_vendor");
83
- expect(names).toContain("search_invoice");
91
+ r.screen({
92
+ id: "widget-detail",
93
+ type: "custom",
94
+ renderer: { react: "stub" },
95
+ detailFor: "widget",
96
+ });
97
+ r.screen({
98
+ id: "widget-edit",
99
+ type: "entityEdit",
100
+ entity: "widget",
101
+ layout: { sections: [{ title: "s", fields: ["name"] }] },
102
+ });
103
+ r.screen({
104
+ id: "widget-approve-form",
105
+ type: "actionForm",
106
+ handler: "catalog-test:write:widget:approve",
107
+ fields: { note: createTextField() },
108
+ layout: { sections: [{ title: "s", fields: ["note"] }] },
109
+ });
84
110
  });
111
+ }
85
112
 
86
- test("skips search_<entity> when the entity has no searchable fields", () => {
87
- const registry = fakeRegistry([vendorFixture([])]);
88
- const catalog = buildToolCatalog(registry);
89
- expect(catalog.tools.map((t) => t.name)).not.toContain("search_vendor");
90
- });
113
+ type CatalogRequest = {
114
+ readonly mode: AgentToolMode;
115
+ readonly roles: readonly string[];
116
+ readonly locale: string;
117
+ };
91
118
 
92
- test("skips an entity entirely when it has no mounted :list handler", () => {
93
- // getHandlerEntity resolves it, but the qn doesn't end in ":<entity>:list" — e.g. a
94
- // :detail handler picked up by a naive "any handler mentioning this entity" scan.
95
- const registry: RegistrySearchView = {
96
- getAllQueryHandlers: () => new Map([["vendor-feature:query:vendor:detail", FAKE_HANDLER]]),
97
- getHandlerEntity: () => "vendor",
98
- getEntity: () => vendorEntity,
99
- getSearchableFields: () => ["name"],
100
- };
101
- const catalog = buildToolCatalog(registry);
102
- expect(catalog.tools).toEqual([]);
119
+ function buildCatalog({ mode, roles, locale }: CatalogRequest) {
120
+ const registry = createRegistry([buildCatalogTestFeature()]);
121
+ const manifest = buildAgentManifest(registry, { locale, roles });
122
+ return buildToolCatalog(registry, manifest, { mode });
123
+ }
124
+
125
+ const ADMIN: CatalogRequest = { mode: "edit", roles: ["Admin"], locale: "en" };
126
+ const READER: CatalogRequest = { mode: "edit", roles: ["Reader"], locale: "en" };
127
+
128
+ describe("buildToolCatalog — search_<entity> / find_<entity>_by_<field> (existing generation)", () => {
129
+ test("generates search_<entity> and find_<entity>_by_<field> for a mounted :list handler", () => {
130
+ const catalog = buildCatalog(ADMIN);
131
+ const names = catalog.tools.map((t) => t.name);
132
+ expect(names).toContain("search_widget");
133
+ expect(names).toContain("find_widget_by_name");
134
+ expect(names).toContain("find_widget_by_status");
135
+ expect(names).not.toContain("find_widget_by_notes");
103
136
  });
104
137
 
105
- test("generates one find_<entity>_by_<field> tool per filterable field", () => {
106
- const registry = fakeRegistry([vendorFixture(["name"])]);
107
- const catalog = buildToolCatalog(registry);
138
+ test("skips an entity with no mounted :list handler", () => {
139
+ const catalog = buildCatalog(ADMIN);
108
140
  const names = catalog.tools.map((t) => t.name);
141
+ expect(names).not.toContain("search_orphan");
142
+ expect(names).not.toContain("get_orphan");
143
+ expect(names).not.toContain("list_orphan");
144
+ });
109
145
 
110
- expect(names).toContain("find_vendor_by_name");
111
- expect(names).toContain("find_vendor_by_iban");
112
- expect(names).toContain("find_vendor_by_status");
113
- expect(names).not.toContain("find_vendor_by_notes"); // not filterable
146
+ test("both catalog halves read the same role source: an unknown role gets neither half", () => {
147
+ const catalog = buildCatalog({ mode: "edit", roles: ["Nobody"], locale: "en" });
148
+ const names = catalog.tools.map((t) => t.name);
149
+ // Registry-derived half and manifest-derived half both vanish for a role that matches
150
+ // nothing — they can only agree because both follow `manifest.builtForRoles`.
151
+ expect(names).not.toContain("search_widget");
152
+ expect(names).not.toContain("find_widget_by_name");
153
+ expect(names).not.toContain("get_widget");
154
+ expect(names).not.toContain("list_widget");
155
+ expect(names).not.toContain(toolNameForQn("catalog-test:query:widget:summary"));
156
+ expect(names).not.toContain(toolNameForQn("catalog-test:write:widget:approve"));
157
+ expect(names).toEqual(["navigate", "ask_user"]);
114
158
  });
115
159
 
116
- test("select field becomes a string schema with an enum of its options", () => {
117
- const registry = fakeRegistry([vendorFixture()]);
118
- const catalog = buildToolCatalog(registry);
119
- const statusTool = catalog.tools.find((t) => t.name === "find_vendor_by_status");
160
+ test("@ts-expect-error: ToolCatalogOptions has no roles channel of its own", () => {
161
+ const registry = createRegistry([buildCatalogTestFeature()]);
162
+ const manifest = buildAgentManifest(registry, { locale: "en", roles: ["Admin"] });
163
+ // @ts-expect-error: roles must come from the manifest; a second channel could desync the halves.
164
+ const catalog = buildToolCatalog(registry, manifest, { mode: "edit", roles: ["Nobody"] });
165
+ expect(catalog.tools.length).toBeGreaterThan(0);
166
+ });
167
+ });
120
168
 
121
- expect(statusTool?.inputSchema).toEqual({
169
+ describe("buildToolCatalog — get_<entity> / list_<entity>", () => {
170
+ test("get_<entity> fetches by id via the mounted :detail handler", () => {
171
+ const catalog = buildCatalog(ADMIN);
172
+ const tool = catalog.tools.find((t) => t.name === "get_widget");
173
+ expect(tool?.inputSchema).toEqual({
122
174
  type: "object",
123
- properties: { status: { type: "string", enum: ["active", "archived"] } },
124
- required: ["status"],
175
+ properties: { id: { type: "string", description: "Record id (uuid)" } },
176
+ required: ["id"],
125
177
  additionalProperties: false,
126
178
  });
179
+ expect(catalog.dispatchTable.get("get_widget")).toEqual({
180
+ kind: "server",
181
+ op: "query",
182
+ qn: "catalog-test:query:widget:detail",
183
+ risk: "low",
184
+ entity: "widget",
185
+ detail: true,
186
+ });
127
187
  });
128
188
 
129
- test("reference field becomes a string schema describing the referenced entity", () => {
130
- const registry = fakeRegistry([invoiceFixture()]);
131
- const catalog = buildToolCatalog(registry);
132
- const vendorIdTool = catalog.tools.find((t) => t.name === "find_invoice_by_vendorId");
133
-
134
- expect(vendorIdTool?.inputSchema).toEqual({
135
- type: "object",
189
+ test("list_<entity> names searchable + filterable fields and states the total count", () => {
190
+ const catalog = buildCatalog(ADMIN);
191
+ const tool = catalog.tools.find((t) => t.name === "list_widget");
192
+ expect(tool?.description).toContain("name");
193
+ expect(tool?.description).toContain("status");
194
+ expect(tool?.description.toLowerCase()).toContain("total");
195
+ expect(tool?.inputSchema).toMatchObject({
136
196
  properties: {
137
- vendorId: { type: "string", description: 'ID referencing "vendor"' },
197
+ search: { type: "string" },
198
+ filters: {
199
+ type: "array",
200
+ items: { properties: { field: { enum: ["name", "status"] } } },
201
+ },
202
+ limit: { type: "integer", minimum: 1, maximum: 200 },
138
203
  },
139
- required: ["vendorId"],
204
+ required: [],
140
205
  additionalProperties: false,
141
206
  });
207
+ expect(catalog.dispatchTable.get("list_widget")).toEqual({
208
+ kind: "server",
209
+ op: "query",
210
+ qn: "catalog-test:query:widget:list",
211
+ risk: "low",
212
+ entity: "widget",
213
+ list: { searchableFields: ["name"], filterableFields: ["name", "status"] },
214
+ });
215
+ });
216
+ });
217
+
218
+ describe("buildToolCatalog — handler-derived query/write tools", () => {
219
+ test("a described, non-CRUD query handler becomes its own tool, role-gated", () => {
220
+ const admin = buildCatalog(ADMIN);
221
+ const reader = buildCatalog(READER);
222
+ expect(admin.tools.map((t) => t.name)).toContain("catalog_test_widget_summary");
223
+ expect(reader.tools.map((t) => t.name)).not.toContain("catalog_test_widget_summary");
224
+ });
225
+
226
+ test("a described write handler becomes a tool with injectsVersion + a stripped schema when a readable detail handler exists", () => {
227
+ const catalog = buildCatalog(ADMIN);
228
+ const name = toolNameForQn("catalog-test:write:widget:approve");
229
+ const tool = catalog.tools.find((t) => t.name === name);
230
+ const schema = tool?.inputSchema ?? {};
231
+ const properties = isRecord(schema["properties"]) ? schema["properties"] : {};
232
+ const required = Array.isArray(schema["required"]) ? schema["required"] : [];
233
+
234
+ expect(properties).toHaveProperty("id");
235
+ expect(properties).toHaveProperty("note");
236
+ expect(properties).not.toHaveProperty("version");
237
+ expect(required).toContain("id");
238
+ expect(required).toContain("note");
239
+ expect(required).not.toContain("version");
240
+
241
+ expect(catalog.dispatchTable.get(name)).toEqual({
242
+ kind: "server",
243
+ op: "write",
244
+ qn: "catalog-test:write:widget:approve",
245
+ risk: "mid",
246
+ entity: "widget",
247
+ detailQn: "catalog-test:query:widget:detail",
248
+ injectsVersion: true,
249
+ });
250
+ });
251
+
252
+ test("a write handler with no entity mapping gets no detailQn and no version injection", () => {
253
+ const catalog = buildCatalog(ADMIN);
254
+ const name = toolNameForQn("catalog-test:write:ping");
255
+ expect(catalog.dispatchTable.get(name)).toEqual({
256
+ kind: "server",
257
+ op: "write",
258
+ qn: "catalog-test:write:ping",
259
+ risk: "mid",
260
+ });
261
+ });
262
+
263
+ test("read-only mode produces no write tools and no open_form descriptor", () => {
264
+ const readOnly = buildCatalog({ mode: "read-only", roles: ["Admin"], locale: "en" });
265
+ const names = readOnly.tools.map((t) => t.name);
266
+ expect(names).not.toContain(toolNameForQn("catalog-test:write:widget:approve"));
267
+ expect(names).not.toContain(toolNameForQn("catalog-test:write:ping"));
268
+ expect(readOnly.dispatchTable.has("open_form")).toBe(false);
269
+ // Read-side tools + client tools stay available.
270
+ expect(names).toContain("get_widget");
271
+ expect(names).toContain("list_widget");
272
+ expect(names).toContain("navigate");
273
+ expect(names).toContain("ask_user");
142
274
  });
143
275
 
144
- test("search tool description lists every searchable field", () => {
145
- const registry = fakeRegistry([vendorFixture(["name", "iban"])]);
146
- const catalog = buildToolCatalog(registry);
147
- const searchTool = catalog.tools.find((t) => t.name === "search_vendor");
276
+ test("a name collision between two handler-derived tools skips the later one instead of shadowing it", () => {
277
+ const catalog = buildCatalog(ADMIN);
278
+ const collidingName = toolNameForQn("catalog-test:write:widget:approve-x");
279
+ expect(collidingName).toBe(toolNameForQn("catalog-test:write:widget:approve--x"));
280
+
281
+ const matches = catalog.tools.filter((t) => t.name === collidingName);
282
+ expect(matches).toHaveLength(1);
283
+ // manifest.handlers is qn-sorted; "approve--x" < "approve-x" by code point (the extra
284
+ // "-" beats "x" at the first differing position), so it's added first and wins the name.
285
+ expect(catalog.dispatchTable.get(collidingName)).toMatchObject({
286
+ qn: "catalog-test:write:widget:approve--x",
287
+ });
288
+ });
289
+ });
148
290
 
149
- expect(searchTool?.description).toContain("name, iban");
291
+ describe("buildToolCatalog — client tools", () => {
292
+ test("navigate maps the entity's detailFor screen and lists every manifest screen id", () => {
293
+ const catalog = buildCatalog(ADMIN);
294
+ const descriptor = catalog.dispatchTable.get("navigate");
295
+ expect(descriptor?.kind).toBe("client");
296
+ if (descriptor?.kind !== "client" || descriptor.op !== "navigate")
297
+ throw new Error("wrong kind");
298
+ // Screen ids in the manifest are feature-qualified ("<feature>:screen:<id>") —
299
+ // see registry-ingest.ts's populateScreensNavWorkspaces, which overwrites the
300
+ // feature-local short id with the qualified name before it reaches the registry.
301
+ expect(descriptor.entityScreens.get("widget")).toBe("catalog-test:screen:widget-detail");
302
+ expect(descriptor.screenIds.has("catalog-test:screen:widget-edit")).toBe(true);
303
+ expect(descriptor.screenIds.has("catalog-test:screen:widget-approve-form")).toBe(true);
150
304
  });
151
305
 
152
- test("empty registry produces an empty catalog", () => {
153
- const registry = fakeRegistry([]);
154
- const catalog = buildToolCatalog(registry);
155
- expect(catalog.tools).toEqual([]);
156
- expect(catalog.dispatchTable.size).toBe(0);
306
+ test("open_form maps the actionForm handler and the entityEdit create/update handlers", () => {
307
+ const catalog = buildCatalog(ADMIN);
308
+ const descriptor = catalog.dispatchTable.get("open_form");
309
+ if (descriptor?.kind !== "client" || descriptor.op !== "open_form")
310
+ throw new Error("wrong kind");
311
+ expect(descriptor.formScreens.get("catalog-test:write:widget:approve")).toBe(
312
+ "catalog-test:screen:widget-approve-form",
313
+ );
314
+ expect(descriptor.formScreens.get("catalog-test:write:widget:create")).toBe(
315
+ "catalog-test:screen:widget-edit",
316
+ );
317
+ expect(descriptor.formScreens.get("catalog-test:write:widget:update")).toBe(
318
+ "catalog-test:screen:widget-edit",
319
+ );
320
+ expect(catalog.tools.map((t) => t.name)).not.toContain("open_form");
157
321
  });
158
322
 
159
- test("dispatchTable maps search_<entity> to a search descriptor carrying the real qn", () => {
160
- const registry = fakeRegistry([vendorFixture(["name"])]);
161
- const catalog = buildToolCatalog(registry);
162
- expect(catalog.dispatchTable.get("search_vendor")).toEqual({
163
- kind: "search",
164
- entityName: "vendor",
165
- qn: "vendor-feature:query:vendor:list",
323
+ test("ask_user is always present with a fixed schema", () => {
324
+ const catalog = buildCatalog(READER);
325
+ const tool = catalog.tools.find((t) => t.name === "ask_user");
326
+ expect(tool?.inputSchema).toEqual({
327
+ type: "object",
328
+ properties: {
329
+ question: { type: "string" },
330
+ options: { type: "array", items: { type: "string" } },
331
+ },
332
+ required: ["question"],
333
+ additionalProperties: false,
166
334
  });
167
335
  });
336
+ });
168
337
 
169
- test("dispatchTable maps find_<entity>_by_<field> to a findBy descriptor carrying the real qn", () => {
170
- const registry = fakeRegistry([vendorFixture()]);
171
- const catalog = buildToolCatalog(registry);
172
- expect(catalog.dispatchTable.get("find_vendor_by_iban")).toEqual({
173
- kind: "findBy",
174
- entityName: "vendor",
175
- fieldName: "iban",
176
- qn: "vendor-feature:query:vendor:list",
338
+ describe("buildToolCatalog — built-in client tool names vs. a colliding handler-derived tool", () => {
339
+ test("a handler-derived tool sharing the built-in 'navigate' name wins; the built-in is skipped rather than shadowing it", () => {
340
+ const registry: RegistrySearchView = {
341
+ getAllQueryHandlers: () => new Map<string, never>(),
342
+ getHandlerEntity: () => undefined,
343
+ getEntity: () => undefined,
344
+ getSearchableFields: () => [],
345
+ };
346
+ const manifest: AgentManifest = {
347
+ builtForRoles: ["Admin"],
348
+ features: [],
349
+ entities: [],
350
+ handlers: [
351
+ {
352
+ // toolNameForQn drops the "query" verb segment; a bare 2-segment qn collapses
353
+ // to exactly "navigate" — the same name buildNavigateTool always produces.
354
+ qn: "query:navigate",
355
+ kind: "query",
356
+ description: "A handler that happens to be named like the built-in navigate tool.",
357
+ risk: "low",
358
+ inputSchema: {
359
+ type: "object",
360
+ properties: {},
361
+ required: [],
362
+ additionalProperties: false,
363
+ },
364
+ },
365
+ ],
366
+ screens: [],
367
+ navs: [],
368
+ workspaces: [],
369
+ tenantSettings: { locale: "en" },
370
+ };
371
+
372
+ const catalog = buildToolCatalog(registry, manifest, { mode: "edit" });
373
+
374
+ const matches = catalog.tools.filter((t) => t.name === "navigate");
375
+ expect(matches).toHaveLength(1);
376
+ expect(catalog.dispatchTable.get("navigate")).toEqual({
377
+ kind: "server",
378
+ op: "query",
379
+ qn: "query:navigate",
380
+ risk: "low",
177
381
  });
178
382
  });
179
383
  });
384
+
385
+ describe("toolNameForQn", () => {
386
+ test("drops the verb segment, joins with underscores, sanitizes non-identifier characters", () => {
387
+ expect(toolNameForQn("agent-tools-test-vendor:write:vendor:approve")).toBe(
388
+ "agent_tools_test_vendor_vendor_approve",
389
+ );
390
+ });
391
+ });
@@ -16,6 +16,7 @@ import {
16
16
  type TestStack,
17
17
  unsafeCreateEntityTable,
18
18
  } from "@cosmicdrift/kumiko-framework/stack";
19
+ import { buildAgentManifest } from "../agent-manifest";
19
20
  import { buildToolCatalog } from "../tool-catalog";
20
21
  import { dispatchToolCall } from "../tool-dispatch";
21
22
 
@@ -61,18 +62,25 @@ const readerA = createTestUser({ roles: ["Reader"], id: adminA.id, tenantId: adm
61
62
  const noRoleA = createTestUser({ roles: [], id: adminA.id, tenantId: adminA.tenantId });
62
63
 
63
64
  describe("dispatchToolCall — real <entity>:list pipeline", () => {
65
+ function catalogFor(roles: readonly string[]) {
66
+ const manifest = buildAgentManifest(stack.registry, { locale: "en", roles });
67
+ return buildToolCatalog(stack.registry, manifest, { mode: "edit" });
68
+ }
69
+
64
70
  test("find_vendor_by_iban never leaks another tenant's row, even on an identical IBAN", async () => {
65
71
  await stack.dispatcher.write(VENDOR_CREATE_QN, { name: "Acme A", iban: "DE-SAME" }, adminA);
66
72
  await stack.dispatcher.write(VENDOR_CREATE_QN, { name: "Acme B", iban: "DE-SAME" }, adminB);
67
73
 
68
- const catalog = buildToolCatalog(stack.registry);
69
- const result = await dispatchToolCall(
70
- stack.dispatcher,
71
- readerA,
72
- "find_vendor_by_iban",
73
- { iban: "DE-SAME" },
74
- catalog.dispatchTable,
75
- );
74
+ const catalog = catalogFor(readerA.roles);
75
+ const result = await dispatchToolCall({
76
+ dispatcher: stack.dispatcher,
77
+ user: readerA,
78
+ toolName: "find_vendor_by_iban",
79
+ input: { iban: "DE-SAME" },
80
+ dispatchTable: catalog.dispatchTable,
81
+ runId: "run-1",
82
+ toolCallId: "call-1",
83
+ });
76
84
 
77
85
  expect(result.ok).toBe(true);
78
86
  if (!result.ok) throw new Error("unreachable");
@@ -84,14 +92,18 @@ describe("dispatchToolCall — real <entity>:list pipeline", () => {
84
92
  test("a caller without the list handler's required role gets denied, not an empty ok result", async () => {
85
93
  await stack.dispatcher.write(VENDOR_CREATE_QN, { name: "Acme A", iban: "DE-SAME" }, adminA);
86
94
 
87
- const catalog = buildToolCatalog(stack.registry);
88
- const result = await dispatchToolCall(
89
- stack.dispatcher,
90
- noRoleA,
91
- "find_vendor_by_iban",
92
- { iban: "DE-SAME" },
93
- catalog.dispatchTable,
94
- );
95
+ // Build the catalog for the READER role (the list handler's actual gate) so
96
+ // find_vendor_by_iban is generated at all; noRoleA then calls it without any role.
97
+ const catalog = catalogFor(readerA.roles);
98
+ const result = await dispatchToolCall({
99
+ dispatcher: stack.dispatcher,
100
+ user: noRoleA,
101
+ toolName: "find_vendor_by_iban",
102
+ input: { iban: "DE-SAME" },
103
+ dispatchTable: catalog.dispatchTable,
104
+ runId: "run-1",
105
+ toolCallId: "call-2",
106
+ });
95
107
 
96
108
  expect(result.ok).toBe(false);
97
109
  if (result.ok) throw new Error("unreachable");