@slashfi/agents-sdk 0.77.0 → 0.77.2

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.
Files changed (59) hide show
  1. package/dist/adk-tools.d.ts +2 -2
  2. package/dist/adk-tools.d.ts.map +1 -1
  3. package/dist/adk-tools.js +258 -118
  4. package/dist/adk-tools.js.map +1 -1
  5. package/dist/adk.js +7 -9
  6. package/dist/adk.js.map +1 -1
  7. package/dist/agent-definitions/config.d.ts.map +1 -1
  8. package/dist/agent-definitions/config.js +12 -14
  9. package/dist/agent-definitions/config.js.map +1 -1
  10. package/dist/cjs/adk-tools.js +258 -118
  11. package/dist/cjs/adk-tools.js.map +1 -1
  12. package/dist/cjs/agent-definitions/config.js +12 -14
  13. package/dist/cjs/agent-definitions/config.js.map +1 -1
  14. package/dist/cjs/config-store.js +34 -17
  15. package/dist/cjs/config-store.js.map +1 -1
  16. package/dist/cjs/define-config.js +5 -7
  17. package/dist/cjs/define-config.js.map +1 -1
  18. package/dist/cjs/index.js.map +1 -1
  19. package/dist/cjs/materialize.js +1 -1
  20. package/dist/cjs/materialize.js.map +1 -1
  21. package/dist/cjs/registry-consumer.js +1 -1
  22. package/dist/cjs/registry.js +33 -2
  23. package/dist/cjs/registry.js.map +1 -1
  24. package/dist/cjs/types.js.map +1 -1
  25. package/dist/config-store.d.ts +3 -3
  26. package/dist/config-store.d.ts.map +1 -1
  27. package/dist/config-store.js +34 -17
  28. package/dist/config-store.js.map +1 -1
  29. package/dist/define-config.d.ts +11 -18
  30. package/dist/define-config.d.ts.map +1 -1
  31. package/dist/define-config.js +5 -7
  32. package/dist/define-config.js.map +1 -1
  33. package/dist/index.d.ts +1 -1
  34. package/dist/index.d.ts.map +1 -1
  35. package/dist/index.js.map +1 -1
  36. package/dist/materialize.js +1 -1
  37. package/dist/materialize.js.map +1 -1
  38. package/dist/registry-consumer.d.ts +1 -1
  39. package/dist/registry-consumer.js +1 -1
  40. package/dist/registry.d.ts.map +1 -1
  41. package/dist/registry.js +33 -2
  42. package/dist/registry.js.map +1 -1
  43. package/dist/types.d.ts +5 -3
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/types.js.map +1 -1
  46. package/dist/validate.d.ts +8 -8
  47. package/package.json +1 -1
  48. package/src/adk-tools.ts +289 -127
  49. package/src/adk.ts +7 -8
  50. package/src/agent-definitions/config.ts +15 -16
  51. package/src/config-store.ts +43 -19
  52. package/src/consumer.test.ts +7 -7
  53. package/src/define-config.ts +11 -20
  54. package/src/index.ts +1 -0
  55. package/src/materialize.ts +1 -1
  56. package/src/ref-naming.test.ts +164 -91
  57. package/src/registry-consumer.ts +1 -1
  58. package/src/registry.ts +40 -2
  59. package/src/types.ts +21 -6
@@ -20,6 +20,7 @@
20
20
  import type { FsStore } from "./agent-definitions/config.js";
21
21
  import type {
22
22
  ConsumerConfig,
23
+ RefAddInput,
23
24
  RefEntry,
24
25
  RegistryEntry,
25
26
  ResolvedRef,
@@ -248,10 +249,10 @@ type AdkRefCallFn = keyof AdkAgentRegistry extends never
248
249
  <A extends AgentPath, T extends ToolsOf<A>>(name: A, tool: T, params: ParamsOf<A, T>) => Promise<CallAgentResponse>;
249
250
 
250
251
  export interface AdkRefApi {
251
- add(entry: RefEntry): Promise<{ security: SecuritySchemeSummary | null }>;
252
+ add(entry: RefAddInput): Promise<{ security: SecuritySchemeSummary | null }>;
252
253
  remove(name: string): Promise<boolean>;
253
254
  list(): Promise<ResolvedRef[]>;
254
- get(name: string): Promise<RefEntry | null>;
255
+ get(name: string): Promise<ResolvedRef | null>;
255
256
  update(name: string, updates: Partial<RefEntry>): Promise<boolean>;
256
257
  inspect(name: string, options?: { full?: boolean }): Promise<AgentInspection | null>;
257
258
  call: AdkRefCallFn;
@@ -332,11 +333,12 @@ function refName(entry: RefEntry): string {
332
333
  * Refs may be stored as `@foo` or `foo` depending on how they were added;
333
334
  * this ensures lookups work regardless of which form the caller uses.
334
335
  */
335
- function findRef(refs: RefEntry[], name: string): RefEntry | undefined {
336
+ function findRef(refs: RefEntry[], name: string): ResolvedRef | undefined {
336
337
  const match = refs.find((r) => refName(r) === name);
337
- if (match) return match;
338
+ if (match) return normalizeRef(match);
338
339
  const alt = name.startsWith("@") ? name.slice(1) : `@${name}`;
339
- return refs.find((r) => refName(r) === alt);
340
+ const altMatch = refs.find((r) => refName(r) === alt);
341
+ return altMatch ? normalizeRef(altMatch) : undefined;
340
342
  }
341
343
 
342
344
  /**
@@ -502,7 +504,8 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
502
504
  const config = await readConfig();
503
505
  const refs = (config.refs ?? []).map((r): RefEntry => {
504
506
  if (refName(r) !== name) return r;
505
- return { ...r, config: { ...r.config, [key]: stored } };
507
+ const normalized = normalizeRef(r);
508
+ return { ...normalized, config: { ...normalized.config, [key]: stored } };
506
509
  });
507
510
  await writeConfig({ ...config, refs });
508
511
  }
@@ -815,7 +818,11 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
815
818
  operation: string,
816
819
  params: Record<string, unknown>,
817
820
  ): Promise<T> {
818
- const consumer = await buildConsumerForRef({ ref: "", sourceRegistry: { url: reg.url, agentPath: agent } } as RefEntry);
821
+ const consumer = await buildConsumerForRef({
822
+ ref: "",
823
+ name: "",
824
+ sourceRegistry: { url: reg.url, agentPath: agent },
825
+ });
819
826
  const resolved = consumer.registries().find((r) => r.url === reg.url);
820
827
  if (!resolved) throw new Error(`Registry ${reg.url} not resolvable for proxy forwarding`);
821
828
 
@@ -1498,11 +1505,22 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1498
1505
  // ==========================================
1499
1506
 
1500
1507
  const ref: AdkRefApi = {
1501
- async add(entry: RefEntry): Promise<{ security: SecuritySchemeSummary | null }> {
1508
+ async add(entryInput: RefAddInput): Promise<{ security: SecuritySchemeSummary | null }> {
1502
1509
  let security: SecuritySchemeSummary | null = null;
1503
1510
 
1504
1511
  const config = await readConfig();
1505
1512
  const hasRegistries = (config.registries ?? []).length > 0;
1513
+ const name = entryInput.name ?? entryInput.ref;
1514
+ let entry: RefEntry = { ...entryInput, name };
1515
+
1516
+ if ((config.refs ?? []).some((r) => refNameMatches(r, name))) {
1517
+ throw new AdkError({
1518
+ code: "REF_INVALID",
1519
+ message: `Cannot add ref "${entry.ref}" as "${name}": a ref with that name already exists`,
1520
+ hint: "Choose a different name, or remove/update the existing ref first.",
1521
+ details: { ref: entry.ref, name },
1522
+ });
1523
+ }
1506
1524
 
1507
1525
  // Auto-infer scheme from context
1508
1526
  if (!entry.scheme) {
@@ -1599,9 +1617,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1599
1617
  }
1600
1618
  }
1601
1619
 
1602
- const name = refName(entry);
1603
- const refs = (config.refs ?? []).filter((r) => refName(r) !== name);
1604
- refs.push(entry);
1620
+ const refs = [...(config.refs ?? []), entry];
1605
1621
  await writeConfig({ ...config, refs });
1606
1622
 
1607
1623
  return { security };
@@ -1622,7 +1638,7 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1622
1638
  return (config.refs ?? []).map(normalizeRef);
1623
1639
  },
1624
1640
 
1625
- async get(name: string): Promise<RefEntry | null> {
1641
+ async get(name: string): Promise<ResolvedRef | null> {
1626
1642
  const config = await readConfig();
1627
1643
  return findRef(config.refs ?? [], name) ?? null;
1628
1644
  },
@@ -1636,14 +1652,21 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
1636
1652
  found = true;
1637
1653
  const updated = { ...r };
1638
1654
  if (updates.url) updated.url = updates.url;
1639
- // Rename: prefer `name`, fall back to legacy `as`. When the
1640
- // caller passes `name`, clear the legacy `as` so the stored
1641
- // entry has one source of truth.
1642
1655
  if (updates.name !== undefined) {
1656
+ const duplicate = config.refs?.some(
1657
+ (candidate) =>
1658
+ !refNameMatches(candidate, name) &&
1659
+ refNameMatches(candidate, updates.name as string),
1660
+ );
1661
+ if (duplicate) {
1662
+ throw new AdkError({
1663
+ code: "REF_INVALID",
1664
+ message: `Cannot rename ref "${name}" to "${updates.name}": a ref with that name already exists`,
1665
+ hint: "Choose a different name, or remove/update the existing ref first.",
1666
+ details: { name, newName: updates.name },
1667
+ });
1668
+ }
1643
1669
  updated.name = updates.name;
1644
- if (updated.as !== undefined) updated.as = undefined;
1645
- } else if (updates.as !== undefined) {
1646
- updated.as = updates.as;
1647
1670
  }
1648
1671
  if (updates.scheme) updated.scheme = updates.scheme;
1649
1672
  if (updates.config) updated.config = { ...updated.config, ...updates.config };
@@ -2120,7 +2143,8 @@ export function createAdk(fs: FsStore, options: AdkOptions = {}): Adk {
2120
2143
  // so callers can include extra context (tenant/user IDs).
2121
2144
  const statePayload = {
2122
2145
  ...opts?.stateContext,
2123
- ref: name,
2146
+ ref: entry.ref,
2147
+ name,
2124
2148
  ts: Date.now(),
2125
2149
  };
2126
2150
  const state = btoa(JSON.stringify(statePayload));
@@ -111,7 +111,7 @@ describe("Registry Consumer E2E", () => {
111
111
  registries: [`http://localhost:${PORT}`],
112
112
  refs: [
113
113
  { ref: "@math" },
114
- { ref: "@echo", as: "my-echo", config: { greeting: "hello" } },
114
+ { ref: "@echo", name: "my-echo", config: { greeting: "hello" } },
115
115
  ],
116
116
  };
117
117
 
@@ -151,12 +151,12 @@ describe("Registry Consumer E2E", () => {
151
151
  );
152
152
  });
153
153
 
154
- test("multi-instance refs with as: alias", async () => {
154
+ test("multi-instance refs with name aliases", async () => {
155
155
  const config = {
156
156
  registries: [`http://localhost:${PORT}`],
157
157
  refs: [
158
- { ref: "@echo", as: "echo-1", config: { prefix: "first" } },
159
- { ref: "@echo", as: "echo-2", config: { prefix: "second" } },
158
+ { ref: "@echo", name: "echo-1", config: { prefix: "first" } },
159
+ { ref: "@echo", name: "echo-2", config: { prefix: "second" } },
160
160
  ],
161
161
  };
162
162
 
@@ -240,10 +240,10 @@ describe("normalizeRef", () => {
240
240
  });
241
241
  });
242
242
 
243
- test("object ref with alias", () => {
243
+ test("object ref with name", () => {
244
244
  const result = normalizeRef({
245
245
  ref: "postgres",
246
- as: "prod-db",
246
+ name: "prod-db",
247
247
  config: { url: "https://twin.slash.com/secrets/db" },
248
248
  });
249
249
  expect(result.ref).toBe("postgres");
@@ -253,7 +253,7 @@ describe("normalizeRef", () => {
253
253
  });
254
254
  });
255
255
 
256
- test("object ref without alias uses ref as name", () => {
256
+ test("object ref without name uses ref as name", () => {
257
257
  const result = normalizeRef({ ref: "github" });
258
258
  expect(result.name).toBe("github");
259
259
  });
@@ -16,8 +16,8 @@
16
16
  * ],
17
17
  * refs: [
18
18
  * 'notion',
19
- * { ref: 'postgres', as: 'prod-db', config: { url: 'https://twin.slash.com/secrets/crdb-url' } },
20
- * { ref: 'postgres', as: 'staging', config: { url: 'https://twin.slash.com/secrets/staging-url' } },
19
+ * { ref: 'postgres', name: 'prod-db', config: { url: 'https://twin.slash.com/secrets/crdb-url' } },
20
+ * { ref: 'postgres', name: 'staging', config: { url: 'https://twin.slash.com/secrets/staging-url' } },
21
21
  * ],
22
22
  * });
23
23
  * ```
@@ -158,14 +158,10 @@ export type RefEntry = {
158
158
 
159
159
  /**
160
160
  * Local identifier for this ref. Used by all operations
161
- * (call/remove/auth/update/…) to look up the entry. If omitted,
162
- * the canonical `ref` string is used as the identifier — the
163
- * common case "one local instance per agent" requires only
164
- * `{ ref: 'notion', ... }`. Set `name` to a different value only
165
- * when you need multiple local instances of the same remote
166
- * agent (e.g. `{ ref: 'notion', name: 'work-notion' }`).
161
+ * (call/remove/auth/update/…) to look up the entry. Add paths
162
+ * default this to `ref` when omitted.
167
163
  */
168
- name?: string;
164
+ name: string;
169
165
 
170
166
  /** Connection scheme */
171
167
  scheme?: 'mcp' | 'https' | 'registry';
@@ -173,12 +169,6 @@ export type RefEntry = {
173
169
  /** Direct URL to the agent (e.g. https://mcp.notion.com/mcp) */
174
170
  url?: string;
175
171
 
176
- /**
177
- * @deprecated Use `name` instead. `as` is preserved for reading
178
- * old consumer-config.json files; new writes emit `name`.
179
- */
180
- as?: string;
181
-
182
172
  /** Per-instance config (headers, secrets, etc. — values support {{secret-uri}} templates) */
183
173
  config?: RefConfig;
184
174
 
@@ -189,6 +179,9 @@ export type RefEntry = {
189
179
  status?: 'active' | 'inactive' | 'error';
190
180
  };
191
181
 
182
+ /** Input accepted by add paths. `name` defaults to `ref` when omitted. */
183
+ export type RefAddInput = Omit<RefEntry, "name"> & { name?: string };
184
+
192
185
  // ============================================
193
186
  // Consumer Config
194
187
  // ============================================
@@ -259,15 +252,13 @@ export interface ResolvedConfig {
259
252
  /**
260
253
  * Normalize a ref entry to its full form.
261
254
  *
262
- * Local identifier resolution order: `entry.name` `entry.as` (legacy)
263
- * `entry.ref` (canonical). This order makes the tool/API surface
264
- * consistent with the `ref.add({ ref, name })` contract while still
265
- * reading old `{ ref, as }` entries from pre-0.74 consumer-config.json.
255
+ * Add paths default `name` to `ref` before writing. `normalizeRef` keeps the
256
+ * same invariant for in-memory/test configs that omit `name`.
266
257
  */
267
258
  export function normalizeRef(entry: RefEntry): ResolvedRef {
268
259
  return {
269
260
  ...entry,
270
- name: entry.name ?? entry.as ?? entry.ref,
261
+ name: entry.name ?? entry.ref,
271
262
  config: entry.config ?? {},
272
263
  };
273
264
  }
package/src/index.ts CHANGED
@@ -307,6 +307,7 @@ export type {
307
307
  RegistryAuth,
308
308
  RegistryEntry,
309
309
  RefConfig,
310
+ RefAddInput,
310
311
  RefEntry,
311
312
  ConsumerConfig,
312
313
  ResolvedRegistry,
@@ -326,7 +326,7 @@ export async function syncAllRefs(
326
326
  for (const refEntry of refs) {
327
327
  const name = typeof refEntry === "string"
328
328
  ? refEntry
329
- : (refEntry as any).as ?? (refEntry as any).ref ?? (refEntry as any).name;
329
+ : (refEntry as any).name ?? (refEntry as any).ref;
330
330
  if (!name) continue;
331
331
  if (opts?.filter && name !== opts.filter) continue;
332
332
  names.push(name);
@@ -1,21 +1,16 @@
1
1
  /**
2
2
  * Tests for the `ref` naming contract introduced in 0.74:
3
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.
4
+ * - `name` is the local identifier for every stored ref entry.
5
+ * - Add paths default `name` to `ref` when omitted.
6
+ * - `as` is not part of the stored or public ref shape.
7
+ * - Adding a duplicate `name` is a loud error, not a replace.
13
8
  */
14
9
 
15
10
  import { describe, expect, test } from "bun:test";
16
11
  import { createAdkTools } from "./adk-tools";
17
12
  import type { FsStore } from "./agent-definitions/config";
18
- import { createAdk } from "./index";
13
+ import { createAdk, createAgentRegistry, defineAgent } from "./index";
19
14
  import type { ToolContext } from "./types";
20
15
 
21
16
  function createMemoryFs(): FsStore {
@@ -42,7 +37,7 @@ async function readJson<T = unknown>(fs: FsStore, path: string): Promise<T> {
42
37
  // ─── RefEntry: name field on write ───────────────────────────────────
43
38
 
44
39
  describe("ref.add — identifier field", () => {
45
- test("single-instance case stores only `ref` (no `name`/`as`)", async () => {
40
+ test("single-instance case stores explicit `name` equal to `ref`", async () => {
46
41
  const fs = createMemoryFs();
47
42
  const adk = createAdk(fs);
48
43
 
@@ -58,8 +53,7 @@ describe("ref.add — identifier field", () => {
58
53
  );
59
54
  expect(parsed.refs).toHaveLength(1);
60
55
  expect(parsed.refs[0].ref).toBe("test-ref");
61
- expect(parsed.refs[0].name).toBeUndefined();
62
- expect(parsed.refs[0].as).toBeUndefined();
56
+ expect(parsed.refs[0].name).toBe("test-ref");
63
57
  });
64
58
 
65
59
  test("aliasing case stores both `ref` and `name`", async () => {
@@ -79,14 +73,40 @@ describe("ref.add — identifier field", () => {
79
73
  );
80
74
  expect(parsed.refs[0].ref).toBe("notion");
81
75
  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();
76
+ });
77
+
78
+ test("adding a duplicate name rejects instead of replacing", async () => {
79
+ const fs = createMemoryFs();
80
+ const adk = createAdk(fs);
81
+
82
+ await adk.ref.add({
83
+ ref: "notion",
84
+ name: "work",
85
+ scheme: "mcp",
86
+ url: "http://localhost:12345",
87
+ });
88
+
89
+ await expect(
90
+ adk.ref.add({
91
+ ref: "linear",
92
+ name: "work",
93
+ scheme: "mcp",
94
+ url: "http://localhost:12346",
95
+ }),
96
+ ).rejects.toThrow(/already exists/);
97
+
98
+ const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
99
+ fs,
100
+ "consumer-config.json",
101
+ );
102
+ expect(parsed.refs).toHaveLength(1);
103
+ expect(parsed.refs[0].ref).toBe("notion");
84
104
  });
85
105
  });
86
106
 
87
107
  // ─── Lookup compatibility: new `name` field works end-to-end ─────────
88
108
 
89
- describe("ref lookup — name/as/ref resolution", () => {
109
+ describe("ref lookup — name/ref resolution", () => {
90
110
  test("entries written with `name` are findable by `name`", async () => {
91
111
  const fs = createMemoryFs();
92
112
  const adk = createAdk(fs);
@@ -119,9 +139,7 @@ describe("ref lookup — name/as/ref resolution", () => {
119
139
  expect(refs[0]?.name).toBe("work-notion");
120
140
  });
121
141
 
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.
142
+ test("entries without `name` normalize to `ref`", async () => {
125
143
  const fs = createMemoryFs();
126
144
  await fs.writeFile(
127
145
  "consumer-config.json",
@@ -129,7 +147,6 @@ describe("ref lookup — name/as/ref resolution", () => {
129
147
  refs: [
130
148
  {
131
149
  ref: "notion",
132
- as: "work-notion",
133
150
  scheme: "mcp",
134
151
  url: "http://localhost:12345",
135
152
  },
@@ -138,56 +155,26 @@ describe("ref lookup — name/as/ref resolution", () => {
138
155
  );
139
156
  const adk = createAdk(fs);
140
157
 
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");
158
+ const refs = await adk.ref.list();
159
+ expect(refs[0]?.name).toBe("notion");
160
+ expect(await adk.ref.get("notion")).not.toBeNull();
168
161
  });
169
162
  });
170
163
 
171
- // ─── ref.update: renaming clears the legacy `as` field ───────────────
164
+ // ─── ref.update: name is the only rename field ───────────────────────
172
165
 
173
- describe("ref.update — name/as handling", () => {
174
- test("passing `name` in updates sets name and clears legacy `as`", async () => {
166
+ describe("ref.update — name handling", () => {
167
+ test("passing `name` in updates sets name", async () => {
175
168
  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
169
  const adk = createAdk(fs);
190
170
 
171
+ await adk.ref.add({
172
+ ref: "notion",
173
+ name: "old-alias",
174
+ scheme: "mcp",
175
+ url: "http://localhost:12345",
176
+ });
177
+
191
178
  const ok = await adk.ref.update("old-alias", { name: "new-alias" });
192
179
  expect(ok).toBe(true);
193
180
 
@@ -196,35 +183,29 @@ describe("ref.update — name/as handling", () => {
196
183
  "consumer-config.json",
197
184
  );
198
185
  expect(parsed.refs[0].name).toBe("new-alias");
199
- expect(parsed.refs[0].as).toBeUndefined();
200
186
  expect(parsed.refs[0].ref).toBe("notion");
201
187
  });
202
188
 
203
- test("passing only `as` updates the legacy field (pre-0.74 callers)", async () => {
189
+ test("renaming to an existing name rejects", async () => {
204
190
  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
191
  const adk = createAdk(fs);
219
192
 
220
- const ok = await adk.ref.update("first", { as: "second" });
221
- expect(ok).toBe(true);
193
+ await adk.ref.add({
194
+ ref: "notion",
195
+ name: "first",
196
+ scheme: "mcp",
197
+ url: "http://localhost:12345",
198
+ });
199
+ await adk.ref.add({
200
+ ref: "linear",
201
+ name: "second",
202
+ scheme: "mcp",
203
+ url: "http://localhost:12346",
204
+ });
222
205
 
223
- const parsed = await readJson<{ refs: Array<Record<string, unknown>> }>(
224
- fs,
225
- "consumer-config.json",
206
+ await expect(adk.ref.update("first", { name: "second" })).rejects.toThrow(
207
+ /already exists/,
226
208
  );
227
- expect(parsed.refs[0].as).toBe("second");
228
209
  });
229
210
  });
230
211
 
@@ -232,10 +213,8 @@ describe("ref.update — name/as handling", () => {
232
213
  //
233
214
  // When an LLM is prompted with "Add a ref called X", its tool-call
234
215
  // 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.
216
+ // depending on sampling. The `add` handler defaults the missing field so
217
+ // both paths converge on the same stored `{ ref: 'X', name: 'X' }` entry.
239
218
 
240
219
  describe("ref tool — add operation defaults ref to name", () => {
241
220
  function makeRefTool(adk: ReturnType<typeof createAdk>) {
@@ -267,8 +246,7 @@ describe("ref tool — add operation defaults ref to name", () => {
267
246
  );
268
247
  expect(parsed.refs).toHaveLength(1);
269
248
  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();
249
+ expect(parsed.refs[0].name).toBe("test-identity-ref");
272
250
 
273
251
  const entry = await adk.ref.get("test-identity-ref");
274
252
  expect(entry).not.toBeNull();
@@ -295,7 +273,7 @@ describe("ref tool — add operation defaults ref to name", () => {
295
273
  "consumer-config.json",
296
274
  );
297
275
  expect(parsed.refs[0].ref).toBe("test-identity-ref");
298
- expect(parsed.refs[0].name).toBeUndefined();
276
+ expect(parsed.refs[0].name).toBe("test-identity-ref");
299
277
  });
300
278
 
301
279
  test("LLM sends `ref` + different `name` → stored as canonical + alias", async () => {
@@ -348,4 +326,99 @@ describe("ref tool — add operation defaults ref to name", () => {
348
326
  const raw = await fs.readFile("consumer-config.json");
349
327
  expect(raw).toBeNull();
350
328
  });
329
+
330
+ test("invalid add input returns schema details through registry call", async () => {
331
+ const fs = createMemoryFs();
332
+ const adk = createAdk(fs);
333
+ const refTool = makeRefTool(adk);
334
+ const registry = createAgentRegistry();
335
+ registry.register(
336
+ defineAgent({
337
+ path: "@config",
338
+ entrypoint: "Config agent",
339
+ tools: [refTool],
340
+ visibility: "public",
341
+ }),
342
+ );
343
+
344
+ const response = await registry.call({
345
+ action: "execute_tool",
346
+ path: "@config",
347
+ tool: "ref",
348
+ params: {
349
+ operation: "add",
350
+ ref: "google-calendar",
351
+ },
352
+ });
353
+
354
+ expect(response.success).toBe(false);
355
+ if (response.success) throw new Error("expected invalid input error");
356
+ expect(response.code).toBe("TOOL_INPUT_INVALID");
357
+ expect(response.error).toContain("Invalid ref.add input");
358
+ expect(response.details?.issues).toEqual(
359
+ expect.arrayContaining([
360
+ expect.objectContaining({
361
+ path: "sourceRegistry",
362
+ }),
363
+ ]),
364
+ );
365
+ expect(response.details?.schema).toMatchObject({
366
+ anyOf: expect.any(Array),
367
+ });
368
+ expect(response.details?.operationSchema).toMatchObject({
369
+ type: "object",
370
+ });
371
+ expect(response.hint).toContain("details.schema");
372
+ expect(response.details).not.toHaveProperty("examples");
373
+ expect(JSON.stringify(response.details?.operationSchema)).toContain(
374
+ "sourceRegistry",
375
+ );
376
+ });
377
+ });
378
+
379
+ describe("ref tool — auth state hook", () => {
380
+ test("passes tool input to getAuthStateContext", async () => {
381
+ const authCalls: Array<{
382
+ name: string;
383
+ opts: { stateContext?: Record<string, unknown> };
384
+ }> = [];
385
+ const adk = {
386
+ ref: {
387
+ auth: async (
388
+ name: string,
389
+ opts: { stateContext?: Record<string, unknown> },
390
+ ) => {
391
+ authCalls.push({ name, opts });
392
+ return { complete: true };
393
+ },
394
+ },
395
+ } as unknown as ReturnType<typeof createAdk>;
396
+ const tools = createAdkTools({
397
+ resolveScope: () => adk,
398
+ hooks: {
399
+ getAuthStateContext: async (input) => ({
400
+ name: input.name,
401
+ ref: input.ref,
402
+ }),
403
+ },
404
+ });
405
+ const refTool = tools.find((t) => t.name === "ref");
406
+ if (!refTool) throw new Error("ref tool not found");
407
+
408
+ await refTool.execute(
409
+ {
410
+ operation: "auth",
411
+ ref: "google-gmail",
412
+ name: "work2",
413
+ },
414
+ {} as ToolContext,
415
+ );
416
+
417
+ expect(authCalls).toHaveLength(1);
418
+ expect(authCalls[0]?.name).toBe("work2");
419
+ expect(authCalls[0]?.opts.stateContext).toEqual({
420
+ ref: "google-gmail",
421
+ name: "work2",
422
+ });
423
+ });
351
424
  });
@@ -12,7 +12,7 @@
12
12
  *
13
13
  * const config = defineConfig({
14
14
  * registries: ['https://registry.slash.com'],
15
- * refs: ['notion', { ref: 'postgres', as: 'prod-db', config: { url: '...' } }],
15
+ * refs: ['notion', { ref: 'postgres', name: 'prod-db', config: { url: '...' } }],
16
16
  * });
17
17
  *
18
18
  * const consumer = await createRegistryConsumer(config);