@cosmicdrift/kumiko-bundled-features 0.235.4 → 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,5 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import type { SessionUser } from "@cosmicdrift/kumiko-framework/engine";
3
+ import type { ToolCallRequest, ToolDispatcher } from "../tool-dispatch";
3
4
  import { dispatchToolCall } from "../tool-dispatch";
4
5
  import type { ToolDispatchDescriptor } from "../types";
5
6
 
@@ -19,40 +20,121 @@ const DISPATCH_TABLE = new Map<string, ToolDispatchDescriptor>([
19
20
  qn: "vendor-feature:query:vendor:list",
20
21
  },
21
22
  ],
23
+ [
24
+ "get_vendor",
25
+ {
26
+ kind: "server",
27
+ op: "query",
28
+ qn: "vendor-feature:query:vendor:detail",
29
+ risk: "low",
30
+ entity: "vendor",
31
+ detail: true,
32
+ },
33
+ ],
34
+ [
35
+ "list_vendor",
36
+ {
37
+ kind: "server",
38
+ op: "query",
39
+ qn: "vendor-feature:query:vendor:list",
40
+ risk: "low",
41
+ entity: "vendor",
42
+ list: { searchableFields: ["name"], filterableFields: ["status"] },
43
+ },
44
+ ],
45
+ [
46
+ "vendor_feature_vendor_approve",
47
+ {
48
+ kind: "server",
49
+ op: "write",
50
+ qn: "vendor-feature:write:vendor:approve",
51
+ risk: "mid",
52
+ entity: "vendor",
53
+ detailQn: "vendor-feature:query:vendor:detail",
54
+ injectsVersion: true,
55
+ },
56
+ ],
57
+ [
58
+ "vendor_feature_vendor_ping",
59
+ {
60
+ kind: "server",
61
+ op: "write",
62
+ qn: "vendor-feature:write:vendor:ping",
63
+ risk: "mid",
64
+ },
65
+ ],
66
+ [
67
+ "navigate",
68
+ {
69
+ kind: "client",
70
+ op: "navigate",
71
+ entityScreens: new Map([["vendor", "vendor-detail"]]),
72
+ screenIds: new Set(["vendor-list", "vendor-detail"]),
73
+ },
74
+ ],
75
+ [
76
+ "open_form",
77
+ {
78
+ kind: "client",
79
+ op: "open_form",
80
+ formScreens: new Map([["vendor-feature:write:vendor:approve", "vendor-approve-form"]]),
81
+ },
82
+ ],
83
+ ["ask_user", { kind: "client", op: "ask_user" }],
22
84
  ]);
23
85
 
24
- function recordingDispatcher() {
25
- const calls: { type: string; payload: unknown; user: SessionUser }[] = [];
86
+ function recordingDispatcher(overrides: Partial<ToolDispatcher> = {}): ToolDispatcher & {
87
+ queryCalls: { type: string; payload: unknown; user: SessionUser }[];
88
+ writeCalls: { type: string; payload: unknown; user: SessionUser; requestId?: string }[];
89
+ } {
90
+ const queryCalls: { type: string; payload: unknown; user: SessionUser }[] = [];
91
+ const writeCalls: { type: string; payload: unknown; user: SessionUser; requestId?: string }[] =
92
+ [];
26
93
  return {
27
- calls,
28
- query: async (type: string, payload: unknown, user: SessionUser) => {
29
- calls.push({ type, payload, user });
94
+ queryCalls,
95
+ writeCalls,
96
+ query: async (type, payload, user) => {
97
+ queryCalls.push({ type, payload, user });
30
98
  return { rows: [{ id: "vendor-1" }] };
31
99
  },
100
+ write: async (type, payload, user, requestId) => {
101
+ writeCalls.push({ type, payload, user, requestId });
102
+ return { isSuccess: true, data: { id: "vendor-1", version: 2 } };
103
+ },
104
+ ...overrides,
32
105
  };
33
106
  }
34
107
 
35
- describe("dispatchToolCall", () => {
108
+ function request(overrides: Partial<ToolCallRequest>): ToolCallRequest {
109
+ return {
110
+ dispatcher: recordingDispatcher(),
111
+ user: CALLER,
112
+ toolName: "search_vendor",
113
+ input: {},
114
+ dispatchTable: DISPATCH_TABLE,
115
+ runId: "run-1",
116
+ toolCallId: "call-1",
117
+ ...overrides,
118
+ };
119
+ }
120
+
121
+ describe("dispatchToolCall — search/findBy (unchanged behaviour)", () => {
36
122
  test("unknown tool name returns an error result without calling the dispatcher", async () => {
37
123
  const dispatcher = recordingDispatcher();
38
- const result = await dispatchToolCall(dispatcher, CALLER, "search_ghost", {}, DISPATCH_TABLE);
124
+ const result = await dispatchToolCall(request({ dispatcher, toolName: "search_ghost" }));
39
125
 
40
126
  expect(result).toEqual({ ok: false, error: 'Unknown tool "search_ghost"' });
41
- expect(dispatcher.calls).toHaveLength(0);
127
+ expect(dispatcher.queryCalls).toHaveLength(0);
42
128
  });
43
129
 
44
130
  test("search tool dispatches to <entity>:list with a search payload, using the caller's identity", async () => {
45
131
  const dispatcher = recordingDispatcher();
46
132
  const result = await dispatchToolCall(
47
- dispatcher,
48
- CALLER,
49
- "search_vendor",
50
- { query: "Müller GmbH" },
51
- DISPATCH_TABLE,
133
+ request({ dispatcher, toolName: "search_vendor", input: { query: "Müller GmbH" } }),
52
134
  );
53
135
 
54
136
  expect(result).toEqual({ ok: true, data: { rows: [{ id: "vendor-1" }] } });
55
- expect(dispatcher.calls).toEqual([
137
+ expect(dispatcher.queryCalls).toEqual([
56
138
  {
57
139
  type: "vendor-feature:query:vendor:list",
58
140
  payload: { search: "Müller GmbH", limit: 10 },
@@ -61,26 +143,18 @@ describe("dispatchToolCall", () => {
61
143
  ]);
62
144
  });
63
145
 
64
- test("search tool without a query argument returns an error, no dispatcher call", async () => {
65
- const dispatcher = recordingDispatcher();
66
- const result = await dispatchToolCall(dispatcher, CALLER, "search_vendor", {}, DISPATCH_TABLE);
67
-
68
- expect(result.ok).toBe(false);
69
- expect(dispatcher.calls).toHaveLength(0);
70
- });
71
-
72
146
  test("findBy tool dispatches to <entity>:list with an exact filter payload", async () => {
73
147
  const dispatcher = recordingDispatcher();
74
148
  const result = await dispatchToolCall(
75
- dispatcher,
76
- CALLER,
77
- "find_vendor_by_iban",
78
- { iban: "DE89370400440532013000" },
79
- DISPATCH_TABLE,
149
+ request({
150
+ dispatcher,
151
+ toolName: "find_vendor_by_iban",
152
+ input: { iban: "DE89370400440532013000" },
153
+ }),
80
154
  );
81
155
 
82
156
  expect(result).toEqual({ ok: true, data: { rows: [{ id: "vendor-1" }] } });
83
- expect(dispatcher.calls).toEqual([
157
+ expect(dispatcher.queryCalls).toEqual([
84
158
  {
85
159
  type: "vendor-feature:query:vendor:list",
86
160
  payload: {
@@ -92,35 +166,307 @@ describe("dispatchToolCall", () => {
92
166
  ]);
93
167
  });
94
168
 
95
- test("findBy tool without its required field argument returns an error, no dispatcher call", async () => {
169
+ test("a dispatcher rejection (e.g. missing handler or failed cap check) becomes an error result, not a throw", async () => {
170
+ const dispatcher = recordingDispatcher({
171
+ query: async () => {
172
+ throw new Error("no handler registered for qn 'vendor:list'");
173
+ },
174
+ });
175
+
176
+ const result = await dispatchToolCall(
177
+ request({ dispatcher, toolName: "search_vendor", input: { query: "anything" } }),
178
+ );
179
+
180
+ expect(result).toEqual({ ok: false, error: "no handler registered for qn 'vendor:list'" });
181
+ });
182
+ });
183
+
184
+ describe("dispatchToolCall — get_/list_ server query tools", () => {
185
+ test("get_<entity> requires a non-empty string id, then queries the detail handler with { id }", async () => {
186
+ const dispatcher = recordingDispatcher();
187
+ const missing = await dispatchToolCall(
188
+ request({ dispatcher, toolName: "get_vendor", input: {} }),
189
+ );
190
+ expect(missing.ok).toBe(false);
191
+ expect(dispatcher.queryCalls).toHaveLength(0);
192
+
193
+ const result = await dispatchToolCall(
194
+ request({ dispatcher, toolName: "get_vendor", input: { id: "vendor-1" } }),
195
+ );
196
+ expect(result).toEqual({ ok: true, data: { rows: [{ id: "vendor-1" }] } });
197
+ expect(dispatcher.queryCalls).toEqual([
198
+ { type: "vendor-feature:query:vendor:detail", payload: { id: "vendor-1" }, user: CALLER },
199
+ ]);
200
+ });
201
+
202
+ test("list_<entity> rejects a filter field outside the descriptor's allowlist", async () => {
96
203
  const dispatcher = recordingDispatcher();
97
204
  const result = await dispatchToolCall(
98
- dispatcher,
99
- CALLER,
100
- "find_vendor_by_iban",
101
- {},
102
- DISPATCH_TABLE,
205
+ request({
206
+ dispatcher,
207
+ toolName: "list_vendor",
208
+ input: { filters: [{ field: "notFilterable", op: "eq", value: "x" }] },
209
+ }),
103
210
  );
104
211
 
105
212
  expect(result.ok).toBe(false);
106
- expect(dispatcher.calls).toHaveLength(0);
213
+ expect(dispatcher.queryCalls).toHaveLength(0);
107
214
  });
108
215
 
109
- test("a dispatcher rejection (e.g. missing handler or failed cap check) becomes an error result, not a throw", async () => {
110
- const dispatcher = {
216
+ test("list_<entity> rejects an unsupported filter operator", async () => {
217
+ const dispatcher = recordingDispatcher();
218
+ const result = await dispatchToolCall(
219
+ request({
220
+ dispatcher,
221
+ toolName: "list_vendor",
222
+ input: { filters: [{ field: "status", op: "like", value: "x" }] },
223
+ }),
224
+ );
225
+
226
+ expect(result.ok).toBe(false);
227
+ expect(dispatcher.queryCalls).toHaveLength(0);
228
+ });
229
+
230
+ test("list_<entity> builds the entityListSchema payload with totalCount + clamped limit", async () => {
231
+ const dispatcher = recordingDispatcher();
232
+ const result = await dispatchToolCall(
233
+ request({
234
+ dispatcher,
235
+ toolName: "list_vendor",
236
+ input: {
237
+ search: "acme",
238
+ filters: [{ field: "status", op: "eq", value: "active" }],
239
+ limit: 999,
240
+ },
241
+ }),
242
+ );
243
+
244
+ expect(result.ok).toBe(true);
245
+ expect(dispatcher.queryCalls).toEqual([
246
+ {
247
+ type: "vendor-feature:query:vendor:list",
248
+ payload: {
249
+ totalCount: true,
250
+ search: "acme",
251
+ filters: [{ field: "status", op: "eq", value: "active" }],
252
+ limit: 200,
253
+ },
254
+ user: CALLER,
255
+ },
256
+ ]);
257
+ });
258
+ });
259
+
260
+ describe("dispatchToolCall — server write tools", () => {
261
+ test("injectsVersion: reads the current version off the detail handler before writing", async () => {
262
+ const dispatcher = recordingDispatcher({
263
+ query: async (type, payload, user) => {
264
+ dispatcher.queryCalls.push({ type, payload, user });
265
+ expect(type).toBe("vendor-feature:query:vendor:detail");
266
+ return { id: "vendor-1", version: 5 };
267
+ },
268
+ });
269
+ const result = await dispatchToolCall(
270
+ request({
271
+ dispatcher,
272
+ toolName: "vendor_feature_vendor_approve",
273
+ input: { id: "vendor-1", note: "looks good" },
274
+ }),
275
+ );
276
+
277
+ expect(result.ok).toBe(true);
278
+ expect(dispatcher.writeCalls).toEqual([
279
+ {
280
+ type: "vendor-feature:write:vendor:approve",
281
+ payload: { id: "vendor-1", note: "looks good", version: 5 },
282
+ user: CALLER,
283
+ requestId: "run-1:call-1",
284
+ },
285
+ ]);
286
+ // Two detail-handler reads: the pre-write version check and the post-write
287
+ // re-read, both field-level filtered rather than trusting the raw write result.
288
+ expect(dispatcher.queryCalls).toEqual([
289
+ { type: "vendor-feature:query:vendor:detail", payload: { id: "vendor-1" }, user: CALLER },
290
+ { type: "vendor-feature:query:vendor:detail", payload: { id: "vendor-1" }, user: CALLER },
291
+ ]);
292
+ });
293
+
294
+ test("a write rejection becomes an error result, never a throw, and never falls back to the raw payload", async () => {
295
+ const dispatcher = recordingDispatcher({
296
+ query: async () => ({ id: "vendor-1", version: 5 }),
297
+ write: async () => ({
298
+ isSuccess: false,
299
+ error: {
300
+ code: "access_denied",
301
+ httpStatus: 403,
302
+ i18nKey: "errors.access_denied",
303
+ message: "access denied",
304
+ },
305
+ }),
306
+ });
307
+ const result = await dispatchToolCall(
308
+ request({ dispatcher, toolName: "vendor_feature_vendor_approve", input: { id: "vendor-1" } }),
309
+ );
310
+
311
+ expect(result).toEqual({ ok: false, error: "access denied" });
312
+ });
313
+
314
+ test("no detailQn: returns only id/version scalars, never the raw write payload", async () => {
315
+ const dispatcher = recordingDispatcher({
316
+ write: async () => ({
317
+ isSuccess: true,
318
+ data: { id: "vendor-9", version: 1, secret: "leak" },
319
+ }),
320
+ });
321
+ const result = await dispatchToolCall(
322
+ request({ dispatcher, toolName: "vendor_feature_vendor_ping", input: {} }),
323
+ );
324
+
325
+ expect(result).toEqual({ ok: true, data: { id: "vendor-9", version: 1 } });
326
+ });
327
+
328
+ test("no detailQn: falls back to id/version nested under the write result's `data` projection", async () => {
329
+ // Real create/update shape: `{ id, data: <projection>, changes, previous, ... }` — version
330
+ // lives on the projection, not the top level (see dispatchServerWrite's own docstring).
331
+ const dispatcher = recordingDispatcher({
332
+ write: async () => ({
333
+ isSuccess: true,
334
+ data: {
335
+ kind: "save",
336
+ id: "vendor-9",
337
+ data: { id: "vendor-9", version: 1, secret: "leak" },
338
+ changes: { secret: "leak" },
339
+ previous: {},
340
+ },
341
+ }),
342
+ });
343
+ const result = await dispatchToolCall(
344
+ request({ dispatcher, toolName: "vendor_feature_vendor_ping", input: {} }),
345
+ );
346
+
347
+ expect(result).toEqual({ ok: true, data: { id: "vendor-9", version: 1 } });
348
+ });
349
+
350
+ test("detailQn re-read throwing (e.g. a delete) falls back to id/version scalars", async () => {
351
+ // First query call (pre-write version read) succeeds, second (post-write re-read) throws.
352
+ let calls = 0;
353
+ const scriptedDispatcher: ToolDispatcher = {
111
354
  query: async () => {
112
- throw new Error("no handler registered for qn 'vendor:list'");
355
+ calls += 1;
356
+ if (calls === 1) return { version: 5 };
357
+ throw new Error("row not found");
113
358
  },
359
+ write: async () => ({ isSuccess: true, data: { id: "vendor-1", version: 6 } }),
114
360
  };
361
+ const result = await dispatchToolCall(
362
+ request({
363
+ dispatcher: scriptedDispatcher,
364
+ toolName: "vendor_feature_vendor_approve",
365
+ input: { id: "vendor-1" },
366
+ }),
367
+ );
115
368
 
369
+ expect(result).toEqual({ ok: true, data: { id: "vendor-1", version: 6 } });
370
+ });
371
+ });
372
+
373
+ describe("dispatchToolCall — client tools", () => {
374
+ test("navigate resolves { entity, id } to the entity's detail screen", async () => {
116
375
  const result = await dispatchToolCall(
117
- dispatcher,
118
- CALLER,
119
- "search_vendor",
120
- { query: "anything" },
121
- DISPATCH_TABLE,
376
+ request({ toolName: "navigate", input: { entity: "vendor", id: "vendor-1" } }),
122
377
  );
378
+ expect(result).toEqual({
379
+ ok: true,
380
+ data: { kind: "navigate", screenId: "vendor-detail", params: { id: "vendor-1" } },
381
+ });
382
+ });
123
383
 
124
- expect(result).toEqual({ ok: false, error: "no handler registered for qn 'vendor:list'" });
384
+ test("navigate rejects an unknown entity", async () => {
385
+ const result = await dispatchToolCall(
386
+ request({ toolName: "navigate", input: { entity: "ghost", id: "1" } }),
387
+ );
388
+ expect(result.ok).toBe(false);
389
+ });
390
+
391
+ test("navigate resolves { screenId, params } when screenId is in the allowlist", async () => {
392
+ const result = await dispatchToolCall(
393
+ request({
394
+ toolName: "navigate",
395
+ input: { screenId: "vendor-list", params: { tab: "open" } },
396
+ }),
397
+ );
398
+ expect(result).toEqual({
399
+ ok: true,
400
+ data: { kind: "navigate", screenId: "vendor-list", params: { tab: "open" } },
401
+ });
402
+ });
403
+
404
+ test("navigate rejects a screenId outside the manifest allowlist", async () => {
405
+ const result = await dispatchToolCall(
406
+ request({ toolName: "navigate", input: { screenId: "not-a-real-screen" } }),
407
+ );
408
+ expect(result.ok).toBe(false);
409
+ });
410
+
411
+ test("open_form resolves a known handler QN to its form screen", async () => {
412
+ const result = await dispatchToolCall(
413
+ request({
414
+ toolName: "open_form",
415
+ input: { handlerQn: "vendor-feature:write:vendor:approve" },
416
+ }),
417
+ );
418
+ expect(result).toEqual({
419
+ ok: true,
420
+ data: {
421
+ kind: "open_form",
422
+ screenId: "vendor-approve-form",
423
+ handlerQn: "vendor-feature:write:vendor:approve",
424
+ prefill: {},
425
+ },
426
+ });
427
+ });
428
+
429
+ test("open_form rejects an unmapped handler QN", async () => {
430
+ const result = await dispatchToolCall(
431
+ request({ toolName: "open_form", input: { handlerQn: "vendor-feature:write:vendor:ghost" } }),
432
+ );
433
+ expect(result.ok).toBe(false);
434
+ });
435
+
436
+ test("ask_user requires a non-empty question", async () => {
437
+ const missing = await dispatchToolCall(request({ toolName: "ask_user", input: {} }));
438
+ expect(missing.ok).toBe(false);
439
+
440
+ const result = await dispatchToolCall(
441
+ request({ toolName: "ask_user", input: { question: "Which vendor?", options: ["A", "B"] } }),
442
+ );
443
+ expect(result).toEqual({
444
+ ok: true,
445
+ data: { kind: "ask_user", question: "Which vendor?", options: ["A", "B"] },
446
+ });
447
+ });
448
+
449
+ test("ask_user omits options from the result data when not provided", async () => {
450
+ const result = await dispatchToolCall(
451
+ request({ toolName: "ask_user", input: { question: "Which vendor?" } }),
452
+ );
453
+ expect(result).toEqual({ ok: true, data: { kind: "ask_user", question: "Which vendor?" } });
454
+ if (result.ok) {
455
+ expect(result.data).not.toHaveProperty("options");
456
+ }
457
+ });
458
+
459
+ test("ask_user rejects a non-array or mixed-type options argument", async () => {
460
+ const notAnArray = await dispatchToolCall(
461
+ request({ toolName: "ask_user", input: { question: "Which vendor?", options: "A" } }),
462
+ );
463
+ expect(notAnArray.ok).toBe(false);
464
+ if (!notAnArray.ok) expect(notAnArray.error).toContain(`"options"`);
465
+
466
+ const mixedTypes = await dispatchToolCall(
467
+ request({ toolName: "ask_user", input: { question: "Which vendor?", options: ["A", 1] } }),
468
+ );
469
+ expect(mixedTypes.ok).toBe(false);
470
+ if (!mixedTypes.ok) expect(mixedTypes.error).toContain(`"options"`);
125
471
  });
126
472
  });
@@ -266,6 +266,8 @@ function buildScreens(
266
266
  // A detail screen takes the row id; everything else is parameterless.
267
267
  params: screen.detailFor !== undefined ? ["id"] : [],
268
268
  workspaces: screenWorkspaces,
269
+ ...(screen.detailFor !== undefined && { detailFor: screen.detailFor }),
270
+ ...("handler" in screen && typeof screen.handler === "string" && { handler: screen.handler }),
269
271
  });
270
272
  }
271
273
  return result;
@@ -315,6 +317,7 @@ export function buildAgentManifest(
315
317
  const workspaces = buildWorkspaces(workspaceMap, translations, roles);
316
318
 
317
319
  return {
320
+ builtForRoles: [...roles],
318
321
  features: sortedByKey(features, (f) => f.name),
319
322
  entities: sortedByKey(entities, (e) => e.name),
320
323
  handlers: sortedByKey(handlers, (h) => h.qn),
@@ -2,8 +2,8 @@ export type { AgentDocGap, AgentDocGapKind } from "./agent-doc-lint";
2
2
  export { AgentDocGapKinds, findAgentDocGaps, formatAgentDocGap } from "./agent-doc-lint";
3
3
  export { buildAgentManifest } from "./agent-manifest";
4
4
  export { AGENT_TOOLS_FEATURE_NAME, createAgentToolsFeature } from "./feature";
5
- export { buildToolCatalog } from "./tool-catalog";
6
- export type { ToolCallResult, ToolDispatcher } from "./tool-dispatch";
5
+ export { buildToolCatalog, OPEN_FORM_TOOL_NAME, toolNameForQn } from "./tool-catalog";
6
+ export type { ToolCallRequest, ToolCallResult, ToolDispatcher } from "./tool-dispatch";
7
7
  export { dispatchToolCall } from "./tool-dispatch";
8
8
  export type {
9
9
  AgentManifest,
@@ -15,9 +15,11 @@ export type {
15
15
  AgentManifestOptions,
16
16
  AgentManifestScreen,
17
17
  AgentManifestWorkspace,
18
+ AgentToolMode,
18
19
  RegistryManifestView,
19
20
  RegistrySearchView,
20
21
  ToolCatalog,
22
+ ToolCatalogOptions,
21
23
  ToolDefinition,
22
24
  ToolDispatchDescriptor,
23
25
  } from "./types";