@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.
- 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,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
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
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
|
-
|
|
83
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
113
|
+
type CatalogRequest = {
|
|
114
|
+
readonly mode: AgentToolMode;
|
|
115
|
+
readonly roles: readonly string[];
|
|
116
|
+
readonly locale: string;
|
|
117
|
+
};
|
|
91
118
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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("
|
|
106
|
-
const
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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("
|
|
117
|
-
const registry =
|
|
118
|
-
const
|
|
119
|
-
|
|
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
|
-
|
|
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: {
|
|
124
|
-
required: ["
|
|
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("
|
|
130
|
-
const
|
|
131
|
-
const
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
expect(
|
|
135
|
-
|
|
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
|
-
|
|
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: [
|
|
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("
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
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
|
-
|
|
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("
|
|
153
|
-
const
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
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("
|
|
160
|
-
const
|
|
161
|
-
const
|
|
162
|
-
expect(
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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 =
|
|
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
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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");
|