@cosmicdrift/kumiko-bundled-features 0.235.4 → 0.236.1
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 +9 -9
- package/src/agent-tools/__tests__/agent-manifest.test.ts +6 -0
- package/src/agent-tools/__tests__/agent-tools.integration.test.ts +199 -0
- package/src/agent-tools/__tests__/tool-catalog.test.ts +340 -128
- package/src/agent-tools/__tests__/tool-dispatch.integration.test.ts +28 -16
- package/src/agent-tools/__tests__/tool-dispatch.test.ts +390 -44
- package/src/agent-tools/agent-manifest.ts +3 -0
- package/src/agent-tools/index.ts +4 -2
- package/src/agent-tools/tool-catalog.ts +457 -17
- package/src/agent-tools/tool-dispatch.ts +334 -43
- package/src/agent-tools/types.ts +61 -1
- package/src/feature-toggles/__tests__/feature-toggles.integration.test.ts +55 -82
- package/src/feature-toggles/feature.ts +24 -15
- package/src/feature-toggles/index.ts +3 -0
- package/src/feature-toggles/toggle-runtime.ts +46 -8
- package/src/feature-toggles/toggle-sync-signal.ts +57 -0
- package/src/notes-history/__tests__/notes-history-ownership.integration.test.ts +202 -0
- package/src/notes-history/entity.ts +51 -32
- package/src/notes-history/feature.ts +39 -7
- package/src/notes-history-user-data/hooks.ts +12 -9
- package/src/shared/has-where-rule.ts +14 -0
- package/src/shared/index.ts +1 -0
- package/src/tags/__tests__/tags-ownership.test.ts +23 -0
- package/src/tags/entity.ts +26 -16
- package/src/tags/feature.ts +44 -5
|
@@ -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
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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.
|
|
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
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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.
|
|
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("
|
|
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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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.
|
|
213
|
+
expect(dispatcher.queryCalls).toHaveLength(0);
|
|
107
214
|
});
|
|
108
215
|
|
|
109
|
-
test("
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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),
|
package/src/agent-tools/index.ts
CHANGED
|
@@ -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";
|