import { describe, it, expect } from 'vitest'; import { execFileSync } from 'node:child_process'; import { join } from 'node:path'; import { AGENT_TOOLS } from '@beehexa/hexasync-template-validate'; import * as retrieve from '@beehexa/hexasync-template-validate'; /** * Story 7.6 AC-1 — the editor-hosted agent sees the same tool set as the local server. * * ⛔ Two lists cannot satisfy that AC: they agree on the day they are written and drift afterwards. `AGENT_TOOLS` is * the one definition, and this asserts the SERVER registers exactly it — so the extension registering from the same * list is parity by construction rather than by inspection. */ const CLI = join(process.cwd(), 'apps/cli/dist/main.js'); function offeredTools(): { name: string; title?: string; description?: string; inputSchema?: { properties?: Record; required?: string[]; }; }[] { const input = [ { jsonrpc: '2.0', id: 0, method: 'initialize', params: { protocolVersion: '2024-11-05', capabilities: {}, clientInfo: { name: 't', version: '1' }, }, }, { jsonrpc: '2.0', method: 'notifications/initialized' }, { jsonrpc: '2.0', id: 1, method: 'tools/list' }, ] .map((c) => JSON.stringify(c)) .join('\n') + '\n'; const out = execFileSync('node', [CLI, 'mcp'], { input, encoding: 'utf8', timeout: 25_000, }); const reply = out .split('\n') .filter(Boolean) .map( (l) => JSON.parse(l) as { id?: number; result?: { tools?: { name: string; title?: string; description?: string; inputSchema?: { properties?: Record; required?: string[]; }; }[]; }; }, ) .find((m) => m.id === 1); return reply?.result?.tools ?? []; } describe('the server offers exactly the shared tool set', () => { it('by name, with nothing extra and nothing missing', () => { expect( offeredTools() .map((tool) => tool.name) .sort(), ).toEqual(AGENT_TOOLS.map((tool) => tool.name).sort()); }); it('⛔ and by TITLE, DESCRIPTION and ARGUMENTS, not by name alone', () => { /** * ⛔ This compared names only, and the two lists had already diverged behind it: `hexasync_explain_rule` was * described one way in `AGENT_TOOLS` and another in the server's own hardcoded config. `AGENT_TOOLS` claims in * its docblock that both surfaces register FROM it — a name check cannot tell whether that is true. */ const offered = new Map(offeredTools().map((tool) => [tool.name, tool])); for (const tool of AGENT_TOOLS) { const live = offered.get(tool.name)!; expect(live.title, tool.name).toBe(tool.title); expect(live.description, tool.name).toBe(tool.description); expect( Object.keys(live.inputSchema?.properties ?? {}).sort(), tool.name, ).toEqual(tool.args.map((arg) => arg.name).sort()); expect((live.inputSchema?.required ?? []).sort(), tool.name).toEqual( tool.args .filter((arg) => arg.required) .map((arg) => arg.name) .sort(), ); /** * ⛔ And the TYPE each argument is advertised with. The server derives its zod schema from `AgentToolArg.type`, * and `string[]` is one of the three declared types — a mapping that fell through to `z.string()` for it makes * `changedFiles` REJECT the array it documents, while every name-and-requiredness check above still passes. * Confirmed against the live reply: under that mutation the server advertises `"type": "string"`. */ const JSON_TYPE: Record = { string: 'string', boolean: 'boolean', 'string[]': 'array', }; for (const arg of tool.args) { expect( live.inputSchema?.properties?.[arg.name]?.type, `${tool.name}.${arg.name}`, ).toBe(JSON_TYPE[arg.type]); } } }); it('every tool names the shared function that answers it', () => { /** * ⛔ This asserted `toBeTruthy()` on `answeredBy`, whose TYPE is a union of six string literals — so it could * only fail if someone wrote `''`, and the review landed a mutation setting one tool's answerer to * `'validateProject'`, a name that resolves to no function anywhere, with 18 tests still green. * * Checked against the real module now. `orientation` and `validateProject` are answered by the HOST — each * surface knows its own filesystem — so they are named as such rather than pretending to be exports. */ /** * ⚠️ Keyed on the TOOL, not on the answerer's name. Keyed the other way, a mutation setting `explain_type`'s * answerer to `'validateProject'` — a function that exists nowhere — simply skipped the check and passed. The two * host-answered tools are named here so a THIRD one cannot be added silently by picking a host-ish string. */ const HOST_ANSWERED: Record = { hexasync_orientation: 'orientation', hexasync_validate: 'validateProject', }; const exported = retrieve as unknown as Record; for (const tool of AGENT_TOOLS) { const host = HOST_ANSWERED[tool.name]; if (host !== undefined) { expect(tool.answeredBy, tool.name).toBe(host); continue; } expect( typeof exported[tool.answeredBy], `${tool.name} → ${tool.answeredBy}`, ).toBe('function'); } }); it('⛔ tool names are public surface, so they are pinned', () => { /** * PS-5 makes these public: a client configuration, a prompt and an agent's memory all refer to them by name, so a * rename is a breaking change rather than a tidy-up. Pinned literally, because the cost of finding out later is * paid by someone else's configuration. */ expect(AGENT_TOOLS.map((t) => t.name)).toEqual([ 'hexasync_orientation', 'hexasync_explain_rule', 'hexasync_explain_type', 'hexasync_list_connectors', 'hexasync_search_examples', 'hexasync_validate', // Story 8.9 — the five authoring positions, as data. 'hexasync_explain_position', ]); }); });