@slashfi/agents-sdk 0.73.0 → 0.74.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/dist/adk-tools.d.ts +1 -1
- package/dist/adk-tools.d.ts.map +1 -1
- package/dist/adk-tools.js +122 -26
- package/dist/adk-tools.js.map +1 -1
- package/dist/cjs/adk-tools.js +122 -26
- package/dist/cjs/adk-tools.js.map +1 -1
- package/dist/cjs/config-store.js +10 -1
- package/dist/cjs/config-store.js.map +1 -1
- package/dist/cjs/define-config.js +9 -2
- package/dist/cjs/define-config.js.map +1 -1
- package/dist/config-store.d.ts.map +1 -1
- package/dist/config-store.js +10 -1
- package/dist/config-store.js.map +1 -1
- package/dist/define-config.d.ts +23 -3
- package/dist/define-config.d.ts.map +1 -1
- package/dist/define-config.js +9 -2
- package/dist/define-config.js.map +1 -1
- package/dist/validate.d.ts +8 -8
- package/package.json +1 -1
- package/src/adk-tools.ts +177 -36
- package/src/config-store.ts +9 -1
- package/src/define-config.ts +25 -4
- package/src/ref-naming.test.ts +351 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for the `ref` naming contract introduced in 0.74:
|
|
3
|
+
*
|
|
4
|
+
* - `RefEntry` gains an optional `name?` field for the local identifier.
|
|
5
|
+
* The legacy `as?` field still parses for backward compat.
|
|
6
|
+
* - `normalizeRef` resolves the identifier as `name ?? as ?? ref`, so
|
|
7
|
+
* all lookup paths (get, list, update, remove) accept entries written
|
|
8
|
+
* in either shape.
|
|
9
|
+
* - `createRefTool` (the adk-tools.ts MCP tool) drops `as` from its
|
|
10
|
+
* schema and defaults `ref` to `name` on add, so "Add a ref called X"
|
|
11
|
+
* via an LLM that picks either field lands on the same stored
|
|
12
|
+
* `{ ref: 'X' }` entry.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { describe, expect, test } from "bun:test";
|
|
16
|
+
import { createAdkTools } from "./adk-tools";
|
|
17
|
+
import type { FsStore } from "./agent-definitions/config";
|
|
18
|
+
import { createAdk } from "./index";
|
|
19
|
+
import type { ToolContext } from "./types";
|
|
20
|
+
|
|
21
|
+
function createMemoryFs(): FsStore {
|
|
22
|
+
const files = new Map<string, string>();
|
|
23
|
+
return {
|
|
24
|
+
async readFile(path: string) {
|
|
25
|
+
return files.get(path) ?? null;
|
|
26
|
+
},
|
|
27
|
+
async writeFile(path: string, content: string) {
|
|
28
|
+
files.set(path, content);
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Read a known-present file and parse it as JSON, with typed assertion. */
|
|
34
|
+
async function readJson<T = unknown>(fs: FsStore, path: string): Promise<T> {
|
|
35
|
+
const raw = await fs.readFile(path);
|
|
36
|
+
if (raw === null) {
|
|
37
|
+
throw new Error(`Expected ${path} to exist`);
|
|
38
|
+
}
|
|
39
|
+
return JSON.parse(raw) as T;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// ─── RefEntry: name field on write ───────────────────────────────────
|
|
43
|
+
|
|
44
|
+
describe("ref.add — identifier field", () => {
|
|
45
|
+
test("single-instance case stores only `ref` (no `name`/`as`)", async () => {
|
|
46
|
+
const fs = createMemoryFs();
|
|
47
|
+
const adk = createAdk(fs);
|
|
48
|
+
|
|
49
|
+
await adk.ref.add({
|
|
50
|
+
ref: "test-ref",
|
|
51
|
+
scheme: "mcp",
|
|
52
|
+
url: "http://localhost:12345",
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
|
|
56
|
+
fs,
|
|
57
|
+
"consumer-config.json",
|
|
58
|
+
);
|
|
59
|
+
expect(parsed.refs).toHaveLength(1);
|
|
60
|
+
expect(parsed.refs[0].ref).toBe("test-ref");
|
|
61
|
+
expect(parsed.refs[0].name).toBeUndefined();
|
|
62
|
+
expect(parsed.refs[0].as).toBeUndefined();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("aliasing case stores both `ref` and `name`", async () => {
|
|
66
|
+
const fs = createMemoryFs();
|
|
67
|
+
const adk = createAdk(fs);
|
|
68
|
+
|
|
69
|
+
await adk.ref.add({
|
|
70
|
+
ref: "notion",
|
|
71
|
+
name: "work-notion",
|
|
72
|
+
scheme: "mcp",
|
|
73
|
+
url: "http://localhost:12345",
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
|
|
77
|
+
fs,
|
|
78
|
+
"consumer-config.json",
|
|
79
|
+
);
|
|
80
|
+
expect(parsed.refs[0].ref).toBe("notion");
|
|
81
|
+
expect(parsed.refs[0].name).toBe("work-notion");
|
|
82
|
+
// Legacy `as` field is never emitted on new writes.
|
|
83
|
+
expect(parsed.refs[0].as).toBeUndefined();
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// ─── Lookup compatibility: new `name` field works end-to-end ─────────
|
|
88
|
+
|
|
89
|
+
describe("ref lookup — name/as/ref resolution", () => {
|
|
90
|
+
test("entries written with `name` are findable by `name`", async () => {
|
|
91
|
+
const fs = createMemoryFs();
|
|
92
|
+
const adk = createAdk(fs);
|
|
93
|
+
|
|
94
|
+
await adk.ref.add({
|
|
95
|
+
ref: "notion",
|
|
96
|
+
name: "work-notion",
|
|
97
|
+
scheme: "mcp",
|
|
98
|
+
url: "http://localhost:12345",
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
const entry = await adk.ref.get("work-notion");
|
|
102
|
+
expect(entry).not.toBeNull();
|
|
103
|
+
expect(entry?.ref).toBe("notion");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("entries written with `name` return name field when listed", async () => {
|
|
107
|
+
const fs = createMemoryFs();
|
|
108
|
+
const adk = createAdk(fs);
|
|
109
|
+
|
|
110
|
+
await adk.ref.add({
|
|
111
|
+
ref: "notion",
|
|
112
|
+
name: "work-notion",
|
|
113
|
+
scheme: "mcp",
|
|
114
|
+
url: "http://localhost:12345",
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const refs = await adk.ref.list();
|
|
118
|
+
expect(refs).toHaveLength(1);
|
|
119
|
+
expect(refs[0]?.name).toBe("work-notion");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("legacy entries written with `as` remain findable by `as`", async () => {
|
|
123
|
+
// Simulate a consumer-config.json produced by a pre-0.74 client that
|
|
124
|
+
// still writes the `as` field. The read path must still resolve it.
|
|
125
|
+
const fs = createMemoryFs();
|
|
126
|
+
await fs.writeFile(
|
|
127
|
+
"consumer-config.json",
|
|
128
|
+
JSON.stringify({
|
|
129
|
+
refs: [
|
|
130
|
+
{
|
|
131
|
+
ref: "notion",
|
|
132
|
+
as: "work-notion",
|
|
133
|
+
scheme: "mcp",
|
|
134
|
+
url: "http://localhost:12345",
|
|
135
|
+
},
|
|
136
|
+
],
|
|
137
|
+
}),
|
|
138
|
+
);
|
|
139
|
+
const adk = createAdk(fs);
|
|
140
|
+
|
|
141
|
+
const entry = await adk.ref.get("work-notion");
|
|
142
|
+
expect(entry).not.toBeNull();
|
|
143
|
+
expect(entry?.ref).toBe("notion");
|
|
144
|
+
expect(entry?.as).toBe("work-notion");
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("when both `name` and `as` are present, `name` wins", async () => {
|
|
148
|
+
const fs = createMemoryFs();
|
|
149
|
+
await fs.writeFile(
|
|
150
|
+
"consumer-config.json",
|
|
151
|
+
JSON.stringify({
|
|
152
|
+
refs: [
|
|
153
|
+
{
|
|
154
|
+
ref: "notion",
|
|
155
|
+
name: "new-identifier",
|
|
156
|
+
as: "legacy-identifier",
|
|
157
|
+
scheme: "mcp",
|
|
158
|
+
url: "http://localhost:12345",
|
|
159
|
+
},
|
|
160
|
+
],
|
|
161
|
+
}),
|
|
162
|
+
);
|
|
163
|
+
const adk = createAdk(fs);
|
|
164
|
+
|
|
165
|
+
const byNew = await adk.ref.get("new-identifier");
|
|
166
|
+
expect(byNew).not.toBeNull();
|
|
167
|
+
expect(byNew?.ref).toBe("notion");
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
// ─── ref.update: renaming clears the legacy `as` field ───────────────
|
|
172
|
+
|
|
173
|
+
describe("ref.update — name/as handling", () => {
|
|
174
|
+
test("passing `name` in updates sets name and clears legacy `as`", async () => {
|
|
175
|
+
const fs = createMemoryFs();
|
|
176
|
+
await fs.writeFile(
|
|
177
|
+
"consumer-config.json",
|
|
178
|
+
JSON.stringify({
|
|
179
|
+
refs: [
|
|
180
|
+
{
|
|
181
|
+
ref: "notion",
|
|
182
|
+
as: "old-alias",
|
|
183
|
+
scheme: "mcp",
|
|
184
|
+
url: "http://localhost:12345",
|
|
185
|
+
},
|
|
186
|
+
],
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
const adk = createAdk(fs);
|
|
190
|
+
|
|
191
|
+
const ok = await adk.ref.update("old-alias", { name: "new-alias" });
|
|
192
|
+
expect(ok).toBe(true);
|
|
193
|
+
|
|
194
|
+
const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
|
|
195
|
+
fs,
|
|
196
|
+
"consumer-config.json",
|
|
197
|
+
);
|
|
198
|
+
expect(parsed.refs[0].name).toBe("new-alias");
|
|
199
|
+
expect(parsed.refs[0].as).toBeUndefined();
|
|
200
|
+
expect(parsed.refs[0].ref).toBe("notion");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
test("passing only `as` updates the legacy field (pre-0.74 callers)", async () => {
|
|
204
|
+
const fs = createMemoryFs();
|
|
205
|
+
await fs.writeFile(
|
|
206
|
+
"consumer-config.json",
|
|
207
|
+
JSON.stringify({
|
|
208
|
+
refs: [
|
|
209
|
+
{
|
|
210
|
+
ref: "notion",
|
|
211
|
+
as: "first",
|
|
212
|
+
scheme: "mcp",
|
|
213
|
+
url: "http://localhost:12345",
|
|
214
|
+
},
|
|
215
|
+
],
|
|
216
|
+
}),
|
|
217
|
+
);
|
|
218
|
+
const adk = createAdk(fs);
|
|
219
|
+
|
|
220
|
+
const ok = await adk.ref.update("first", { as: "second" });
|
|
221
|
+
expect(ok).toBe(true);
|
|
222
|
+
|
|
223
|
+
const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
|
|
224
|
+
fs,
|
|
225
|
+
"consumer-config.json",
|
|
226
|
+
);
|
|
227
|
+
expect(parsed.refs[0].as).toBe("second");
|
|
228
|
+
});
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ─── Tool surface: the LLM non-determinism scenario ──────────────────
|
|
232
|
+
//
|
|
233
|
+
// When an LLM is prompted with "Add a ref called X", its tool-call
|
|
234
|
+
// arguments can land on either `{ ref: 'X', … }` or `{ name: 'X', … }`
|
|
235
|
+
// depending on sampling. Pre-0.74, the former stored `{ ref: 'X' }`
|
|
236
|
+
// and the latter stored `{ ref: undefined }` (broken lookup). The
|
|
237
|
+
// `add` handler now defaults `ref ??= name` so both paths converge on
|
|
238
|
+
// the same stored entry.
|
|
239
|
+
|
|
240
|
+
describe("ref tool — add operation defaults ref to name", () => {
|
|
241
|
+
function makeRefTool(adk: ReturnType<typeof createAdk>) {
|
|
242
|
+
const tools = createAdkTools({ resolveScope: () => adk });
|
|
243
|
+
const ref = tools.find((t) => t.name === "ref");
|
|
244
|
+
if (!ref) throw new Error("ref tool not found");
|
|
245
|
+
return ref;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
test("LLM sends only `name` → ref defaults to name, entry is findable", async () => {
|
|
249
|
+
const fs = createMemoryFs();
|
|
250
|
+
const adk = createAdk(fs);
|
|
251
|
+
const refTool = makeRefTool(adk);
|
|
252
|
+
|
|
253
|
+
const ctx = {} as ToolContext;
|
|
254
|
+
await refTool.execute(
|
|
255
|
+
{
|
|
256
|
+
operation: "add",
|
|
257
|
+
name: "test-identity-ref",
|
|
258
|
+
scheme: "mcp",
|
|
259
|
+
url: "http://127.0.0.1:33469",
|
|
260
|
+
},
|
|
261
|
+
ctx,
|
|
262
|
+
);
|
|
263
|
+
|
|
264
|
+
const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
|
|
265
|
+
fs,
|
|
266
|
+
"consumer-config.json",
|
|
267
|
+
);
|
|
268
|
+
expect(parsed.refs).toHaveLength(1);
|
|
269
|
+
expect(parsed.refs[0].ref).toBe("test-identity-ref");
|
|
270
|
+
// name was not stored because it equals ref (single-instance case)
|
|
271
|
+
expect(parsed.refs[0].name).toBeUndefined();
|
|
272
|
+
|
|
273
|
+
const entry = await adk.ref.get("test-identity-ref");
|
|
274
|
+
expect(entry).not.toBeNull();
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
test("LLM sends only `ref` → same resulting entry", async () => {
|
|
278
|
+
const fs = createMemoryFs();
|
|
279
|
+
const adk = createAdk(fs);
|
|
280
|
+
const refTool = makeRefTool(adk);
|
|
281
|
+
|
|
282
|
+
const ctx = {} as ToolContext;
|
|
283
|
+
await refTool.execute(
|
|
284
|
+
{
|
|
285
|
+
operation: "add",
|
|
286
|
+
ref: "test-identity-ref",
|
|
287
|
+
scheme: "mcp",
|
|
288
|
+
url: "http://127.0.0.1:33469",
|
|
289
|
+
},
|
|
290
|
+
ctx,
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
|
|
294
|
+
fs,
|
|
295
|
+
"consumer-config.json",
|
|
296
|
+
);
|
|
297
|
+
expect(parsed.refs[0].ref).toBe("test-identity-ref");
|
|
298
|
+
expect(parsed.refs[0].name).toBeUndefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test("LLM sends `ref` + different `name` → stored as canonical + alias", async () => {
|
|
302
|
+
const fs = createMemoryFs();
|
|
303
|
+
const adk = createAdk(fs);
|
|
304
|
+
const refTool = makeRefTool(adk);
|
|
305
|
+
|
|
306
|
+
const ctx = {} as ToolContext;
|
|
307
|
+
await refTool.execute(
|
|
308
|
+
{
|
|
309
|
+
operation: "add",
|
|
310
|
+
ref: "notion",
|
|
311
|
+
name: "work-notion",
|
|
312
|
+
scheme: "mcp",
|
|
313
|
+
url: "http://127.0.0.1:33469",
|
|
314
|
+
},
|
|
315
|
+
ctx,
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
|
|
319
|
+
fs,
|
|
320
|
+
"consumer-config.json",
|
|
321
|
+
);
|
|
322
|
+
expect(parsed.refs[0].ref).toBe("notion");
|
|
323
|
+
expect(parsed.refs[0].name).toBe("work-notion");
|
|
324
|
+
|
|
325
|
+
// The entry is findable by the local identifier (name), not the
|
|
326
|
+
// canonical ref.
|
|
327
|
+
expect(await adk.ref.get("work-notion")).not.toBeNull();
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
test("LLM omits both `ref` and `name` → loud error (no silent bad write)", async () => {
|
|
331
|
+
const fs = createMemoryFs();
|
|
332
|
+
const adk = createAdk(fs);
|
|
333
|
+
const refTool = makeRefTool(adk);
|
|
334
|
+
|
|
335
|
+
const ctx = {} as ToolContext;
|
|
336
|
+
await expect(
|
|
337
|
+
refTool.execute(
|
|
338
|
+
{
|
|
339
|
+
operation: "add",
|
|
340
|
+
scheme: "mcp",
|
|
341
|
+
url: "http://127.0.0.1:33469",
|
|
342
|
+
},
|
|
343
|
+
ctx,
|
|
344
|
+
),
|
|
345
|
+
).rejects.toThrow(/ref|name/);
|
|
346
|
+
|
|
347
|
+
// Nothing got written.
|
|
348
|
+
const raw = await fs.readFile("consumer-config.json");
|
|
349
|
+
expect(raw).toBeNull();
|
|
350
|
+
});
|
|
351
|
+
});
|