@open-mercato/ai-assistant 0.6.5-develop.5240.2.bbd9d30275 → 0.6.5-develop.5266.2.9acdca82ec
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/.turbo/turbo-build.log +1 -1
- package/dist/modules/ai_assistant/lib/agent-registry.js +16 -6
- package/dist/modules/ai_assistant/lib/agent-registry.js.map +2 -2
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js +91 -0
- package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-loader.js +30 -3
- package/dist/modules/ai_assistant/lib/tool-loader.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-test-runner.js +7 -92
- package/dist/modules/ai_assistant/lib/tool-test-runner.js.map +2 -2
- package/package.json +6 -6
- package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +140 -0
- package/src/modules/ai_assistant/lib/agent-registry.ts +43 -14
- package/src/modules/ai_assistant/lib/generated-registry-loader.ts +161 -0
- package/src/modules/ai_assistant/lib/tool-loader.ts +60 -9
- package/src/modules/ai_assistant/lib/tool-test-runner.ts +8 -134
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../../src/modules/ai_assistant/lib/tool-test-runner.ts"],
|
|
4
|
-
"sourcesContent": ["/**\n * In-process AI tool test runner.\n *\n * Iterates every tool registered in `ai-tools.generated.ts`, invokes the\n * handler against a super-admin tenant context, and returns a structured\n * report. Used by the `mercato ai_assistant test-tools` CLI subcommand and\n * by `.ai/qa/tests/integration/TC-INT-AI-TOOLS.spec.ts`.\n *\n * Safety posture:\n * - No HTTP exposure \u2014 runs only inside the Node process driving the CLI.\n * - Mutation tools are exercised through `prepareMutation` and we assert a\n * pending-action envelope is returned. The pending-action row is created\n * in `ai_pending_actions` but never confirmed, so no real write happens.\n * - Tools without an explicit fixture entry are skipped with reason\n * `'no fixture'` rather than failing.\n */\nimport path from 'node:path'\nimport fs from 'node:fs'\nimport { fileURLToPath, pathToFileURL } from 'node:url'\nimport type { AwilixContainer } from 'awilix'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { McpToolContext, AiToolDefinition } from './types'\nimport type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'\nimport { getFixture, type ToolFixture } from './tool-test-fixtures'\nimport { prepareMutation } from './prepare-mutation'\nimport { executePendingActionConfirm } from './pending-action-executor'\n\nexport type ToolTestStatus = 'pass' | 'fail' | 'skip'\n\nexport interface ToolTestRecord {\n module: string\n tool: string\n isMutation: boolean\n status: ToolTestStatus\n durationMs: number\n reason?: string\n resultPreview?: unknown\n}\n\nexport interface ToolTestReport {\n tenantId: string | null\n organizationId: string | null\n total: number\n passed: number\n failed: number\n skipped: number\n records: ToolTestRecord[]\n}\n\ninterface RawAiToolsModule {\n aiToolConfigEntriesRaw?: { moduleId: string; tools: unknown[] }[]\n aiToolConfigEntries?: { moduleId: string; tools: unknown[] }[]\n}\n\nfunction isToolDefinition(value: unknown): value is AiToolDefinition {\n if (!value || typeof value !== 'object') return false\n const candidate = value as Record<string, unknown>\n return (\n typeof candidate.name === 'string' &&\n typeof candidate.description === 'string' &&\n candidate.inputSchema !== undefined &&\n typeof candidate.handler === 'function'\n )\n}\n\n/**\n * Locate `apps/mercato/.mercato/generated/ai-tools.generated.ts` without\n * hardcoding the workspace layout. Searches upward from this file's\n * compiled location; in the monorepo dist this is\n * `packages/ai-assistant/dist/...` so we walk up until we hit a directory\n * containing `apps/mercato/.mercato/generated`.\n */\nfunction findGeneratedAiToolsPath(): string | null {\n const here = (() => {\n try {\n return fileURLToPath(import.meta.url)\n } catch {\n return null\n }\n })()\n if (!here) return null\n let cursor = path.dirname(here)\n for (let i = 0; i < 12; i++) {\n const candidate = path.join(cursor, 'apps', 'mercato', '.mercato', 'generated', 'ai-tools.generated.ts')\n if (fs.existsSync(candidate)) return candidate\n const next = path.dirname(cursor)\n if (next === cursor) break\n cursor = next\n }\n // Fallback: cwd-based lookup (when CLI is invoked from apps/mercato).\n const fromCwd = path.resolve(process.cwd(), 'apps', 'mercato', '.mercato', 'generated', 'ai-tools.generated.ts')\n if (fs.existsSync(fromCwd)) return fromCwd\n const fromCwdDirect = path.resolve(process.cwd(), '.mercato', 'generated', 'ai-tools.generated.ts')\n if (fs.existsSync(fromCwdDirect)) return fromCwdDirect\n return null\n}\n\n/**\n * Compile-and-import `ai-tools.generated.ts` on the fly. Mirrors the approach\n * used by `loadBootstrapData` in `@open-mercato/shared/lib/bootstrap/dynamicLoader`:\n * resolves the `@/` alias to the app root, marks every other package import as\n * external, and emits a sibling `.mjs` we can `import()` from Node. Cached on\n * mtime so repeat runs in the same process don't recompile.\n */\nasync function compileAndImportGenerated(tsPath: string): Promise<RawAiToolsModule> {\n const jsPath = tsPath.replace(/\\.ts$/, '.mjs')\n // appRoot is two directories up from `.mercato/generated/<file>.ts`.\n const appRoot = path.dirname(path.dirname(path.dirname(tsPath)))\n const tsExists = fs.existsSync(tsPath)\n if (!tsExists) {\n throw new Error(`Generated file not found: ${tsPath}`)\n }\n const jsExists = fs.existsSync(jsPath)\n const needsCompile =\n !jsExists || fs.statSync(tsPath).mtimeMs > fs.statSync(jsPath).mtimeMs\n if (needsCompile) {\n const esbuild = await import('esbuild')\n // Transpile-only: don't bundle. Generated registry files only declare an\n // array literal whose entries are static `import(\"\u2026\")` arrow functions \u2014\n // we want those `import()` strings to stay as runtime imports so Node\n // resolves them lazily through the workspace's normal module resolution.\n // Eagerly bundling them pulls Next.js / route handler internals into the\n // .mjs and breaks at runtime (e.g. `next/server` package-exports map).\n const tsSource = fs.readFileSync(tsPath, 'utf-8')\n // Rewrite `@/...` aliases to absolute paths so Node can resolve them.\n const aliasRewritten = tsSource.replace(\n /from\\s+[\"']@\\/([^\"']+)[\"']/g,\n (_match, p1: string) => {\n const target = path.join(appRoot, p1)\n const candidate = fs.existsSync(target)\n ? target\n : fs.existsSync(target + '.ts')\n ? target + '.ts'\n : target\n return `from ${JSON.stringify(pathToFileURL(candidate).href)}`\n },\n ).replace(\n /import\\s*\\(\\s*[\"']@\\/([^\"']+)[\"']\\s*\\)/g,\n (_match, p1: string) => {\n const target = path.join(appRoot, p1)\n const candidate = fs.existsSync(target)\n ? target\n : fs.existsSync(target + '.ts')\n ? target + '.ts'\n : target\n return `import(${JSON.stringify(pathToFileURL(candidate).href)})`\n },\n )\n const result = await esbuild.transform(aliasRewritten, {\n loader: 'ts',\n format: 'esm',\n target: 'node18',\n sourcemap: false,\n sourcefile: tsPath,\n })\n fs.writeFileSync(jsPath, result.code)\n }\n return (await import(pathToFileURL(jsPath).href)) as RawAiToolsModule\n}\n\n/**\n * Compile-and-import `api-routes.generated.ts` and register its manifest with\n * the shared registry. Many tool handlers delegate to `aiApiOperationRunner`\n * which fails closed when no manifest is registered. Idempotent: registering\n * the same array twice is safe.\n */\nasync function ensureApiRouteManifestsRegistered(generatedDir: string): Promise<void> {\n const tsPath = path.join(generatedDir, 'api-routes.generated.ts')\n if (!fs.existsSync(tsPath)) return\n try {\n const mod = (await compileAndImportGenerated(tsPath)) as Record<string, unknown>\n const apiRoutes = (mod as { apiRoutes?: unknown }).apiRoutes\n if (!Array.isArray(apiRoutes)) {\n console.warn('[tool-test-runner] api-routes.generated.mjs returned no apiRoutes array')\n return\n }\n const registry = await import('@open-mercato/shared/modules/registry')\n registry.registerApiRouteManifests(\n apiRoutes as Parameters<typeof registry.registerApiRouteManifests>[0],\n )\n if (process.env.OM_TOOL_TEST_DEBUG === '1') {\n console.log(\n `[tool-test-runner] Registered ${apiRoutes.length} api-route manifests; getApiRouteManifests().length=${registry.getApiRouteManifests().length}`,\n )\n }\n } catch (error) {\n console.warn(\n '[tool-test-runner] Could not register api-routes manifest:',\n error instanceof Error ? error.message : error,\n )\n }\n}\n\nasync function loadGeneratedTools(): Promise<{ moduleId: string; tools: AiToolDefinition[] }[]> {\n const tsPath = findGeneratedAiToolsPath()\n if (!tsPath) {\n throw new Error(\n 'Could not locate apps/<app>/.mercato/generated/ai-tools.generated.ts. Run `yarn generate` first.',\n )\n }\n await ensureApiRouteManifestsRegistered(path.dirname(tsPath))\n const mod = await compileAndImportGenerated(tsPath)\n const entries = mod.aiToolConfigEntriesRaw ?? mod.aiToolConfigEntries ?? []\n const result: { moduleId: string; tools: AiToolDefinition[] }[] = []\n for (const entry of entries) {\n if (!entry || typeof entry.moduleId !== 'string') continue\n const tools = Array.isArray(entry.tools)\n ? entry.tools.filter(isToolDefinition)\n : []\n result.push({ moduleId: entry.moduleId, tools })\n }\n return result\n}\n\nexport async function pickDefaultTenant(\n container: AwilixContainer,\n): Promise<{ tenantId: string; organizationId: string | null; userId: string | null } | null> {\n try {\n const em = container.resolve<{\n getConnection: () => {\n execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>\n }\n }>('em') as unknown as {\n getConnection: () => {\n execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>\n }\n }\n const conn = em.getConnection()\n const tenantRows = await conn.execute(\n `SELECT id FROM tenants WHERE deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,\n )\n const tenantId =\n Array.isArray(tenantRows) && tenantRows[0]\n ? String((tenantRows[0] as Record<string, unknown>).id)\n : null\n if (!tenantId) return null\n const orgRows = await conn.execute(\n `SELECT id FROM organizations WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,\n [tenantId],\n )\n const organizationId =\n Array.isArray(orgRows) && orgRows[0]\n ? String((orgRows[0] as Record<string, unknown>).id)\n : null\n const userRows = await conn.execute(\n `SELECT id FROM users WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,\n [tenantId],\n )\n const userId =\n Array.isArray(userRows) && userRows[0]\n ? String((userRows[0] as Record<string, unknown>).id)\n : null\n return { tenantId, organizationId, userId }\n } catch (error) {\n if (process.env.OM_TOOL_TEST_DEBUG === '1') {\n console.warn('[tool-test-runner] pickDefaultTenant failed:', error)\n }\n return null\n }\n}\n\nfunction buildSuperAdminContext(\n container: AwilixContainer,\n tenantId: string | null,\n organizationId: string | null,\n userId: string | null,\n): McpToolContext {\n return {\n tenantId,\n organizationId,\n userId,\n container,\n userFeatures: ['*'],\n isSuperAdmin: true,\n }\n}\n\nfunction clipPreview(value: unknown): unknown {\n try {\n const json = JSON.stringify(value)\n if (json.length <= 400) return value\n return `${json.slice(0, 400)}\u2026(truncated, ${json.length} bytes)`\n } catch {\n return '[unserializable]'\n }\n}\n\nfunction dummyAgentForTool(tool: AiToolDefinition): AiAgentDefinition {\n // The runner never registers this agent; it's only used as input to\n // `prepareMutation` for shape compatibility. The id namespace is namespaced\n // under `__test__` so it cannot collide with real agents.\n const policy: AiAgentMutationPolicy = 'destructive-confirm-required'\n return {\n id: `__test__.${tool.name}`,\n moduleId: tool.name.split('.')[0] ?? '__test__',\n label: 'Tool Test Runner',\n description: 'Synthetic agent used by the tool test runner',\n systemPrompt: '',\n allowedTools: [tool.name],\n readOnly: false,\n mutationPolicy: policy,\n } as AiAgentDefinition\n}\n\nasync function executeReadTool(\n tool: AiToolDefinition,\n args: Record<string, unknown>,\n container: AwilixContainer,\n ctx: McpToolContext,\n): Promise<unknown> {\n // Mirror the dispatcher: resolve a fresh container per call so EM identity\n // map state is clean between tools.\n const fresh = await createRequestContainer()\n const freshCtx: McpToolContext = { ...ctx, container: fresh, tool }\n void container // keep arg for future use\n return tool.handler(args as never, freshCtx)\n}\n\nasync function executeMutationTool(\n tool: AiToolDefinition,\n args: Record<string, unknown>,\n container: AwilixContainer,\n ctx: McpToolContext,\n): Promise<unknown> {\n // Route through prepareMutation so we assert the approval contract still\n // holds \u2014 the handler must NEVER write directly. We pass a destructive\n // policy so the runtime treats the call as a confirmation candidate and\n // creates a pending-action row.\n const fresh = await createRequestContainer()\n const agent = dummyAgentForTool(tool)\n const userId = ctx.userId ?? '00000000-0000-0000-0000-000000000000'\n const { uiPart, pendingAction } = await prepareMutation(\n {\n agent,\n tool,\n toolCallArgs: args,\n conversationId: null,\n mutationPolicyOverride: null,\n },\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId,\n features: ctx.userFeatures,\n isSuperAdmin: ctx.isSuperAdmin,\n container: fresh,\n },\n )\n // Exercise the actual handler via the same path the production confirm\n // route uses. Without this, mutation tools whose handler crashes (e.g.\n // missing `ctx.tool` for the API operation runner) would be reported as\n // passing because `prepareMutation` never invokes the handler.\n // `prepareMutation` already enforced `tenantId` is non-null above; cast\n // here for the stricter `PendingActionExecuteContext` shape.\n const confirmTenantId = ctx.tenantId as string\n const confirmContainer = await createRequestContainer()\n const confirmation = await executePendingActionConfirm({\n action: pendingAction,\n agent,\n tool,\n ctx: {\n tenantId: confirmTenantId,\n organizationId: ctx.organizationId,\n userId,\n container: confirmContainer,\n userFeatures: ctx.userFeatures,\n isSuperAdmin: ctx.isSuperAdmin,\n },\n emitEvent: async () => {},\n })\n if (!confirmation.ok) {\n const error = (confirmation.executionResult as { error?: { message?: string } } | undefined)?.error\n const cause = confirmation.cause\n const causeMessage =\n cause instanceof Error\n ? cause.message\n : typeof cause === 'string'\n ? cause\n : null\n const message = error?.message ?? causeMessage ?? 'handler invocation failed'\n throw new Error(message)\n }\n return {\n status: 'pending-confirmation',\n pendingActionId: pendingAction.id,\n expiresAt: pendingAction.expiresAt.toISOString(),\n uiPartType: (uiPart as { type?: string } | undefined)?.type ?? null,\n handlerExecuted: true,\n }\n}\n\nasync function resolveFixtureInput(\n fixture: ToolFixture,\n resolved: Map<string, AiToolDefinition>,\n container: AwilixContainer,\n ctx: McpToolContext,\n): Promise<{ ok: true; input: Record<string, unknown> } | { ok: false; reason: string }> {\n if (fixture.input) return { ok: true, input: { ...fixture.input } }\n if (!fixture.idFrom) {\n return { ok: false, reason: 'fixture has neither input nor idFrom' }\n }\n const sourceTool = resolved.get(fixture.idFrom)\n if (!sourceTool) {\n return { ok: false, reason: `idFrom tool not found: ${fixture.idFrom}` }\n }\n const sourceFixture = getFixture(fixture.idFrom)\n if (!sourceFixture?.input) {\n return {\n ok: false,\n reason: `idFrom source ${fixture.idFrom} has no static input fixture`,\n }\n }\n let listResult: unknown\n try {\n listResult = await executeReadTool(\n sourceTool,\n { ...sourceFixture.input },\n container,\n ctx,\n )\n } catch (error) {\n return {\n ok: false,\n reason: `idFrom source ${fixture.idFrom} threw: ${\n error instanceof Error ? error.message : String(error)\n }`,\n }\n }\n const records = extractRecords(listResult)\n if (!records.length) {\n return {\n ok: false,\n reason: `idFrom source ${fixture.idFrom} returned no records`,\n }\n }\n const idField = 'id'\n const id = (records[0] as Record<string, unknown>)[idField]\n if (typeof id !== 'string' || !id) {\n return {\n ok: false,\n reason: `idFrom source ${fixture.idFrom} first record has no string id`,\n }\n }\n const bindAs = fixture.bindAs ?? 'id'\n return {\n ok: true,\n input: { [bindAs]: id, ...(fixture.extra ?? {}) },\n }\n}\n\nfunction extractRecords(value: unknown): unknown[] {\n if (!value || typeof value !== 'object') return []\n const candidate = value as Record<string, unknown>\n for (const key of ['records', 'items', 'people', 'companies', 'deals', 'results', 'data']) {\n const arr = candidate[key]\n if (Array.isArray(arr)) return arr\n }\n return []\n}\n\nexport interface RunToolTestsOptions {\n tenantId?: string | null\n organizationId?: string | null\n userId?: string | null\n moduleFilter?: string | null\n includeMutations?: boolean\n}\n\nexport async function runToolTests(\n options: RunToolTestsOptions = {},\n): Promise<ToolTestReport> {\n const includeMutations = options.includeMutations ?? true\n const container = await createRequestContainer()\n let tenantId: string | null = options.tenantId ?? null\n let organizationId: string | null = options.organizationId ?? null\n let userId: string | null = options.userId ?? null\n if (!tenantId) {\n const picked = await pickDefaultTenant(container)\n if (picked) {\n tenantId = picked.tenantId\n organizationId = picked.organizationId\n userId = userId ?? picked.userId\n }\n }\n const ctx = buildSuperAdminContext(container, tenantId, organizationId, userId)\n const grouped = await loadGeneratedTools()\n\n const flat: { moduleId: string; tool: AiToolDefinition }[] = []\n for (const group of grouped) {\n if (options.moduleFilter && group.moduleId !== options.moduleFilter) continue\n for (const tool of group.tools) flat.push({ moduleId: group.moduleId, tool })\n }\n const resolved = new Map<string, AiToolDefinition>()\n for (const { tool } of flat) resolved.set(tool.name, tool)\n\n const records: ToolTestRecord[] = []\n for (const { moduleId, tool } of flat) {\n const start = Date.now()\n const isMutation = tool.isMutation === true\n const fixture = getFixture(tool.name)\n if (!fixture) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'skip',\n durationMs: Date.now() - start,\n reason: 'no fixture',\n })\n continue\n }\n if (fixture.skip) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'skip',\n durationMs: Date.now() - start,\n reason: fixture.note ?? 'skip-by-fixture',\n })\n continue\n }\n if (isMutation && !includeMutations) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'skip',\n durationMs: Date.now() - start,\n reason: 'mutations excluded',\n })\n continue\n }\n const inputResult = await resolveFixtureInput(fixture, resolved, container, ctx)\n if (!inputResult.ok) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'skip',\n durationMs: Date.now() - start,\n reason: inputResult.reason,\n })\n continue\n }\n try {\n const result = isMutation\n ? await executeMutationTool(tool, inputResult.input, container, ctx)\n : await executeReadTool(tool, inputResult.input, container, ctx)\n // Result must be JSON-serializable.\n JSON.stringify(result)\n // Mutation tools must return a pending-confirmation envelope.\n if (\n isMutation &&\n (typeof result !== 'object' ||\n result === null ||\n (result as Record<string, unknown>).status !== 'pending-confirmation')\n ) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'fail',\n durationMs: Date.now() - start,\n reason: 'mutation tool did not route through prepareMutation (no pending-confirmation envelope)',\n resultPreview: clipPreview(result),\n })\n continue\n }\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'pass',\n durationMs: Date.now() - start,\n resultPreview: clipPreview(result),\n })\n } catch (error) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'fail',\n durationMs: Date.now() - start,\n reason: error instanceof Error ? error.message : String(error),\n })\n }\n }\n const passed = records.filter((r) => r.status === 'pass').length\n const failed = records.filter((r) => r.status === 'fail').length\n const skipped = records.filter((r) => r.status === 'skip').length\n return {\n tenantId,\n organizationId,\n total: records.length,\n passed,\n failed,\n skipped,\n records,\n }\n}\n"],
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["/**\n * In-process AI tool test runner.\n *\n * Iterates every tool registered in `ai-tools.generated.ts`, invokes the\n * handler against a super-admin tenant context, and returns a structured\n * report. Used by the `mercato ai_assistant test-tools` CLI subcommand and\n * by `.ai/qa/tests/integration/TC-INT-AI-TOOLS.spec.ts`.\n *\n * Safety posture:\n * - No HTTP exposure \u2014 runs only inside the Node process driving the CLI.\n * - Mutation tools are exercised through `prepareMutation` and we assert a\n * pending-action envelope is returned. The pending-action row is created\n * in `ai_pending_actions` but never confirmed, so no real write happens.\n * - Tools without an explicit fixture entry are skipped with reason\n * `'no fixture'` rather than failing.\n */\nimport type { AwilixContainer } from 'awilix'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { McpToolContext, AiToolDefinition } from './types'\nimport type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'\nimport { getFixture, type ToolFixture } from './tool-test-fixtures'\nimport { prepareMutation } from './prepare-mutation'\nimport { executePendingActionConfirm } from './pending-action-executor'\nimport {\n findGeneratedFile,\n compileAndImportGenerated,\n ensureApiRouteManifestsRegistered,\n} from './generated-registry-loader'\n\nexport type ToolTestStatus = 'pass' | 'fail' | 'skip'\n\nexport interface ToolTestRecord {\n module: string\n tool: string\n isMutation: boolean\n status: ToolTestStatus\n durationMs: number\n reason?: string\n resultPreview?: unknown\n}\n\nexport interface ToolTestReport {\n tenantId: string | null\n organizationId: string | null\n total: number\n passed: number\n failed: number\n skipped: number\n records: ToolTestRecord[]\n}\n\ninterface RawAiToolsModule {\n aiToolConfigEntriesRaw?: { moduleId: string; tools: unknown[] }[]\n aiToolConfigEntries?: { moduleId: string; tools: unknown[] }[]\n}\n\nfunction isToolDefinition(value: unknown): value is AiToolDefinition {\n if (!value || typeof value !== 'object') return false\n const candidate = value as Record<string, unknown>\n return (\n typeof candidate.name === 'string' &&\n typeof candidate.description === 'string' &&\n candidate.inputSchema !== undefined &&\n typeof candidate.handler === 'function'\n )\n}\n\nasync function loadGeneratedTools(): Promise<{ moduleId: string; tools: AiToolDefinition[] }[]> {\n const tsPath = findGeneratedFile('ai-tools.generated.ts')\n if (!tsPath) {\n throw new Error(\n 'Could not locate apps/<app>/.mercato/generated/ai-tools.generated.ts. Run `yarn generate` first.',\n )\n }\n await ensureApiRouteManifestsRegistered()\n const mod = (await compileAndImportGenerated(tsPath)) as RawAiToolsModule\n const entries = mod.aiToolConfigEntriesRaw ?? mod.aiToolConfigEntries ?? []\n const result: { moduleId: string; tools: AiToolDefinition[] }[] = []\n for (const entry of entries) {\n if (!entry || typeof entry.moduleId !== 'string') continue\n const tools = Array.isArray(entry.tools)\n ? entry.tools.filter(isToolDefinition)\n : []\n result.push({ moduleId: entry.moduleId, tools })\n }\n return result\n}\n\nexport async function pickDefaultTenant(\n container: AwilixContainer,\n): Promise<{ tenantId: string; organizationId: string | null; userId: string | null } | null> {\n try {\n const em = container.resolve<{\n getConnection: () => {\n execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>\n }\n }>('em') as unknown as {\n getConnection: () => {\n execute: (sql: string, params?: unknown[]) => Promise<Record<string, unknown>[]>\n }\n }\n const conn = em.getConnection()\n const tenantRows = await conn.execute(\n `SELECT id FROM tenants WHERE deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,\n )\n const tenantId =\n Array.isArray(tenantRows) && tenantRows[0]\n ? String((tenantRows[0] as Record<string, unknown>).id)\n : null\n if (!tenantId) return null\n const orgRows = await conn.execute(\n `SELECT id FROM organizations WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,\n [tenantId],\n )\n const organizationId =\n Array.isArray(orgRows) && orgRows[0]\n ? String((orgRows[0] as Record<string, unknown>).id)\n : null\n const userRows = await conn.execute(\n `SELECT id FROM users WHERE tenant_id = ? AND deleted_at IS NULL ORDER BY created_at ASC LIMIT 1`,\n [tenantId],\n )\n const userId =\n Array.isArray(userRows) && userRows[0]\n ? String((userRows[0] as Record<string, unknown>).id)\n : null\n return { tenantId, organizationId, userId }\n } catch (error) {\n if (process.env.OM_TOOL_TEST_DEBUG === '1') {\n console.warn('[tool-test-runner] pickDefaultTenant failed:', error)\n }\n return null\n }\n}\n\nfunction buildSuperAdminContext(\n container: AwilixContainer,\n tenantId: string | null,\n organizationId: string | null,\n userId: string | null,\n): McpToolContext {\n return {\n tenantId,\n organizationId,\n userId,\n container,\n userFeatures: ['*'],\n isSuperAdmin: true,\n }\n}\n\nfunction clipPreview(value: unknown): unknown {\n try {\n const json = JSON.stringify(value)\n if (json.length <= 400) return value\n return `${json.slice(0, 400)}\u2026(truncated, ${json.length} bytes)`\n } catch {\n return '[unserializable]'\n }\n}\n\nfunction dummyAgentForTool(tool: AiToolDefinition): AiAgentDefinition {\n // The runner never registers this agent; it's only used as input to\n // `prepareMutation` for shape compatibility. The id namespace is namespaced\n // under `__test__` so it cannot collide with real agents.\n const policy: AiAgentMutationPolicy = 'destructive-confirm-required'\n return {\n id: `__test__.${tool.name}`,\n moduleId: tool.name.split('.')[0] ?? '__test__',\n label: 'Tool Test Runner',\n description: 'Synthetic agent used by the tool test runner',\n systemPrompt: '',\n allowedTools: [tool.name],\n readOnly: false,\n mutationPolicy: policy,\n } as AiAgentDefinition\n}\n\nasync function executeReadTool(\n tool: AiToolDefinition,\n args: Record<string, unknown>,\n container: AwilixContainer,\n ctx: McpToolContext,\n): Promise<unknown> {\n // Mirror the dispatcher: resolve a fresh container per call so EM identity\n // map state is clean between tools.\n const fresh = await createRequestContainer()\n const freshCtx: McpToolContext = { ...ctx, container: fresh, tool }\n void container // keep arg for future use\n return tool.handler(args as never, freshCtx)\n}\n\nasync function executeMutationTool(\n tool: AiToolDefinition,\n args: Record<string, unknown>,\n container: AwilixContainer,\n ctx: McpToolContext,\n): Promise<unknown> {\n // Route through prepareMutation so we assert the approval contract still\n // holds \u2014 the handler must NEVER write directly. We pass a destructive\n // policy so the runtime treats the call as a confirmation candidate and\n // creates a pending-action row.\n const fresh = await createRequestContainer()\n const agent = dummyAgentForTool(tool)\n const userId = ctx.userId ?? '00000000-0000-0000-0000-000000000000'\n const { uiPart, pendingAction } = await prepareMutation(\n {\n agent,\n tool,\n toolCallArgs: args,\n conversationId: null,\n mutationPolicyOverride: null,\n },\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId,\n features: ctx.userFeatures,\n isSuperAdmin: ctx.isSuperAdmin,\n container: fresh,\n },\n )\n // Exercise the actual handler via the same path the production confirm\n // route uses. Without this, mutation tools whose handler crashes (e.g.\n // missing `ctx.tool` for the API operation runner) would be reported as\n // passing because `prepareMutation` never invokes the handler.\n // `prepareMutation` already enforced `tenantId` is non-null above; cast\n // here for the stricter `PendingActionExecuteContext` shape.\n const confirmTenantId = ctx.tenantId as string\n const confirmContainer = await createRequestContainer()\n const confirmation = await executePendingActionConfirm({\n action: pendingAction,\n agent,\n tool,\n ctx: {\n tenantId: confirmTenantId,\n organizationId: ctx.organizationId,\n userId,\n container: confirmContainer,\n userFeatures: ctx.userFeatures,\n isSuperAdmin: ctx.isSuperAdmin,\n },\n emitEvent: async () => {},\n })\n if (!confirmation.ok) {\n const error = (confirmation.executionResult as { error?: { message?: string } } | undefined)?.error\n const cause = confirmation.cause\n const causeMessage =\n cause instanceof Error\n ? cause.message\n : typeof cause === 'string'\n ? cause\n : null\n const message = error?.message ?? causeMessage ?? 'handler invocation failed'\n throw new Error(message)\n }\n return {\n status: 'pending-confirmation',\n pendingActionId: pendingAction.id,\n expiresAt: pendingAction.expiresAt.toISOString(),\n uiPartType: (uiPart as { type?: string } | undefined)?.type ?? null,\n handlerExecuted: true,\n }\n}\n\nasync function resolveFixtureInput(\n fixture: ToolFixture,\n resolved: Map<string, AiToolDefinition>,\n container: AwilixContainer,\n ctx: McpToolContext,\n): Promise<{ ok: true; input: Record<string, unknown> } | { ok: false; reason: string }> {\n if (fixture.input) return { ok: true, input: { ...fixture.input } }\n if (!fixture.idFrom) {\n return { ok: false, reason: 'fixture has neither input nor idFrom' }\n }\n const sourceTool = resolved.get(fixture.idFrom)\n if (!sourceTool) {\n return { ok: false, reason: `idFrom tool not found: ${fixture.idFrom}` }\n }\n const sourceFixture = getFixture(fixture.idFrom)\n if (!sourceFixture?.input) {\n return {\n ok: false,\n reason: `idFrom source ${fixture.idFrom} has no static input fixture`,\n }\n }\n let listResult: unknown\n try {\n listResult = await executeReadTool(\n sourceTool,\n { ...sourceFixture.input },\n container,\n ctx,\n )\n } catch (error) {\n return {\n ok: false,\n reason: `idFrom source ${fixture.idFrom} threw: ${\n error instanceof Error ? error.message : String(error)\n }`,\n }\n }\n const records = extractRecords(listResult)\n if (!records.length) {\n return {\n ok: false,\n reason: `idFrom source ${fixture.idFrom} returned no records`,\n }\n }\n const idField = 'id'\n const id = (records[0] as Record<string, unknown>)[idField]\n if (typeof id !== 'string' || !id) {\n return {\n ok: false,\n reason: `idFrom source ${fixture.idFrom} first record has no string id`,\n }\n }\n const bindAs = fixture.bindAs ?? 'id'\n return {\n ok: true,\n input: { [bindAs]: id, ...(fixture.extra ?? {}) },\n }\n}\n\nfunction extractRecords(value: unknown): unknown[] {\n if (!value || typeof value !== 'object') return []\n const candidate = value as Record<string, unknown>\n for (const key of ['records', 'items', 'people', 'companies', 'deals', 'results', 'data']) {\n const arr = candidate[key]\n if (Array.isArray(arr)) return arr\n }\n return []\n}\n\nexport interface RunToolTestsOptions {\n tenantId?: string | null\n organizationId?: string | null\n userId?: string | null\n moduleFilter?: string | null\n includeMutations?: boolean\n}\n\nexport async function runToolTests(\n options: RunToolTestsOptions = {},\n): Promise<ToolTestReport> {\n const includeMutations = options.includeMutations ?? true\n const container = await createRequestContainer()\n let tenantId: string | null = options.tenantId ?? null\n let organizationId: string | null = options.organizationId ?? null\n let userId: string | null = options.userId ?? null\n if (!tenantId) {\n const picked = await pickDefaultTenant(container)\n if (picked) {\n tenantId = picked.tenantId\n organizationId = picked.organizationId\n userId = userId ?? picked.userId\n }\n }\n const ctx = buildSuperAdminContext(container, tenantId, organizationId, userId)\n const grouped = await loadGeneratedTools()\n\n const flat: { moduleId: string; tool: AiToolDefinition }[] = []\n for (const group of grouped) {\n if (options.moduleFilter && group.moduleId !== options.moduleFilter) continue\n for (const tool of group.tools) flat.push({ moduleId: group.moduleId, tool })\n }\n const resolved = new Map<string, AiToolDefinition>()\n for (const { tool } of flat) resolved.set(tool.name, tool)\n\n const records: ToolTestRecord[] = []\n for (const { moduleId, tool } of flat) {\n const start = Date.now()\n const isMutation = tool.isMutation === true\n const fixture = getFixture(tool.name)\n if (!fixture) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'skip',\n durationMs: Date.now() - start,\n reason: 'no fixture',\n })\n continue\n }\n if (fixture.skip) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'skip',\n durationMs: Date.now() - start,\n reason: fixture.note ?? 'skip-by-fixture',\n })\n continue\n }\n if (isMutation && !includeMutations) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'skip',\n durationMs: Date.now() - start,\n reason: 'mutations excluded',\n })\n continue\n }\n const inputResult = await resolveFixtureInput(fixture, resolved, container, ctx)\n if (!inputResult.ok) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'skip',\n durationMs: Date.now() - start,\n reason: inputResult.reason,\n })\n continue\n }\n try {\n const result = isMutation\n ? await executeMutationTool(tool, inputResult.input, container, ctx)\n : await executeReadTool(tool, inputResult.input, container, ctx)\n // Result must be JSON-serializable.\n JSON.stringify(result)\n // Mutation tools must return a pending-confirmation envelope.\n if (\n isMutation &&\n (typeof result !== 'object' ||\n result === null ||\n (result as Record<string, unknown>).status !== 'pending-confirmation')\n ) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'fail',\n durationMs: Date.now() - start,\n reason: 'mutation tool did not route through prepareMutation (no pending-confirmation envelope)',\n resultPreview: clipPreview(result),\n })\n continue\n }\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'pass',\n durationMs: Date.now() - start,\n resultPreview: clipPreview(result),\n })\n } catch (error) {\n records.push({\n module: moduleId,\n tool: tool.name,\n isMutation,\n status: 'fail',\n durationMs: Date.now() - start,\n reason: error instanceof Error ? error.message : String(error),\n })\n }\n }\n const passed = records.filter((r) => r.status === 'pass').length\n const failed = records.filter((r) => r.status === 'fail').length\n const skipped = records.filter((r) => r.status === 'skip').length\n return {\n tenantId,\n organizationId,\n total: records.length,\n passed,\n failed,\n skipped,\n records,\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ai-assistant",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.5266.2.9acdca82ec",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"engines": {
|
|
6
6
|
"node": ">=22.0.0"
|
|
@@ -98,16 +98,16 @@
|
|
|
98
98
|
"zod-to-json-schema": "^3.25.2"
|
|
99
99
|
},
|
|
100
100
|
"peerDependencies": {
|
|
101
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
102
|
-
"@open-mercato/ui": "0.6.5-develop.
|
|
101
|
+
"@open-mercato/shared": "0.6.5-develop.5266.2.9acdca82ec",
|
|
102
|
+
"@open-mercato/ui": "0.6.5-develop.5266.2.9acdca82ec",
|
|
103
103
|
"react": "^19.0.0",
|
|
104
104
|
"react-dom": "^19.0.0",
|
|
105
105
|
"zod": ">=3.23.0"
|
|
106
106
|
},
|
|
107
107
|
"devDependencies": {
|
|
108
|
-
"@open-mercato/cli": "0.6.5-develop.
|
|
109
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
110
|
-
"@open-mercato/ui": "0.6.5-develop.
|
|
108
|
+
"@open-mercato/cli": "0.6.5-develop.5266.2.9acdca82ec",
|
|
109
|
+
"@open-mercato/shared": "0.6.5-develop.5266.2.9acdca82ec",
|
|
110
|
+
"@open-mercato/ui": "0.6.5-develop.5266.2.9acdca82ec",
|
|
111
111
|
"@types/react": "^19.2.17",
|
|
112
112
|
"@types/react-dom": "^19.2.3",
|
|
113
113
|
"react": "19.2.7",
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import os from 'node:os'
|
|
3
|
+
import path from 'node:path'
|
|
4
|
+
import { pathToFileURL } from 'node:url'
|
|
5
|
+
import * as registry from '@open-mercato/shared/modules/registry'
|
|
6
|
+
import {
|
|
7
|
+
rewriteGeneratedAliasImports,
|
|
8
|
+
findGeneratedFile,
|
|
9
|
+
ensureApiRouteManifestsRegistered,
|
|
10
|
+
} from '../generated-registry-loader'
|
|
11
|
+
|
|
12
|
+
function makeTempDir(): string {
|
|
13
|
+
return fs.mkdtempSync(path.join(os.tmpdir(), 'om-gen-loader-'))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe('rewriteGeneratedAliasImports', () => {
|
|
17
|
+
// Regression for the MCP dev-server crash:
|
|
18
|
+
// "Cannot find package '@/.mercato' imported from .../tool-loader.js"
|
|
19
|
+
// Generated registries use the Next.js `@/` alias, which a standalone Node
|
|
20
|
+
// process cannot resolve. The rewrite turns it into an absolute file URL.
|
|
21
|
+
|
|
22
|
+
it('rewrites a static `from "@/..."` import to an absolute file URL', () => {
|
|
23
|
+
const appRoot = makeTempDir()
|
|
24
|
+
const out = rewriteGeneratedAliasImports(
|
|
25
|
+
`import { x } from '@/.mercato/generated/entities'`,
|
|
26
|
+
appRoot,
|
|
27
|
+
)
|
|
28
|
+
const expectedUrl = pathToFileURL(
|
|
29
|
+
path.join(appRoot, '.mercato/generated/entities'),
|
|
30
|
+
).href
|
|
31
|
+
expect(out).toBe(`import { x } from ${JSON.stringify(expectedUrl)}`)
|
|
32
|
+
expect(out).not.toContain('@/')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('rewrites a dynamic `import("@/...")` call to an absolute file URL', () => {
|
|
36
|
+
const appRoot = makeTempDir()
|
|
37
|
+
const out = rewriteGeneratedAliasImports(
|
|
38
|
+
`const tools = () => import("@/.mercato/generated/ai-tools.generated")`,
|
|
39
|
+
appRoot,
|
|
40
|
+
)
|
|
41
|
+
const expectedUrl = pathToFileURL(
|
|
42
|
+
path.join(appRoot, '.mercato/generated/ai-tools.generated'),
|
|
43
|
+
).href
|
|
44
|
+
expect(out).toBe(`const tools = () => import(${JSON.stringify(expectedUrl)})`)
|
|
45
|
+
expect(out).not.toContain('@/')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('appends `.ts` when the aliased target exists only as a TypeScript file', () => {
|
|
49
|
+
const appRoot = makeTempDir()
|
|
50
|
+
fs.mkdirSync(path.join(appRoot, 'src'), { recursive: true })
|
|
51
|
+
fs.writeFileSync(path.join(appRoot, 'src', 'thing.ts'), 'export const a = 1\n')
|
|
52
|
+
|
|
53
|
+
const out = rewriteGeneratedAliasImports(`import x from '@/src/thing'`, appRoot)
|
|
54
|
+
|
|
55
|
+
const expectedUrl = pathToFileURL(path.join(appRoot, 'src', 'thing.ts')).href
|
|
56
|
+
expect(out).toBe(`import x from ${JSON.stringify(expectedUrl)}`)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it('leaves non-alias imports untouched', () => {
|
|
60
|
+
const source = [
|
|
61
|
+
`import { z } from 'zod'`,
|
|
62
|
+
`import x from './local'`,
|
|
63
|
+
`import y from '@open-mercato/shared/x'`,
|
|
64
|
+
].join('\n')
|
|
65
|
+
expect(rewriteGeneratedAliasImports(source, makeTempDir())).toBe(source)
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
describe('findGeneratedFile', () => {
|
|
70
|
+
let cwdSpy: jest.SpyInstance
|
|
71
|
+
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
cwdSpy?.mockRestore()
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('returns null when the file exists nowhere on the search path', () => {
|
|
77
|
+
// A name that cannot exist anywhere up the tree or under cwd.
|
|
78
|
+
expect(findGeneratedFile('definitely-not-real-xyz.generated.ts')).toBeNull()
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
it('locates the file under <cwd>/.mercato/generated (standalone app layout)', () => {
|
|
82
|
+
const appRoot = makeTempDir()
|
|
83
|
+
const generatedDir = path.join(appRoot, '.mercato', 'generated')
|
|
84
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
85
|
+
const target = path.join(generatedDir, 'unique-standalone.generated.ts')
|
|
86
|
+
fs.writeFileSync(target, 'export const apiRoutes = []\n')
|
|
87
|
+
cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(appRoot)
|
|
88
|
+
|
|
89
|
+
expect(findGeneratedFile('unique-standalone.generated.ts')).toBe(target)
|
|
90
|
+
})
|
|
91
|
+
|
|
92
|
+
it('locates the file under <cwd>/apps/mercato/.mercato/generated (monorepo layout)', () => {
|
|
93
|
+
const root = makeTempDir()
|
|
94
|
+
const generatedDir = path.join(root, 'apps', 'mercato', '.mercato', 'generated')
|
|
95
|
+
fs.mkdirSync(generatedDir, { recursive: true })
|
|
96
|
+
const target = path.join(generatedDir, 'unique-monorepo.generated.ts')
|
|
97
|
+
fs.writeFileSync(target, 'export const apiRoutes = []\n')
|
|
98
|
+
cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(root)
|
|
99
|
+
|
|
100
|
+
expect(findGeneratedFile('unique-monorepo.generated.ts')).toBe(target)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('prefers the apps/mercato layout over the cwd-root layout when both exist', () => {
|
|
104
|
+
const root = makeTempDir()
|
|
105
|
+
const appsDir = path.join(root, 'apps', 'mercato', '.mercato', 'generated')
|
|
106
|
+
const rootDir = path.join(root, '.mercato', 'generated')
|
|
107
|
+
fs.mkdirSync(appsDir, { recursive: true })
|
|
108
|
+
fs.mkdirSync(rootDir, { recursive: true })
|
|
109
|
+
const fileName = 'unique-both.generated.ts'
|
|
110
|
+
fs.writeFileSync(path.join(appsDir, fileName), 'export const apiRoutes = []\n')
|
|
111
|
+
fs.writeFileSync(path.join(rootDir, fileName), 'export const apiRoutes = []\n')
|
|
112
|
+
cwdSpy = jest.spyOn(process, 'cwd').mockReturnValue(root)
|
|
113
|
+
|
|
114
|
+
expect(findGeneratedFile(fileName)).toBe(path.join(appsDir, fileName))
|
|
115
|
+
})
|
|
116
|
+
})
|
|
117
|
+
|
|
118
|
+
describe('ensureApiRouteManifestsRegistered', () => {
|
|
119
|
+
// Regression for "Operation runner manifest unavailable: No API route manifest
|
|
120
|
+
// registered" when calling API-backed module tools over MCP. The standalone
|
|
121
|
+
// MCP servers must register the manifest, but must NOT clobber the in-app
|
|
122
|
+
// agents-framework manifest registered at bootstrap.
|
|
123
|
+
|
|
124
|
+
it('is a no-op when a manifest is already registered (does not interfere with bootstrap)', async () => {
|
|
125
|
+
const getSpy = jest
|
|
126
|
+
.spyOn(registry, 'getApiRouteManifests')
|
|
127
|
+
.mockReturnValue([{ id: 'existing' }] as never)
|
|
128
|
+
const registerSpy = jest
|
|
129
|
+
.spyOn(registry, 'registerApiRouteManifests')
|
|
130
|
+
.mockImplementation(() => {})
|
|
131
|
+
|
|
132
|
+
const count = await ensureApiRouteManifestsRegistered()
|
|
133
|
+
|
|
134
|
+
expect(count).toBe(1)
|
|
135
|
+
expect(registerSpy).not.toHaveBeenCalled()
|
|
136
|
+
|
|
137
|
+
getSpy.mockRestore()
|
|
138
|
+
registerSpy.mockRestore()
|
|
139
|
+
})
|
|
140
|
+
})
|
|
@@ -8,10 +8,38 @@ import {
|
|
|
8
8
|
type AiAgentOverrideConfigEntry,
|
|
9
9
|
} from './ai-overrides'
|
|
10
10
|
import { TASK_PLAN_TOOL_NAME } from './task-plan-labels'
|
|
11
|
+
import { findGeneratedFile, compileAndImportGenerated } from './generated-registry-loader'
|
|
11
12
|
|
|
12
13
|
const agentsById = new Map<string, AiAgentDefinition>()
|
|
13
14
|
let loaded = false
|
|
14
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Import the generated `ai-agents.generated.ts` registry.
|
|
18
|
+
*
|
|
19
|
+
* Dual-strategy, mirroring `tool-loader`'s `importGeneratedAiToolsModule`:
|
|
20
|
+
* 1. Prefer the `@/` path-alias import — the Next.js bundler resolves it at
|
|
21
|
+
* build time, so the in-app agent runtime is unchanged.
|
|
22
|
+
* 2. Fall back to locating + compiling the file from disk when the alias
|
|
23
|
+
* import throws. That only happens in a standalone Node process (the
|
|
24
|
+
* `mcp:dev` / `mcp:serve` MCP servers), where `@/` is not a real package
|
|
25
|
+
* specifier and Node throws `ERR_MODULE_NOT_FOUND`. Without this the
|
|
26
|
+
* `meta.list_agents` / `meta.describe_agent` tools return an empty agent
|
|
27
|
+
* registry over MCP.
|
|
28
|
+
*
|
|
29
|
+
* Returns `null` when no generated file exists (pre-generate builds, tests).
|
|
30
|
+
*/
|
|
31
|
+
async function importGeneratedAiAgentsModule(): Promise<Record<string, unknown> | null> {
|
|
32
|
+
try {
|
|
33
|
+
return (await import(
|
|
34
|
+
'@/.mercato/generated/ai-agents.generated'
|
|
35
|
+
)) as Record<string, unknown>
|
|
36
|
+
} catch {
|
|
37
|
+
const tsPath = findGeneratedFile('ai-agents.generated.ts')
|
|
38
|
+
if (!tsPath) return null
|
|
39
|
+
return compileAndImportGenerated(tsPath)
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
15
43
|
function isStringArray(value: unknown): value is string[] {
|
|
16
44
|
return Array.isArray(value) && value.every((item) => typeof item === 'string')
|
|
17
45
|
}
|
|
@@ -170,10 +198,10 @@ function populateFromAgents(agents: unknown[]): void {
|
|
|
170
198
|
|
|
171
199
|
async function loadOverrideEntries(): Promise<AiAgentOverrideConfigEntry[]> {
|
|
172
200
|
try {
|
|
173
|
-
const mod = (await
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
return Array.isArray(mod.aiAgentOverrideEntries)
|
|
201
|
+
const mod = (await importGeneratedAiAgentsModule()) as {
|
|
202
|
+
aiAgentOverrideEntries?: unknown[]
|
|
203
|
+
} | null
|
|
204
|
+
return mod && Array.isArray(mod.aiAgentOverrideEntries)
|
|
177
205
|
? (mod.aiAgentOverrideEntries as AiAgentOverrideConfigEntry[])
|
|
178
206
|
: []
|
|
179
207
|
} catch {
|
|
@@ -196,12 +224,13 @@ function applyOverridesToRegistry(entries: readonly AiAgentOverrideConfigEntry[]
|
|
|
196
224
|
|
|
197
225
|
async function loadExtensionEntries(): Promise<AiAgentExtension[]> {
|
|
198
226
|
try {
|
|
199
|
-
const mod = (await
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
const entries =
|
|
203
|
-
|
|
204
|
-
|
|
227
|
+
const mod = (await importGeneratedAiAgentsModule()) as {
|
|
228
|
+
aiAgentExtensionEntries?: unknown[]
|
|
229
|
+
} | null
|
|
230
|
+
const entries =
|
|
231
|
+
mod && Array.isArray(mod.aiAgentExtensionEntries)
|
|
232
|
+
? (mod.aiAgentExtensionEntries as AiAgentExtensionConfigEntry[])
|
|
233
|
+
: []
|
|
205
234
|
return composeAgentExtensionEntries(entries).filter(isAiAgentExtension)
|
|
206
235
|
} catch {
|
|
207
236
|
return []
|
|
@@ -249,10 +278,10 @@ function applyExtensionsToRegistry(extensions: readonly AiAgentExtension[]): voi
|
|
|
249
278
|
export async function loadAgentRegistry(): Promise<void> {
|
|
250
279
|
if (loaded) return
|
|
251
280
|
try {
|
|
252
|
-
const mod = (await
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
const agents = Array.isArray(mod.allAiAgents) ? mod.allAiAgents : []
|
|
281
|
+
const mod = (await importGeneratedAiAgentsModule()) as {
|
|
282
|
+
allAiAgents?: unknown[]
|
|
283
|
+
} | null
|
|
284
|
+
const agents = mod && Array.isArray(mod.allAiAgents) ? mod.allAiAgents : []
|
|
256
285
|
populateFromAgents(agents)
|
|
257
286
|
} catch (error) {
|
|
258
287
|
console.error(
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime loader for `.mercato/generated/*.generated.ts` registry files.
|
|
3
|
+
*
|
|
4
|
+
* The generated registries import their entries through the `@/` path alias
|
|
5
|
+
* (e.g. `@/.mercato/generated/ai-tools.generated`). That alias is only
|
|
6
|
+
* understood by the Next.js bundler — in a standalone Node process (the
|
|
7
|
+
* `mcp:dev` / `mcp:serve` MCP servers, the CLI tool-test runner) a raw
|
|
8
|
+
* `import('@/.mercato/...')` throws `ERR_MODULE_NOT_FOUND: Cannot find
|
|
9
|
+
* package '@/.mercato'` because Node treats `@/` as a package specifier.
|
|
10
|
+
*
|
|
11
|
+
* These helpers locate the generated `.ts` file on disk and compile-and-import
|
|
12
|
+
* it with esbuild (transpile-only), rewriting `@/` aliases to absolute paths.
|
|
13
|
+
* This mirrors `loadBootstrapData` in
|
|
14
|
+
* `@open-mercato/shared/lib/bootstrap/dynamicLoader` and works in both the
|
|
15
|
+
* monorepo and standalone apps.
|
|
16
|
+
*/
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import fs from 'node:fs'
|
|
19
|
+
import { fileURLToPath, pathToFileURL } from 'node:url'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Locate a generated registry file (e.g. `ai-tools.generated.ts`) without
|
|
23
|
+
* hardcoding the workspace layout. Searches upward from this module's compiled
|
|
24
|
+
* location for a `apps/mercato/.mercato/generated/<fileName>` (monorepo), then
|
|
25
|
+
* falls back to cwd-relative lookups (standalone apps run from the app dir).
|
|
26
|
+
*/
|
|
27
|
+
export function findGeneratedFile(fileName: string): string | null {
|
|
28
|
+
const here = (() => {
|
|
29
|
+
try {
|
|
30
|
+
return fileURLToPath(import.meta.url)
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
})()
|
|
35
|
+
|
|
36
|
+
if (here) {
|
|
37
|
+
let cursor = path.dirname(here)
|
|
38
|
+
for (let i = 0; i < 12; i++) {
|
|
39
|
+
const candidate = path.join(cursor, 'apps', 'mercato', '.mercato', 'generated', fileName)
|
|
40
|
+
if (fs.existsSync(candidate)) return candidate
|
|
41
|
+
const next = path.dirname(cursor)
|
|
42
|
+
if (next === cursor) break
|
|
43
|
+
cursor = next
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
// Fallbacks: cwd-based lookup (CLI invoked from apps/mercato, or a standalone
|
|
47
|
+
// app whose root holds `.mercato/generated`).
|
|
48
|
+
const fromCwd = path.resolve(process.cwd(), 'apps', 'mercato', '.mercato', 'generated', fileName)
|
|
49
|
+
if (fs.existsSync(fromCwd)) return fromCwd
|
|
50
|
+
const fromCwdDirect = path.resolve(process.cwd(), '.mercato', 'generated', fileName)
|
|
51
|
+
if (fs.existsSync(fromCwdDirect)) return fromCwdDirect
|
|
52
|
+
return null
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Compile-and-import a generated registry file on the fly. Resolves the `@/`
|
|
57
|
+
* alias to the app root, transpiles TS → ESM, and emits a sibling `.mjs` we can
|
|
58
|
+
* `import()` from Node. Cached on mtime so repeat calls in the same process
|
|
59
|
+
* don't recompile.
|
|
60
|
+
*
|
|
61
|
+
* Transpile-only (no bundling): the generated registries declare an array
|
|
62
|
+
* literal whose entries are static `import("…")` arrow functions — we want
|
|
63
|
+
* those `import()` strings to stay as runtime imports so Node resolves them
|
|
64
|
+
* lazily through the workspace's normal module resolution. Eagerly bundling
|
|
65
|
+
* pulls Next.js / route-handler internals into the `.mjs` and breaks at runtime
|
|
66
|
+
* (e.g. `next/server` package-exports map).
|
|
67
|
+
*/
|
|
68
|
+
export async function compileAndImportGenerated(tsPath: string): Promise<Record<string, unknown>> {
|
|
69
|
+
const jsPath = tsPath.replace(/\.ts$/, '.mjs')
|
|
70
|
+
// appRoot is two directories up from `.mercato/generated/<file>.ts`.
|
|
71
|
+
const appRoot = path.dirname(path.dirname(path.dirname(tsPath)))
|
|
72
|
+
|
|
73
|
+
if (!fs.existsSync(tsPath)) {
|
|
74
|
+
throw new Error(`Generated file not found: ${tsPath}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const jsExists = fs.existsSync(jsPath)
|
|
78
|
+
const needsCompile =
|
|
79
|
+
!jsExists || fs.statSync(tsPath).mtimeMs > fs.statSync(jsPath).mtimeMs
|
|
80
|
+
|
|
81
|
+
if (needsCompile) {
|
|
82
|
+
const esbuild = await import('esbuild')
|
|
83
|
+
const tsSource = fs.readFileSync(tsPath, 'utf-8')
|
|
84
|
+
const aliasRewritten = rewriteGeneratedAliasImports(tsSource, appRoot)
|
|
85
|
+
const result = await esbuild.transform(aliasRewritten, {
|
|
86
|
+
loader: 'ts',
|
|
87
|
+
format: 'esm',
|
|
88
|
+
target: 'node18',
|
|
89
|
+
sourcemap: false,
|
|
90
|
+
sourcefile: tsPath,
|
|
91
|
+
})
|
|
92
|
+
fs.writeFileSync(jsPath, result.code)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return (await import(pathToFileURL(jsPath).href)) as Record<string, unknown>
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Rewrite `@/...` path-alias imports (both `from "@/x"` and dynamic
|
|
100
|
+
* `import("@/x")`) in generated-registry source to absolute `file://` URLs
|
|
101
|
+
* rooted at `appRoot`. The `@/` alias is a Next.js bundler convention; outside
|
|
102
|
+
* the bundler Node treats `@/...` as a bare package specifier and throws
|
|
103
|
+
* `ERR_MODULE_NOT_FOUND`. Exported for unit testing.
|
|
104
|
+
*/
|
|
105
|
+
export function rewriteGeneratedAliasImports(source: string, appRoot: string): string {
|
|
106
|
+
const resolveAlias = (relativePath: string): string => {
|
|
107
|
+
const target = path.join(appRoot, relativePath)
|
|
108
|
+
const candidate = fs.existsSync(target)
|
|
109
|
+
? target
|
|
110
|
+
: fs.existsSync(target + '.ts')
|
|
111
|
+
? target + '.ts'
|
|
112
|
+
: target
|
|
113
|
+
return pathToFileURL(candidate).href
|
|
114
|
+
}
|
|
115
|
+
return source
|
|
116
|
+
.replace(/from\s+["']@\/([^"']+)["']/g, (_match, relativePath: string) => {
|
|
117
|
+
return `from ${JSON.stringify(resolveAlias(relativePath))}`
|
|
118
|
+
})
|
|
119
|
+
.replace(/import\s*\(\s*["']@\/([^"']+)["']\s*\)/g, (_match, relativePath: string) => {
|
|
120
|
+
return `import(${JSON.stringify(resolveAlias(relativePath))})`
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Compile-and-import `api-routes.generated.ts` and register its manifest with
|
|
126
|
+
* the shared registry. Many module tools are "API-backed" — their handlers
|
|
127
|
+
* delegate to `createAiApiOperationRunner`, which fails closed with
|
|
128
|
+
* "No API route manifest registered" unless the manifest is present. In the
|
|
129
|
+
* Next.js app this is wired at bootstrap, but the standalone MCP servers
|
|
130
|
+
* (`mcp:dev` / `mcp:serve`) bootstrap DI without it, so we register it here.
|
|
131
|
+
*
|
|
132
|
+
* Idempotent: `registerApiRouteManifests` replaces the stored manifest, so
|
|
133
|
+
* calling this repeatedly (e.g. per-request HTTP handlers) is safe. Returns the
|
|
134
|
+
* number of registered routes (0 when the generated file is absent).
|
|
135
|
+
*/
|
|
136
|
+
export async function ensureApiRouteManifestsRegistered(): Promise<number> {
|
|
137
|
+
const registry = await import('@open-mercato/shared/modules/registry')
|
|
138
|
+
// Already wired (e.g. the Next.js app bootstrap, or a prior call). Leave the
|
|
139
|
+
// existing manifest untouched so we never interfere with the in-app agents
|
|
140
|
+
// framework, which registers it at bootstrap with its own override pipeline.
|
|
141
|
+
const existing = registry.getApiRouteManifests()
|
|
142
|
+
if (existing.length > 0) return existing.length
|
|
143
|
+
|
|
144
|
+
const tsPath = findGeneratedFile('api-routes.generated.ts')
|
|
145
|
+
if (!tsPath) return 0
|
|
146
|
+
try {
|
|
147
|
+
const mod = await compileAndImportGenerated(tsPath)
|
|
148
|
+
const apiRoutes = (mod as { apiRoutes?: unknown }).apiRoutes
|
|
149
|
+
if (!Array.isArray(apiRoutes)) return 0
|
|
150
|
+
registry.registerApiRouteManifests(
|
|
151
|
+
apiRoutes as Parameters<typeof registry.registerApiRouteManifests>[0],
|
|
152
|
+
)
|
|
153
|
+
return apiRoutes.length
|
|
154
|
+
} catch (error) {
|
|
155
|
+
console.warn(
|
|
156
|
+
'[MCP Tools] Could not register api-routes manifest:',
|
|
157
|
+
error instanceof Error ? error.message : error,
|
|
158
|
+
)
|
|
159
|
+
return 0
|
|
160
|
+
}
|
|
161
|
+
}
|