Files
callstack__agent-device/scripts/__tests__/package-closure-audit.test.ts
Michał Pierzchała 801734d433 feat(ai-sdk): add agent-device/ai-sdk tool set and document the MCP zero-code path (#1804)
* feat(ai-sdk): add agent-device/ai-sdk tool set and document the MCP zero-code path

Adds `createAgentDeviceTools()` under a new `agent-device/ai-sdk` subpath,
built from the same command registry the MCP server uses so both stay in
lockstep without a hand-maintained tool list. Introduces a `frameworkTier`
descriptor facet ('core' | 'extended') so the factory can default to a
curated perceive/act loop instead of handing a model dozens of tools.

`ai` is wired as an optional peer dependency, imported lazily inside the
factory rather than at module scope, so importing the subpath itself never
requires `ai` to be installed - only calling it does. The package's own
publishing gate (scripts/lib/shipped-imports.ts) is extended to recognize
peerDependencies as a valid resolution source, since this is the first
optional peer this package has shipped.

Also restructures the AI SDK doc around three tiers (zero-code via
@ai-sdk/mcp, the new typed tool set, hand-written tools) and fixes a stale
`needsApproval` reference in favor of the current `toolApproval` API.

* fix(layering): classify src/ai-sdk as a rank-4 zone

The layering guard requires every src/<folder>/ to be explicitly ranked or
unranked; the new src/ai-sdk/ subpath (added in the prior commit) was left
unclassified, failing CI's Layering Guard job. It sits at the same tier as
client/compat/daemon-server/metro/remote/sdk - a public integration surface
consuming mcp (3) and core (2), imported by nothing else in the tree.

* fix(ci): cover, exempt, and pack the new ai-sdk subpath

Fixes the remaining CI failures on the ai-sdk subpath commit:

- Coverage: src/ai-sdk/index.ts had no dedicated unit test (only manual/
  integration verification), so changed-line coverage sat at 6.9% against
  the 70% gate. Adds src/ai-sdk/__tests__/index.test.ts (core vs 'all' tool
  filtering, session/platform pinning and schema hiding, error
  normalization, toolApproval passthrough) with createCommandToolExecutor
  and createAgentDeviceClient mocked the same way command-tools.test.ts
  does, plus a dedicated missing-peer-dependency.test.ts that mocks `ai`
  itself to throw, isolated to its own file so it doesn't affect the other
  tests' use of the real, installed `ai` package. Changed-line coverage is
  now 29/29 (100%).
- Fallow Code Quality: src/ai-sdk/index.ts and examples/sdk/ai-sdk-tools.ts
  are entry points with no in-repo importer (reached only via package.json
  exports / run directly), and the new subpath's exports are unused
  internally by design - both need the same treatment src/sdk/*.ts and its
  examples already have in .fallowrc.json.
- Integration Tests: test/integration/installed-package-metro.test.ts and
  src/__tests__/package-exports.test.ts each hand-list every published
  subpath and smoke-check it from a real packed install; added ./ai-sdk to
  both so the new subpath is actually exercised, not just silently passing.

* fix(ai-sdk): hide MCP transport/config fields from the model too

createAgentDeviceTools() only removed session and mcpOutputFormat from tool
schemas. stateDir was still model-visible and reached the shared executor
as client configuration, letting a tool call redirect into a different
daemon state directory - defeating the "one pinned session" guarantee the
factory exists to provide. includeCost and responseLevel are MCP
tool-config knobs in the same category, irrelevant to this adapter.

Widens the hidden-field set to session/stateDir/mcpOutputFormat/
includeCost/responseLevel, and now strips them from the runtime input
inside execute() too (not just the schema), so the guarantee holds even if
a caller bypasses schema validation. The schema-properties filter and the
input filter now share one omitHidden() helper instead of two near-
duplicate implementations.

Addresses the P1 review comment on #1804.
2026-08-18 11:57:34 +02:00

196 lines
8.3 KiB
TypeScript

/**
* Behavioral tests for the publishing gate's dependency-closure audit, run against fixture packages
* that stand in for packed output.
*
* The gate itself (scripts/check-package.ts) needs a real `npm pack` behind minutes of Swift and
* Android builds, so every check that runs it can only observe a *healthy* package passing. That
* leaves the interesting direction — does a malformed package actually fail? — untested, which is
* how the audit came to read 0 of the 99 dynamic imports in the built bundle while looking covered.
* These fixtures assert the failure direction, one resolution form at a time.
*
* Each fixture is spelled the way the minifier spells it: no-substitution template literals, short
* aliased `createRequire` bindings. That is what the packed files look like, so that is what the
* audit has to be able to read.
*/
import assert from 'node:assert/strict';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, test } from 'vitest';
import { auditDependencyClosure, type PackedManifest } from '../lib/shipped-imports.ts';
const tempRoots: string[] = [];
afterEach(() => {
for (const root of tempRoots.splice(0)) fs.rmSync(root, { recursive: true, force: true });
});
/** Lays out a fake installed package: shipped files plus the `dependencies` the manifest declares. */
function fixturePackage(
files: Record<string, string>,
dependencies: string[] = [],
peerDependencies: string[] = [],
): () => void {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-closure-fixture-'));
tempRoots.push(root);
for (const [relative, source] of Object.entries(files)) {
const file = path.join(root, relative);
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, source);
}
const manifest: PackedManifest = {
dependencies: Object.fromEntries(dependencies.map((name) => [name, '1.0.0'])),
peerDependencies: Object.fromEntries(peerDependencies.map((name) => [name, '1.0.0'])),
};
return () => void auditDependencyClosure(root, manifest);
}
/** The workspace-private specifier whose published import broke 0.20.4 (#1577). */
const PRIVATE = '@agent-device/ad-script';
/** Every literal spelling of "resolve this specifier at runtime" that a shipped file can use. */
const LAZY_FORMS: Record<string, string> = {
'dynamic import, minified backtick spelling': 'await import(`SPECIFIER`);',
'dynamic import, quoted spelling': "await import('SPECIFIER');",
'bare require': 'const mod = require(`SPECIFIER`);',
'require.resolve': 'const at = require.resolve(`SPECIFIER`);',
'aliased createRequire result': [
"import { createRequire as t } from 'node:module';",
'var a = t(import.meta.url);',
'const mod = a(`SPECIFIER`);',
].join('\n'),
'immediately invoked createRequire': [
"import { createRequire } from 'node:module';",
'const mod = createRequire(import.meta.url)(`SPECIFIER`);',
].join('\n'),
'createRequire through a namespace import': [
"import * as M from 'node:module';",
'var r = M.createRequire(import.meta.url);',
'const mod = r(`SPECIFIER`);',
].join('\n'),
'require alias declared below its use': [
"import { createRequire as t } from 'node:module';",
'export function load() { return q(`SPECIFIER`); }',
'var q = t(import.meta.url);',
].join('\n'),
};
// The reviewable claim of the gate: a shipped file cannot reach a package the manifest does not
// declare. Without every form below, a command could lazily resolve a workspace-private specifier
// and publish green — the 0.20.4 failure class, reintroduced one resolution form at a time.
for (const [form, template] of Object.entries(LAZY_FORMS)) {
test(`an undeclared private specifier fails the closure audit via ${form}`, () => {
const audit = fixturePackage({ 'dist/cli.js': template.replaceAll('SPECIFIER', PRIVATE) });
assert.throws(audit, (error: Error) => {
assert.match(error.message, /Imported but not declared in "dependencies"/);
assert.match(error.message, /@agent-device\/ad-script in dist\/cli\.js/);
return true;
});
});
test(`a declared dependency satisfies the closure audit via ${form}`, () => {
const audit = fixturePackage({ 'dist/cli.js': template.replaceAll('SPECIFIER', 'yaml') }, [
'yaml',
]);
audit();
});
}
// agent-device/ai-sdk imports the optional peer `ai`, resolved from the consumer's own
// install rather than ours — the audit must accept `peerDependencies` as a valid answer to
// "how does this import resolve," the same way it accepts `dependencies`.
test('an import satisfied only by a peerDependency satisfies the closure audit', () => {
const audit = fixturePackage({ 'dist/ai-sdk.js': "import { tool } from 'ai';" }, [], ['ai']);
audit();
});
test('an import satisfied by neither dependencies nor peerDependencies still fails the closure audit', () => {
const audit = fixturePackage({ 'dist/ai-sdk.js': "import { tool } from 'ai';" });
assert.throws(audit, (error: Error) => {
assert.match(
error.message,
/Imported but not declared in "dependencies" or "peerDependencies"/,
);
assert.match(error.message, /ai in dist\/ai-sdk\.js/);
return true;
});
});
test('a peerDependency does not exempt a declared dependency from the "never imported" check', () => {
const audit = fixturePackage({ 'dist/cli.js': 'export const x = 1;' }, ['pngjs'], ['ai']);
assert.throws(audit, /Declared in "dependencies" but never imported[\s\S]*- pngjs/);
});
test('a subpath import is attributed to the package that must be declared', () => {
const audit = fixturePackage({ 'dist/cli.js': 'await import(`@limrun/api/client`);' }, [
'@limrun/api',
]);
audit();
});
test('the audit reads every shipped extension, not only the bundled .js files', () => {
for (const file of ['bin/agent-device.mjs', 'dist/legacy.cjs', 'dist/index.d.ts']) {
const audit = fixturePackage({ [file]: `const mod = require(\`${PRIVATE}\`);` });
assert.throws(
audit,
new RegExp(`${PRIVATE.replace('/', '\\/')} in ${file.replace('/', '\\/')}`),
);
}
});
test('builtins and relative specifiers are not dependencies', () => {
const audit = fixturePackage({
'dist/cli.js': [
"import fs from 'node:fs';",
"import { createRequire as t } from 'node:module';",
'var a = t(import.meta.url);',
'const z = a(`zlib`);',
'const u = a(`util`);',
'await import(`./sibling.js`);',
'await import(`../parent.js`);',
].join('\n'),
'dist/sibling.js': 'export const x = 1;',
});
audit();
});
// The other direction of the same audit: a dependency every user installs and no shipped file
// reaches. How `pngjs` stayed in `dependencies` after tsdown started inlining it.
test('a declared dependency nothing imports fails the closure audit', () => {
const audit = fixturePackage({ 'dist/cli.js': 'export const x = 1;' }, ['pngjs']);
assert.throws(audit, /Declared in "dependencies" but never imported[\s\S]*- pngjs/);
});
// Pinned limitations, so the gate's guarantee stays honest about where it stops. Both are covered
// by the runtime half instead: the gate imports every export and runs the CLI paths that load the
// lazy bundles, which resolves computed specifiers for real.
test('a computed specifier is out of scope for the static audit', () => {
const audit = fixturePackage({
'dist/cli.js': [
"import { createRequire as t } from 'node:module';",
'var a = t(import.meta.url);',
'export const load = (name) => a(name);',
'export const scoped = (v) => require(`@agent-device/ad-` + v);',
'export const lazy = (name) => import(name);',
].join('\n'),
});
audit();
});
test('a minified name collision is not mistaken for a require call', () => {
// The packed png-worker-contract.js really does contain `a(h[t],f,g,l,e,m)` in a scope where `a`
// is not the file's `createRequire` result. Attributing that to a dependency would fail the gate
// on a sound package, so only the one-string-argument shape counts.
const audit = fixturePackage({
'dist/cli.js': [
"import { createRequire as t } from 'node:module';",
'var a = t(import.meta.url);',
'export function draw(h, f, g, l, e, m) {',
' const a = (...parts) => parts.length;',
' return a(h[0], f, g, l, e, m);',
'}',
].join('\n'),
});
audit();
});