@open-mercato/ai-assistant 0.6.5-develop.5337.1.534b781eac → 0.6.5

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 (82) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +14 -2
  3. package/README.md +1 -1
  4. package/dist/modules/ai_assistant/ai-tools/meta-pack.js +3 -1
  5. package/dist/modules/ai_assistant/ai-tools/meta-pack.js.map +2 -2
  6. package/dist/modules/ai_assistant/api/mcp-key/route.js +95 -0
  7. package/dist/modules/ai_assistant/api/mcp-key/route.js.map +7 -0
  8. package/dist/modules/ai_assistant/cli.js +14 -6
  9. package/dist/modules/ai_assistant/cli.js.map +2 -2
  10. package/dist/modules/ai_assistant/components/McpConfigDialog.js +1 -1
  11. package/dist/modules/ai_assistant/components/McpConfigDialog.js.map +2 -2
  12. package/dist/modules/ai_assistant/data/entities.js +4 -0
  13. package/dist/modules/ai_assistant/data/entities.js.map +2 -2
  14. package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js +3 -1
  15. package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js.map +2 -2
  16. package/dist/modules/ai_assistant/lib/ai-overrides.js.map +1 -1
  17. package/dist/modules/ai_assistant/lib/ai-sdk.js.map +1 -1
  18. package/dist/modules/ai_assistant/lib/attachment-parts.js +10 -3
  19. package/dist/modules/ai_assistant/lib/attachment-parts.js.map +2 -2
  20. package/dist/modules/ai_assistant/lib/chat-config.js.map +1 -1
  21. package/dist/modules/ai_assistant/lib/client-factory.js +0 -8
  22. package/dist/modules/ai_assistant/lib/client-factory.js.map +2 -2
  23. package/dist/modules/ai_assistant/lib/codemode-tools.js +26 -0
  24. package/dist/modules/ai_assistant/lib/codemode-tools.js.map +2 -2
  25. package/dist/modules/ai_assistant/lib/generated-registry-loader.js +23 -3
  26. package/dist/modules/ai_assistant/lib/generated-registry-loader.js.map +2 -2
  27. package/dist/modules/ai_assistant/lib/llm-adapters/anthropic.js.map +1 -1
  28. package/dist/modules/ai_assistant/lib/llm-adapters/google.js.map +1 -1
  29. package/dist/modules/ai_assistant/lib/llm-adapters/openai.js.map +1 -1
  30. package/dist/modules/ai_assistant/lib/llm-bootstrap.js.map +1 -1
  31. package/dist/modules/ai_assistant/lib/mcp-client.js +2 -4
  32. package/dist/modules/ai_assistant/lib/mcp-client.js.map +2 -2
  33. package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js +71 -0
  34. package/dist/modules/ai_assistant/lib/mcp-dev-key-resolution.js.map +7 -0
  35. package/dist/modules/ai_assistant/lib/mcp-dev-server.js +1 -30
  36. package/dist/modules/ai_assistant/lib/mcp-dev-server.js.map +2 -2
  37. package/dist/modules/ai_assistant/lib/mcp-server.js +29 -23
  38. package/dist/modules/ai_assistant/lib/mcp-server.js.map +2 -2
  39. package/dist/modules/ai_assistant/lib/openai-compatible-presets.js.map +1 -1
  40. package/dist/modules/ai_assistant/lib/task-plan-stream.js.map +1 -1
  41. package/dist/modules/ai_assistant/setup.js +9 -2
  42. package/dist/modules/ai_assistant/setup.js.map +2 -2
  43. package/package.json +11 -12
  44. package/src/modules/ai_assistant/__tests__/cli-mcp-serve-key.test.ts +63 -0
  45. package/src/modules/ai_assistant/__tests__/setup-schedules.test.ts +33 -0
  46. package/src/modules/ai_assistant/ai-tools/__tests__/meta-pack.test.ts +29 -0
  47. package/src/modules/ai_assistant/ai-tools/meta-pack.ts +7 -1
  48. package/src/modules/ai_assistant/api/mcp-key/__tests__/route.test.ts +144 -0
  49. package/src/modules/ai_assistant/api/mcp-key/route.ts +125 -0
  50. package/src/modules/ai_assistant/cli.ts +18 -5
  51. package/src/modules/ai_assistant/components/McpConfigDialog.tsx +5 -1
  52. package/src/modules/ai_assistant/data/__tests__/schema-unique-indexes.test.ts +39 -0
  53. package/src/modules/ai_assistant/data/entities.ts +5 -0
  54. package/src/modules/ai_assistant/lib/__tests__/ai-overrides.test.ts +1 -1
  55. package/src/modules/ai_assistant/lib/__tests__/attachment-parts.test.ts +112 -0
  56. package/src/modules/ai_assistant/lib/__tests__/client-factory.test.ts +42 -0
  57. package/src/modules/ai_assistant/lib/__tests__/codemode-tools.test.ts +107 -0
  58. package/src/modules/ai_assistant/lib/__tests__/generated-registry-loader.test.ts +30 -3
  59. package/src/modules/ai_assistant/lib/__tests__/mcp-client.test.ts +20 -1
  60. package/src/modules/ai_assistant/lib/__tests__/mcp-dev-key-resolution.test.ts +92 -0
  61. package/src/modules/ai_assistant/lib/__tests__/mcp-server-auth-fail-closed.test.ts +133 -0
  62. package/src/modules/ai_assistant/lib/__tests__/task-plan-stream.test.ts +1 -1
  63. package/src/modules/ai_assistant/lib/ai-api-operation-runner.ts +9 -0
  64. package/src/modules/ai_assistant/lib/ai-overrides.ts +1 -1
  65. package/src/modules/ai_assistant/lib/ai-sdk.ts +1 -1
  66. package/src/modules/ai_assistant/lib/attachment-parts.ts +26 -5
  67. package/src/modules/ai_assistant/lib/chat-config.ts +1 -1
  68. package/src/modules/ai_assistant/lib/client-factory.ts +3 -9
  69. package/src/modules/ai_assistant/lib/codemode-tools.ts +49 -0
  70. package/src/modules/ai_assistant/lib/generated-registry-loader.ts +35 -3
  71. package/src/modules/ai_assistant/lib/llm-adapters/anthropic.ts +1 -1
  72. package/src/modules/ai_assistant/lib/llm-adapters/google.ts +1 -1
  73. package/src/modules/ai_assistant/lib/llm-adapters/openai.ts +1 -1
  74. package/src/modules/ai_assistant/lib/llm-bootstrap.ts +1 -1
  75. package/src/modules/ai_assistant/lib/mcp-client.ts +9 -5
  76. package/src/modules/ai_assistant/lib/mcp-dev-key-resolution.ts +107 -0
  77. package/src/modules/ai_assistant/lib/mcp-dev-server.ts +1 -36
  78. package/src/modules/ai_assistant/lib/mcp-server.ts +47 -33
  79. package/src/modules/ai_assistant/lib/openai-compatible-presets.ts +1 -1
  80. package/src/modules/ai_assistant/lib/task-plan-stream.ts +1 -1
  81. package/src/modules/ai_assistant/lib/types.ts +7 -0
  82. package/src/modules/ai_assistant/setup.ts +11 -2
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/mcp-server.ts"],
4
- "sourcesContent": ["import { Server } from '@modelcontextprotocol/sdk/server/index.js'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport {\n ListToolsRequestSchema,\n CallToolRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js'\nimport { zodToJsonSchema } from 'zod-to-json-schema'\nimport { getToolRegistry } from './tool-registry'\nimport { executeTool } from './tool-executor'\nimport { loadAllModuleTools, indexToolsForSearch } from './tool-loader'\nimport { authenticateMcpRequest, hasRequiredFeatures } from './auth'\nimport type { McpServerOptions, McpToolContext } from './types'\nimport type { SearchService } from '@open-mercato/search/service'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\n\n/**\n * Create and configure an MCP server instance.\n */\nexport async function createMcpServer(options: McpServerOptions): Promise<Server> {\n const { config, container, context, apiKeySecret } = options\n\n let tenantId: string | null = null\n let organizationId: string | null = null\n let userId: string | null = null\n let userFeatures: string[] = []\n let isSuperAdmin = false\n\n // API key authentication takes precedence\n if (apiKeySecret) {\n const authResult = await authenticateMcpRequest(apiKeySecret, container)\n if (!authResult.success) {\n throw new Error(`API key authentication failed: ${authResult.error}`)\n }\n tenantId = authResult.tenantId\n organizationId = authResult.organizationId\n userId = authResult.userId\n userFeatures = authResult.features\n isSuperAdmin = authResult.isSuperAdmin\n console.error(`[MCP Server] Authenticated via API key: ${authResult.keyName}`)\n } else if (context) {\n // Manual context provided\n tenantId = context.tenantId\n organizationId = context.organizationId\n userId = context.userId\n\n if (userId) {\n try {\n const rbacService = container.resolve('rbacService') as {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null }\n ) => Promise<{\n isSuperAdmin: boolean\n features: string[]\n }>\n }\n const acl = await rbacService.loadAcl(userId, {\n tenantId,\n organizationId,\n })\n userFeatures = acl.features\n isSuperAdmin = acl.isSuperAdmin\n } catch (error) {\n console.error('[MCP Server] Failed to load user ACL:', error)\n }\n } else {\n // No user specified - grant superadmin access for development/testing\n isSuperAdmin = true\n console.error('[MCP Server] No user specified, running with superadmin access')\n }\n } else {\n // No context and no API key - superadmin for dev/testing\n isSuperAdmin = true\n console.error('[MCP Server] No auth context, running with superadmin access')\n }\n\n const toolContext: McpToolContext = {\n tenantId,\n organizationId,\n userId,\n container,\n userFeatures,\n isSuperAdmin,\n apiKeySecret,\n }\n\n const server = new Server(\n { name: config.name, version: config.version },\n { capabilities: { tools: {} } }\n )\n\n // List tools handler\n server.setRequestHandler(ListToolsRequestSchema, async () => {\n const registry = getToolRegistry()\n const tools = Array.from(registry.getTools().values())\n\n // Filter tools based on user permissions\n const rbacService = container.resolve<RbacService>('rbacService')\n const accessibleTools = tools.filter((tool) =>\n hasRequiredFeatures(tool.requiredFeatures, userFeatures, isSuperAdmin, rbacService)\n )\n\n if (config.debug) {\n console.error(\n `[MCP Server] Listing ${accessibleTools.length}/${tools.length} tools (filtered by ACL)`\n )\n }\n\n return {\n tools: accessibleTools.map((tool) => ({\n name: tool.name,\n description: tool.description,\n // Cast to any for Zod v4 compatibility with zod-to-json-schema\n inputSchema: zodToJsonSchema(tool.inputSchema as any) as Record<string, unknown>,\n })),\n }\n })\n\n // Call tool handler\n server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const { name, arguments: args } = request.params\n\n if (config.debug) {\n console.error(`[MCP Server] Calling tool: ${name}`, JSON.stringify(args))\n }\n\n const result = await executeTool(name, args ?? {}, toolContext)\n\n if (!result.success) {\n return {\n content: [\n {\n type: 'text',\n text: JSON.stringify({ error: result.error, code: result.errorCode }),\n },\n ],\n isError: true,\n }\n }\n\n return {\n content: [\n {\n type: 'text',\n text: JSON.stringify(result.result, null, 2),\n },\n ],\n }\n })\n\n return server\n}\n\n/**\n * Run MCP server with stdio transport.\n * This keeps the process running until terminated.\n *\n * Supports two authentication modes:\n * 1. API key: Provide `apiKeySecret` option\n * 2. Manual context: Provide `context` with tenant/org/user\n */\nexport async function runMcpServer(options: McpServerOptions): Promise<void> {\n // Generate entity graph for Code Mode search tool\n try {\n const { extractEntityGraph, cacheEntityGraph } = await import('./entity-graph')\n const { getOrm } = await import('@open-mercato/shared/lib/db/mikro')\n const orm = await getOrm()\n const graph = await extractEntityGraph(orm)\n cacheEntityGraph(graph)\n console.error(`[MCP Server] Entity graph: ${graph.nodes.length} entities`)\n } catch (error) {\n console.error('[MCP Server] Entity graph skipped:', error instanceof Error ? error.message : error)\n }\n\n // Pre-cache raw OpenAPI spec for Code Mode search tool\n try {\n const { getRawOpenApiSpec } = await import('./api-endpoint-index')\n await getRawOpenApiSpec()\n console.error('[MCP Server] Raw OpenAPI spec cached for Code Mode')\n } catch (error) {\n console.error('[MCP Server] Raw OpenAPI spec caching skipped:', error instanceof Error ? error.message : error)\n }\n\n // Load tools from all modules before starting\n await loadAllModuleTools()\n\n // Index tools for hybrid search discovery (if search service available)\n try {\n const searchService = options.container.resolve('searchService') as SearchService\n await indexToolsForSearch(searchService)\n } catch (error) {\n // Search service might not be configured - discovery will use fallback\n console.error('[MCP Server] Search indexing skipped (search service not available)')\n }\n\n const server = await createMcpServer(options)\n const transport = new StdioServerTransport()\n\n const toolCount = getToolRegistry().listToolNames().length\n\n console.error(`[MCP Server] Starting ${options.config.name} v${options.config.version}`)\n\n if (options.apiKeySecret) {\n console.error(`[MCP Server] Authentication: API key`)\n } else if (options.context) {\n console.error(`[MCP Server] Tenant: ${options.context.tenantId ?? '(none)'}`)\n console.error(`[MCP Server] Organization: ${options.context.organizationId ?? '(none)'}`)\n console.error(`[MCP Server] User: ${options.context.userId ?? '(superadmin)'}`)\n } else {\n console.error(`[MCP Server] Authentication: none (superadmin mode)`)\n }\n\n console.error(`[MCP Server] Tools registered: ${toolCount}`)\n\n await server.connect(transport)\n\n console.error('[MCP Server] Connected and ready for requests')\n\n // Handle shutdown gracefully\n const shutdown = async () => {\n console.error('[MCP Server] Shutting down...')\n await server.close()\n process.exit(0)\n }\n\n process.on('SIGINT', shutdown)\n process.on('SIGTERM', shutdown)\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { Server } from '@modelcontextprotocol/sdk/server/index.js'\nimport { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'\nimport {\n ListToolsRequestSchema,\n CallToolRequestSchema,\n} from '@modelcontextprotocol/sdk/types.js'\nimport { zodToJsonSchema } from 'zod-to-json-schema'\nimport { getToolRegistry } from './tool-registry'\nimport { executeTool } from './tool-executor'\nimport { loadAllModuleTools, indexToolsForSearch } from './tool-loader'\nimport { authenticateMcpRequest, hasRequiredFeatures } from './auth'\nimport type { McpServerOptions, McpToolContext } from './types'\nimport type { SearchService } from '@open-mercato/search/service'\nimport type { RbacService } from '@open-mercato/core/modules/auth/services/rbacService'\n\n/**\n * Create and configure an MCP server instance.\n */\nexport async function createMcpServer(options: McpServerOptions): Promise<Server> {\n const { config, container, context, allowUnauthenticatedSuperadmin } = options\n\n // Treat empty / whitespace-only secrets as missing so a blank api key cannot\n // fall through into an unauthenticated branch.\n const apiKeySecret =\n typeof options.apiKeySecret === 'string' && options.apiKeySecret.trim().length > 0\n ? options.apiKeySecret\n : undefined\n\n let tenantId: string | null = null\n let organizationId: string | null = null\n let userId: string | null = null\n let userFeatures: string[] = []\n let isSuperAdmin = false\n\n // API key authentication takes precedence\n if (apiKeySecret) {\n const authResult = await authenticateMcpRequest(apiKeySecret, container)\n if (!authResult.success) {\n throw new Error(`API key authentication failed: ${authResult.error}`)\n }\n tenantId = authResult.tenantId\n organizationId = authResult.organizationId\n userId = authResult.userId\n userFeatures = authResult.features\n isSuperAdmin = authResult.isSuperAdmin\n console.error(`[MCP Server] Authenticated via API key: ${authResult.keyName}`)\n } else if (context && context.userId) {\n // Manual context with a real user \u2014 load that user's ACL.\n tenantId = context.tenantId\n organizationId = context.organizationId\n userId = context.userId\n\n try {\n const rbacService = container.resolve('rbacService') as {\n loadAcl: (\n userId: string,\n scope: { tenantId: string | null; organizationId: string | null }\n ) => Promise<{\n isSuperAdmin: boolean\n features: string[]\n }>\n }\n const acl = await rbacService.loadAcl(userId, {\n tenantId,\n organizationId,\n })\n userFeatures = acl.features\n isSuperAdmin = acl.isSuperAdmin\n } catch (error) {\n console.error('[MCP Server] Failed to load user ACL:', error)\n }\n } else if (allowUnauthenticatedSuperadmin) {\n // Explicit, loud dev/testing opt-in. Without a user there is no ACL to load,\n // so the server runs as superadmin with no tenant scoping beyond whatever the\n // caller pinned via `context`. NEVER enable this in production.\n if (context) {\n tenantId = context.tenantId\n organizationId = context.organizationId\n }\n isSuperAdmin = true\n console.error(\n '[MCP Server] WARNING: allowUnauthenticatedSuperadmin is enabled \u2014 running with UNAUTHENTICATED SUPERADMIN access and no per-user ACL. Do not use this outside local development/testing.'\n )\n } else {\n // Fail closed: refuse to start rather than silently escalating to superadmin.\n throw new Error(\n '[internal] MCP server refused to start: no authentication provided. Supply a valid apiKeySecret, a context with a non-empty userId, or explicitly set allowUnauthenticatedSuperadmin: true for local development.'\n )\n }\n\n const toolContext: McpToolContext = {\n tenantId,\n organizationId,\n userId,\n container,\n userFeatures,\n isSuperAdmin,\n apiKeySecret,\n }\n\n const server = new Server(\n { name: config.name, version: config.version },\n { capabilities: { tools: {} } }\n )\n\n // List tools handler\n server.setRequestHandler(ListToolsRequestSchema, async () => {\n const registry = getToolRegistry()\n const tools = Array.from(registry.getTools().values())\n\n // Filter tools based on user permissions\n const rbacService = container.resolve<RbacService>('rbacService')\n const accessibleTools = tools.filter((tool) =>\n hasRequiredFeatures(tool.requiredFeatures, userFeatures, isSuperAdmin, rbacService)\n )\n\n if (config.debug) {\n console.error(\n `[MCP Server] Listing ${accessibleTools.length}/${tools.length} tools (filtered by ACL)`\n )\n }\n\n return {\n tools: accessibleTools.map((tool) => ({\n name: tool.name,\n description: tool.description,\n // Cast to any for Zod v4 compatibility with zod-to-json-schema\n inputSchema: zodToJsonSchema(tool.inputSchema as any) as Record<string, unknown>,\n })),\n }\n })\n\n // Call tool handler\n server.setRequestHandler(CallToolRequestSchema, async (request) => {\n const { name, arguments: args } = request.params\n\n if (config.debug) {\n console.error(`[MCP Server] Calling tool: ${name}`, JSON.stringify(args))\n }\n\n const result = await executeTool(name, args ?? {}, toolContext)\n\n if (!result.success) {\n return {\n content: [\n {\n type: 'text',\n text: JSON.stringify({ error: result.error, code: result.errorCode }),\n },\n ],\n isError: true,\n }\n }\n\n return {\n content: [\n {\n type: 'text',\n text: JSON.stringify(result.result, null, 2),\n },\n ],\n }\n })\n\n return server\n}\n\n/**\n * Run MCP server with stdio transport.\n * This keeps the process running until terminated.\n *\n * Supports two authentication modes:\n * 1. API key: Provide `apiKeySecret` option\n * 2. Manual context: Provide `context` with tenant/org/user\n */\nexport async function runMcpServer(options: McpServerOptions): Promise<void> {\n // Generate entity graph for Code Mode search tool\n try {\n const { extractEntityGraph, cacheEntityGraph } = await import('./entity-graph')\n const { getOrm } = await import('@open-mercato/shared/lib/db/mikro')\n const orm = await getOrm()\n const graph = await extractEntityGraph(orm)\n cacheEntityGraph(graph)\n console.error(`[MCP Server] Entity graph: ${graph.nodes.length} entities`)\n } catch (error) {\n console.error('[MCP Server] Entity graph skipped:', error instanceof Error ? error.message : error)\n }\n\n // Pre-cache raw OpenAPI spec for Code Mode search tool\n try {\n const { getRawOpenApiSpec } = await import('./api-endpoint-index')\n await getRawOpenApiSpec()\n console.error('[MCP Server] Raw OpenAPI spec cached for Code Mode')\n } catch (error) {\n console.error('[MCP Server] Raw OpenAPI spec caching skipped:', error instanceof Error ? error.message : error)\n }\n\n // Load tools from all modules before starting\n await loadAllModuleTools()\n\n // Index tools for hybrid search discovery (if search service available)\n try {\n const searchService = options.container.resolve('searchService') as SearchService\n await indexToolsForSearch(searchService)\n } catch (error) {\n // Search service might not be configured - discovery will use fallback\n console.error('[MCP Server] Search indexing skipped (search service not available)')\n }\n\n const server = await createMcpServer(options)\n const transport = new StdioServerTransport()\n\n const toolCount = getToolRegistry().listToolNames().length\n\n console.error(`[MCP Server] Starting ${options.config.name} v${options.config.version}`)\n\n if (options.apiKeySecret && options.apiKeySecret.trim().length > 0) {\n console.error(`[MCP Server] Authentication: API key`)\n } else if (options.context && options.context.userId) {\n console.error(`[MCP Server] Tenant: ${options.context.tenantId ?? '(none)'}`)\n console.error(`[MCP Server] Organization: ${options.context.organizationId ?? '(none)'}`)\n console.error(`[MCP Server] User: ${options.context.userId}`)\n } else {\n console.error(`[MCP Server] Authentication: none (unauthenticated superadmin opt-in)`)\n }\n\n console.error(`[MCP Server] Tools registered: ${toolCount}`)\n\n await server.connect(transport)\n\n console.error('[MCP Server] Connected and ready for requests')\n\n // Handle shutdown gracefully\n const shutdown = async () => {\n console.error('[MCP Server] Shutting down...')\n await server.close()\n process.exit(0)\n }\n\n process.on('SIGINT', shutdown)\n process.on('SIGTERM', shutdown)\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/openai-compatible-presets.ts"],
4
- "sourcesContent": ["/**\n * Curated registry of OpenAI-compatible LLM backends.\n *\n * Each preset is plain data \u2014 adding a new backend takes one entry in\n * this array, zero new adapter files, and zero changes to route handlers.\n * The {@link createOpenAICompatibleProvider} factory in `./llm-adapters/openai.ts`\n * turns each preset into a concrete `LlmProvider` at bootstrap time.\n *\n * Preset model catalogs are curated snapshots as of 2026-04-14 and should\n * be updated as upstream catalogs evolve. Users can always override the\n * selected model via the `OPENCODE_MODEL` env var without editing this\n * file.\n *\n * @see ./llm-adapters/openai.ts\n * @see .ai/specs/2026-04-14-llm-provider-ports-and-adapters.md\n */\n\nimport type { OpenAICompatiblePreset } from './llm-adapters/openai'\n\n/**\n * Standard OpenAI \u2014 default OpenAI API at api.openai.com.\n */\nconst OPENAI_PRESET: OpenAICompatiblePreset = {\n id: 'openai',\n name: 'OpenAI',\n baseURL: undefined,\n baseURLEnvKeys: ['OPENAI_BASE_URL'],\n envKeys: ['OPENAI_API_KEY', 'OPENCODE_OPENAI_API_KEY'],\n defaultModel: 'gpt-5-mini',\n defaultModels: [\n {\n id: 'gpt-5-mini',\n name: 'GPT-5 Mini',\n contextWindow: 128000,\n tags: ['budget'],\n },\n {\n id: 'gpt-5',\n name: 'GPT-5',\n contextWindow: 128000,\n tags: ['flagship'],\n },\n {\n id: 'gpt-4o-mini',\n name: 'GPT-4o Mini',\n contextWindow: 128000,\n tags: ['budget'],\n },\n {\n id: 'gpt-4o',\n name: 'GPT-4o',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * DeepInfra \u2014 hosts open-weight flagship models at 3-12\u00D7 lower cost than\n * the native APIs. The curated catalog targets the AI Assistant use case\n * (routing + tool use + conversational chat).\n */\nconst DEEPINFRA_PRESET: OpenAICompatiblePreset = {\n id: 'deepinfra',\n name: 'DeepInfra',\n baseURL: 'https://api.deepinfra.com/v1/openai',\n baseURLEnvKeys: ['DEEPINFRA_BASE_URL'],\n envKeys: ['DEEPINFRA_API_KEY'],\n defaultModel: 'zai-org/GLM-5.1',\n defaultModels: [\n {\n id: 'zai-org/GLM-5.1',\n name: 'GLM-5.1 (Zhipu)',\n contextWindow: 202752,\n tags: ['flagship'],\n },\n {\n id: 'zai-org/GLM-4.7-Flash',\n name: 'GLM-4.7 Flash',\n contextWindow: 202752,\n tags: ['budget'],\n },\n {\n id: 'Qwen/Qwen3-235B-A22B-Instruct-2507',\n name: 'Qwen3 235B (MoE)',\n contextWindow: 262144,\n tags: ['flagship'],\n },\n {\n id: 'meta-llama/Llama-4-Scout-17B-16E-Instruct',\n name: 'Llama 4 Scout',\n contextWindow: 327680,\n },\n {\n id: 'deepseek-ai/DeepSeek-V3.2-Exp',\n name: 'DeepSeek V3.2',\n contextWindow: 163840,\n tags: ['reasoning'],\n },\n {\n id: 'Qwen/Qwen3-Coder-30B-A3B-Instruct',\n name: 'Qwen3 Coder 30B',\n contextWindow: 262144,\n tags: ['coding'],\n },\n ],\n}\n\n/**\n * Groq \u2014 specializes in low-latency inference on LPU hardware.\n * Best suited for snappy tool-use and routing, less so for long reasoning.\n */\nconst GROQ_PRESET: OpenAICompatiblePreset = {\n id: 'groq',\n name: 'Groq',\n baseURL: 'https://api.groq.com/openai/v1',\n baseURLEnvKeys: ['GROQ_BASE_URL'],\n envKeys: ['GROQ_API_KEY'],\n defaultModel: 'llama-3.3-70b-versatile',\n defaultModels: [\n {\n id: 'llama-3.3-70b-versatile',\n name: 'Llama 3.3 70B Versatile',\n contextWindow: 131072,\n },\n {\n id: 'llama-4-scout-17b',\n name: 'Llama 4 Scout 17B',\n contextWindow: 131072,\n },\n {\n id: 'mixtral-8x22b-32768',\n name: 'Mixtral 8x22B',\n contextWindow: 32768,\n },\n ],\n}\n\n/**\n * Together AI \u2014 broad catalog of open-weight models with per-model pricing.\n */\nconst TOGETHER_PRESET: OpenAICompatiblePreset = {\n id: 'together',\n name: 'Together AI',\n baseURL: 'https://api.together.xyz/v1',\n baseURLEnvKeys: ['TOGETHER_BASE_URL'],\n envKeys: ['TOGETHER_API_KEY'],\n defaultModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',\n defaultModels: [\n {\n id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',\n name: 'Llama 3.3 70B Turbo',\n contextWindow: 131072,\n },\n {\n id: 'Qwen/Qwen2.5-72B-Instruct-Turbo',\n name: 'Qwen 2.5 72B Turbo',\n contextWindow: 32768,\n },\n ],\n}\n\n/**\n * Fireworks AI \u2014 fast inference with a curated catalog.\n */\nconst FIREWORKS_PRESET: OpenAICompatiblePreset = {\n id: 'fireworks',\n name: 'Fireworks AI',\n baseURL: 'https://api.fireworks.ai/inference/v1',\n baseURLEnvKeys: ['FIREWORKS_BASE_URL'],\n envKeys: ['FIREWORKS_API_KEY'],\n defaultModel: 'accounts/fireworks/models/llama-v3p3-70b-instruct',\n defaultModels: [\n {\n id: 'accounts/fireworks/models/llama-v3p3-70b-instruct',\n name: 'Llama 3.3 70B',\n contextWindow: 131072,\n },\n ],\n}\n\n/**\n * Azure OpenAI \u2014 enterprise Azure deployments. Base URL is deployment-\n * specific and must be provided via `AZURE_OPENAI_BASE_URL`.\n */\nconst AZURE_PRESET: OpenAICompatiblePreset = {\n id: 'azure',\n name: 'Azure OpenAI',\n baseURL: undefined,\n baseURLEnvKeys: ['AZURE_OPENAI_BASE_URL'],\n envKeys: ['AZURE_OPENAI_API_KEY'],\n defaultModel: 'gpt-5-mini',\n defaultModels: [\n {\n id: 'gpt-5-mini',\n name: 'GPT-5 Mini',\n contextWindow: 128000,\n },\n {\n id: 'gpt-5',\n name: 'GPT-5',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * LiteLLM proxy \u2014 self-hosted router for arbitrary upstream providers.\n * Base URL must be supplied via `LITELLM_BASE_URL`.\n */\nconst LITELLM_PRESET: OpenAICompatiblePreset = {\n id: 'litellm',\n name: 'LiteLLM',\n baseURL: 'http://localhost:4000/v1',\n baseURLEnvKeys: ['LITELLM_BASE_URL'],\n envKeys: ['LITELLM_API_KEY'],\n defaultModel: 'gpt-4o-mini',\n defaultModels: [\n {\n id: 'gpt-4o-mini',\n name: 'GPT-4o Mini (via LiteLLM)',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * Ollama \u2014 local model runner for development and offline use.\n * Default port 11434 can be overridden via `OLLAMA_BASE_URL`.\n */\nconst OLLAMA_PRESET: OpenAICompatiblePreset = {\n id: 'ollama',\n name: 'Ollama (local)',\n baseURL: 'http://localhost:11434/v1',\n baseURLEnvKeys: ['OLLAMA_BASE_URL'],\n envKeys: ['OLLAMA_API_KEY'],\n defaultModel: 'llama3.3',\n defaultModels: [\n {\n id: 'llama3.3',\n name: 'Llama 3.3 (local)',\n contextWindow: 131072,\n },\n {\n id: 'qwen2.5-coder',\n name: 'Qwen 2.5 Coder (local)',\n contextWindow: 131072,\n tags: ['coding'],\n },\n ],\n}\n\n/**\n * OpenRouter \u2014 unified API gateway providing access to hundreds of models\n * from Anthropic, OpenAI, Google, Meta, and others via a single OpenAI-\n * compatible endpoint.\n */\nconst OPENROUTER_PRESET: OpenAICompatiblePreset = {\n id: 'openrouter',\n name: 'OpenRouter',\n baseURL: 'https://openrouter.ai/api/v1',\n baseURLEnvKeys: ['OPENROUTER_BASE_URL'],\n envKeys: ['OPENROUTER_API_KEY'],\n defaultModel: 'meta-llama/llama-3.3-70b-instruct',\n defaultModels: [\n {\n id: 'meta-llama/llama-3.3-70b-instruct',\n name: 'Llama 3.3 70B Instruct',\n contextWindow: 131072,\n },\n ],\n}\n\n/**\n * LM Studio \u2014 local model server for development and offline use.\n * Default port 1234 can be overridden via `LM_STUDIO_BASE_URL`.\n * `defaultModel` is intentionally empty \u2014 LM Studio auto-detects\n * the loaded model when the request body's `model` field is empty.\n */\nconst LM_STUDIO_PRESET: OpenAICompatiblePreset = {\n id: 'lm-studio',\n name: 'LM Studio (local)',\n baseURL: 'http://localhost:1234/v1',\n baseURLEnvKeys: ['LM_STUDIO_BASE_URL'],\n envKeys: ['LM_STUDIO_API_KEY'],\n defaultModel: '',\n defaultModels: [],\n}\n\n/**\n * Built-in presets registered at bootstrap time. Order matters \u2014 it\n * determines the default iteration order of\n * `llmProviderRegistry.resolveFirstConfigured()` when no explicit order\n * is supplied.\n */\nexport const OPENAI_COMPATIBLE_PRESETS: readonly OpenAICompatiblePreset[] = [\n OPENAI_PRESET,\n DEEPINFRA_PRESET,\n GROQ_PRESET,\n TOGETHER_PRESET,\n FIREWORKS_PRESET,\n AZURE_PRESET,\n LITELLM_PRESET,\n OLLAMA_PRESET,\n OPENROUTER_PRESET,\n LM_STUDIO_PRESET,\n]\n"],
4
+ "sourcesContent": ["/**\n * Curated registry of OpenAI-compatible LLM backends.\n *\n * Each preset is plain data \u2014 adding a new backend takes one entry in\n * this array, zero new adapter files, and zero changes to route handlers.\n * The {@link createOpenAICompatibleProvider} factory in `./llm-adapters/openai.ts`\n * turns each preset into a concrete `LlmProvider` at bootstrap time.\n *\n * Preset model catalogs are curated snapshots as of 2026-04-14 and should\n * be updated as upstream catalogs evolve. Users can always override the\n * selected model via the `OPENCODE_MODEL` env var without editing this\n * file.\n *\n * @see ./llm-adapters/openai.ts\n * @see .ai/specs/implemented/2026-04-14-llm-provider-ports-and-adapters.md\n */\n\nimport type { OpenAICompatiblePreset } from './llm-adapters/openai'\n\n/**\n * Standard OpenAI \u2014 default OpenAI API at api.openai.com.\n */\nconst OPENAI_PRESET: OpenAICompatiblePreset = {\n id: 'openai',\n name: 'OpenAI',\n baseURL: undefined,\n baseURLEnvKeys: ['OPENAI_BASE_URL'],\n envKeys: ['OPENAI_API_KEY', 'OPENCODE_OPENAI_API_KEY'],\n defaultModel: 'gpt-5-mini',\n defaultModels: [\n {\n id: 'gpt-5-mini',\n name: 'GPT-5 Mini',\n contextWindow: 128000,\n tags: ['budget'],\n },\n {\n id: 'gpt-5',\n name: 'GPT-5',\n contextWindow: 128000,\n tags: ['flagship'],\n },\n {\n id: 'gpt-4o-mini',\n name: 'GPT-4o Mini',\n contextWindow: 128000,\n tags: ['budget'],\n },\n {\n id: 'gpt-4o',\n name: 'GPT-4o',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * DeepInfra \u2014 hosts open-weight flagship models at 3-12\u00D7 lower cost than\n * the native APIs. The curated catalog targets the AI Assistant use case\n * (routing + tool use + conversational chat).\n */\nconst DEEPINFRA_PRESET: OpenAICompatiblePreset = {\n id: 'deepinfra',\n name: 'DeepInfra',\n baseURL: 'https://api.deepinfra.com/v1/openai',\n baseURLEnvKeys: ['DEEPINFRA_BASE_URL'],\n envKeys: ['DEEPINFRA_API_KEY'],\n defaultModel: 'zai-org/GLM-5.1',\n defaultModels: [\n {\n id: 'zai-org/GLM-5.1',\n name: 'GLM-5.1 (Zhipu)',\n contextWindow: 202752,\n tags: ['flagship'],\n },\n {\n id: 'zai-org/GLM-4.7-Flash',\n name: 'GLM-4.7 Flash',\n contextWindow: 202752,\n tags: ['budget'],\n },\n {\n id: 'Qwen/Qwen3-235B-A22B-Instruct-2507',\n name: 'Qwen3 235B (MoE)',\n contextWindow: 262144,\n tags: ['flagship'],\n },\n {\n id: 'meta-llama/Llama-4-Scout-17B-16E-Instruct',\n name: 'Llama 4 Scout',\n contextWindow: 327680,\n },\n {\n id: 'deepseek-ai/DeepSeek-V3.2-Exp',\n name: 'DeepSeek V3.2',\n contextWindow: 163840,\n tags: ['reasoning'],\n },\n {\n id: 'Qwen/Qwen3-Coder-30B-A3B-Instruct',\n name: 'Qwen3 Coder 30B',\n contextWindow: 262144,\n tags: ['coding'],\n },\n ],\n}\n\n/**\n * Groq \u2014 specializes in low-latency inference on LPU hardware.\n * Best suited for snappy tool-use and routing, less so for long reasoning.\n */\nconst GROQ_PRESET: OpenAICompatiblePreset = {\n id: 'groq',\n name: 'Groq',\n baseURL: 'https://api.groq.com/openai/v1',\n baseURLEnvKeys: ['GROQ_BASE_URL'],\n envKeys: ['GROQ_API_KEY'],\n defaultModel: 'llama-3.3-70b-versatile',\n defaultModels: [\n {\n id: 'llama-3.3-70b-versatile',\n name: 'Llama 3.3 70B Versatile',\n contextWindow: 131072,\n },\n {\n id: 'llama-4-scout-17b',\n name: 'Llama 4 Scout 17B',\n contextWindow: 131072,\n },\n {\n id: 'mixtral-8x22b-32768',\n name: 'Mixtral 8x22B',\n contextWindow: 32768,\n },\n ],\n}\n\n/**\n * Together AI \u2014 broad catalog of open-weight models with per-model pricing.\n */\nconst TOGETHER_PRESET: OpenAICompatiblePreset = {\n id: 'together',\n name: 'Together AI',\n baseURL: 'https://api.together.xyz/v1',\n baseURLEnvKeys: ['TOGETHER_BASE_URL'],\n envKeys: ['TOGETHER_API_KEY'],\n defaultModel: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',\n defaultModels: [\n {\n id: 'meta-llama/Llama-3.3-70B-Instruct-Turbo',\n name: 'Llama 3.3 70B Turbo',\n contextWindow: 131072,\n },\n {\n id: 'Qwen/Qwen2.5-72B-Instruct-Turbo',\n name: 'Qwen 2.5 72B Turbo',\n contextWindow: 32768,\n },\n ],\n}\n\n/**\n * Fireworks AI \u2014 fast inference with a curated catalog.\n */\nconst FIREWORKS_PRESET: OpenAICompatiblePreset = {\n id: 'fireworks',\n name: 'Fireworks AI',\n baseURL: 'https://api.fireworks.ai/inference/v1',\n baseURLEnvKeys: ['FIREWORKS_BASE_URL'],\n envKeys: ['FIREWORKS_API_KEY'],\n defaultModel: 'accounts/fireworks/models/llama-v3p3-70b-instruct',\n defaultModels: [\n {\n id: 'accounts/fireworks/models/llama-v3p3-70b-instruct',\n name: 'Llama 3.3 70B',\n contextWindow: 131072,\n },\n ],\n}\n\n/**\n * Azure OpenAI \u2014 enterprise Azure deployments. Base URL is deployment-\n * specific and must be provided via `AZURE_OPENAI_BASE_URL`.\n */\nconst AZURE_PRESET: OpenAICompatiblePreset = {\n id: 'azure',\n name: 'Azure OpenAI',\n baseURL: undefined,\n baseURLEnvKeys: ['AZURE_OPENAI_BASE_URL'],\n envKeys: ['AZURE_OPENAI_API_KEY'],\n defaultModel: 'gpt-5-mini',\n defaultModels: [\n {\n id: 'gpt-5-mini',\n name: 'GPT-5 Mini',\n contextWindow: 128000,\n },\n {\n id: 'gpt-5',\n name: 'GPT-5',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * LiteLLM proxy \u2014 self-hosted router for arbitrary upstream providers.\n * Base URL must be supplied via `LITELLM_BASE_URL`.\n */\nconst LITELLM_PRESET: OpenAICompatiblePreset = {\n id: 'litellm',\n name: 'LiteLLM',\n baseURL: 'http://localhost:4000/v1',\n baseURLEnvKeys: ['LITELLM_BASE_URL'],\n envKeys: ['LITELLM_API_KEY'],\n defaultModel: 'gpt-4o-mini',\n defaultModels: [\n {\n id: 'gpt-4o-mini',\n name: 'GPT-4o Mini (via LiteLLM)',\n contextWindow: 128000,\n },\n ],\n}\n\n/**\n * Ollama \u2014 local model runner for development and offline use.\n * Default port 11434 can be overridden via `OLLAMA_BASE_URL`.\n */\nconst OLLAMA_PRESET: OpenAICompatiblePreset = {\n id: 'ollama',\n name: 'Ollama (local)',\n baseURL: 'http://localhost:11434/v1',\n baseURLEnvKeys: ['OLLAMA_BASE_URL'],\n envKeys: ['OLLAMA_API_KEY'],\n defaultModel: 'llama3.3',\n defaultModels: [\n {\n id: 'llama3.3',\n name: 'Llama 3.3 (local)',\n contextWindow: 131072,\n },\n {\n id: 'qwen2.5-coder',\n name: 'Qwen 2.5 Coder (local)',\n contextWindow: 131072,\n tags: ['coding'],\n },\n ],\n}\n\n/**\n * OpenRouter \u2014 unified API gateway providing access to hundreds of models\n * from Anthropic, OpenAI, Google, Meta, and others via a single OpenAI-\n * compatible endpoint.\n */\nconst OPENROUTER_PRESET: OpenAICompatiblePreset = {\n id: 'openrouter',\n name: 'OpenRouter',\n baseURL: 'https://openrouter.ai/api/v1',\n baseURLEnvKeys: ['OPENROUTER_BASE_URL'],\n envKeys: ['OPENROUTER_API_KEY'],\n defaultModel: 'meta-llama/llama-3.3-70b-instruct',\n defaultModels: [\n {\n id: 'meta-llama/llama-3.3-70b-instruct',\n name: 'Llama 3.3 70B Instruct',\n contextWindow: 131072,\n },\n ],\n}\n\n/**\n * LM Studio \u2014 local model server for development and offline use.\n * Default port 1234 can be overridden via `LM_STUDIO_BASE_URL`.\n * `defaultModel` is intentionally empty \u2014 LM Studio auto-detects\n * the loaded model when the request body's `model` field is empty.\n */\nconst LM_STUDIO_PRESET: OpenAICompatiblePreset = {\n id: 'lm-studio',\n name: 'LM Studio (local)',\n baseURL: 'http://localhost:1234/v1',\n baseURLEnvKeys: ['LM_STUDIO_BASE_URL'],\n envKeys: ['LM_STUDIO_API_KEY'],\n defaultModel: '',\n defaultModels: [],\n}\n\n/**\n * Built-in presets registered at bootstrap time. Order matters \u2014 it\n * determines the default iteration order of\n * `llmProviderRegistry.resolveFirstConfigured()` when no explicit order\n * is supplied.\n */\nexport const OPENAI_COMPATIBLE_PRESETS: readonly OpenAICompatiblePreset[] = [\n OPENAI_PRESET,\n DEEPINFRA_PRESET,\n GROQ_PRESET,\n TOGETHER_PRESET,\n FIREWORKS_PRESET,\n AZURE_PRESET,\n LITELLM_PRESET,\n OLLAMA_PRESET,\n OPENROUTER_PRESET,\n LM_STUDIO_PRESET,\n]\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/task-plan-stream.ts"],
4
- "sourcesContent": ["/**\n * Visible AI chat agent task plan \u2014 server-side SSE injector.\n *\n * Spec: `.ai/specs/2026-05-13-ai-chat-visible-task-plan.md`.\n *\n * Wraps a streaming `Response` produced by `streamText().toUIMessageStreamResponse()`\n * (or the equivalent `ToolLoopAgent.stream(...).toUIMessageStreamResponse()`)\n * and interleaves additive `data-agent-task-plan` / `data-agent-task-update`\n * SSE chunks alongside the AI SDK tool lifecycle chunks. The original chunks\n * are passed through unchanged so existing clients that ignore unknown chunk\n * types continue to work.\n *\n * The injector derives task labels and states from the SDK tool lifecycle:\n * - `tool-input-start` \u2192 create/update task with state `running`\n * - `tool-input-available` \u2192 keep task `running` (label may be refined)\n * - `tool-output-available` \u2192 mark task `done`\n * - `tool-output-error` \u2192 mark task `failed`\n * - `tool-input-error` \u2192 mark task `failed`\n *\n * Agent-authored labels flow through the reserved non-mutation\n * `meta.update_task_plan` tool. Its input is sanitized before the plan reaches\n * the client; the raw meta-tool call is still passed through for older clients\n * but the visible plan uses only the safe labels.\n */\n\nimport {\n TASK_PLAN_LABEL_MAX_CHARS,\n isTaskPlanToolName,\n normalizeTaskPlanToolName,\n sanitizeAgentTaskPlanInput,\n} from './task-plan-labels'\n\nconst SSE_ENCODER = new TextEncoder()\nconst SSE_DECODER = new TextDecoder()\n\n/**\n * Mirrors the client-side `AiAgentTaskSnapshot` so server and client agree on\n * the wire format. Kept locally in this module to avoid pulling a UI package\n * dependency into the runtime \u2014 the shape is small and only ever serialized\n * to JSON for the SSE chunks below.\n */\nexport interface ServerTaskSnapshot {\n id: string\n label: string\n state: 'pending' | 'running' | 'done' | 'failed' | 'skipped'\n detail?: string\n source: 'runtime' | 'agent'\n toolCallId?: string\n}\n\nconst TERMINAL_STATES: ReadonlySet<ServerTaskSnapshot['state']> = new Set([\n 'done',\n 'failed',\n 'skipped',\n])\n\nconst TASK_LABEL_MAX_CHARS = TASK_PLAN_LABEL_MAX_CHARS\n\n/**\n * Convert a raw model-sanitized tool name (e.g. `customers__list_people`) to a\n * compact operator-facing label (e.g. `Customers list people`). The trailing\n * segment is title-cased so the plan reads like a checklist instead of a code\n * trace.\n */\nexport function deriveTaskLabel(toolName: string | undefined): string {\n if (typeof toolName !== 'string' || toolName.length === 0) {\n return 'Tool call'\n }\n const display = toolName.replace(/__/g, '.')\n const segments = display.split('.')\n const lastSegment = segments[segments.length - 1] ?? display\n const humanized = lastSegment.replace(/_/g, ' ').trim()\n if (humanized.length === 0) return display.slice(0, TASK_LABEL_MAX_CHARS)\n const titled = humanized.charAt(0).toUpperCase() + humanized.slice(1)\n if (segments.length <= 1) {\n return titled.slice(0, TASK_LABEL_MAX_CHARS)\n }\n const moduleSegment = segments[0]\n const moduleLabel = moduleSegment.charAt(0).toUpperCase() + moduleSegment.slice(1).replace(/_/g, ' ')\n const combined = `${moduleLabel} \u00B7 ${titled}`\n return combined.slice(0, TASK_LABEL_MAX_CHARS)\n}\n\ntype AccumulatorEntry = {\n snapshot: ServerTaskSnapshot\n emitted: boolean\n}\n\ntype ToolChunk = {\n type?: unknown\n toolCallId?: unknown\n toolName?: unknown\n input?: unknown\n}\n\n/**\n * Encapsulates the per-turn task-plan state. Exposed for unit tests so the\n * derivation logic can be exercised without standing up a full SSE pipeline.\n */\nexport class TaskPlanAccumulator {\n private readonly tasks = new Map<string, AccumulatorEntry>()\n private readonly toolCallToTaskId = new Map<string, string>()\n private readonly taskToolNames = new Map<string, string>()\n private readonly internalToolCallIds = new Set<string>()\n private snapshotEmitted = false\n private hasAgentAuthoredPlan = false\n\n constructor(public readonly planId: string) {}\n\n private upsert(\n id: string,\n patch: Partial<ServerTaskSnapshot> & { label?: string; toolCallId?: string },\n ): ServerTaskSnapshot {\n const existing = this.tasks.get(id)\n if (!existing) {\n const created: ServerTaskSnapshot = {\n id,\n label: patch.label ?? 'Tool call',\n state: patch.state ?? 'running',\n source: patch.source ?? 'runtime',\n detail: patch.detail,\n toolCallId: patch.toolCallId,\n }\n this.tasks.set(id, { snapshot: created, emitted: false })\n return created\n }\n const current = existing.snapshot\n const nextState = TERMINAL_STATES.has(current.state) ? current.state : patch.state ?? current.state\n const merged: ServerTaskSnapshot = {\n id: current.id,\n label: patch.label ?? current.label,\n state: nextState,\n source: patch.source ?? current.source,\n detail: patch.detail ?? current.detail,\n toolCallId: patch.toolCallId ?? current.toolCallId,\n }\n this.tasks.set(id, { snapshot: merged, emitted: existing.emitted })\n return merged\n }\n\n private makeUniqueTaskId(baseId: string): string {\n let candidate = baseId\n let suffix = 2\n while (this.tasks.has(candidate)) {\n candidate = `${baseId}-${suffix}`\n suffix += 1\n }\n return candidate\n }\n\n private emitFullSnapshot(): string[] {\n if (this.tasks.size === 0) return []\n this.snapshotEmitted = true\n const initialTasks = Array.from(this.tasks.values()).map((e) => e.snapshot)\n for (const e of this.tasks.values()) e.emitted = true\n return [\n formatSseEvent({\n type: 'data-agent-task-plan',\n planId: this.planId,\n tasks: initialTasks,\n }),\n ]\n }\n\n private handleAgentAuthoredPlan(input: unknown): string[] {\n const plan = sanitizeAgentTaskPlanInput(input)\n if (plan.tasks.length === 0) return []\n\n this.tasks.clear()\n this.toolCallToTaskId.clear()\n this.taskToolNames.clear()\n this.snapshotEmitted = false\n this.hasAgentAuthoredPlan = true\n\n plan.tasks.forEach((task, index) => {\n const id = this.makeUniqueTaskId(task.id ?? `agent-plan-${index + 1}`)\n const snapshot: ServerTaskSnapshot = {\n id,\n label: task.label,\n state: 'pending',\n source: 'agent',\n detail: task.detail,\n }\n this.tasks.set(id, { snapshot, emitted: false })\n if (task.toolName) {\n this.taskToolNames.set(id, task.toolName)\n }\n })\n\n return this.emitFullSnapshot()\n }\n\n private resolveTaskIdForToolCall(toolCallId: string, toolName: string | undefined): string {\n const existing = this.toolCallToTaskId.get(toolCallId)\n if (existing) return existing\n const plannedTaskId = this.findPlannedTaskId(toolName)\n if (plannedTaskId) {\n this.toolCallToTaskId.set(toolCallId, plannedTaskId)\n return plannedTaskId\n }\n this.toolCallToTaskId.set(toolCallId, toolCallId)\n return toolCallId\n }\n\n private findPlannedTaskId(toolName: string | undefined): string | null {\n if (!this.hasAgentAuthoredPlan) return null\n const entries = Array.from(this.tasks.entries())\n const isAvailable = (entry: AccumulatorEntry) => !TERMINAL_STATES.has(entry.snapshot.state)\n if (toolName) {\n const exactPending = entries.find(([id, entry]) => {\n return entry.snapshot.state === 'pending' && this.taskToolNames.get(id) === toolName\n })\n if (exactPending) return exactPending[0]\n const exactAvailable = entries.find(([id, entry]) => {\n return isAvailable(entry) && this.taskToolNames.get(id) === toolName\n })\n if (exactAvailable) return exactAvailable[0]\n }\n const genericPending = entries.find(([id, entry]) => {\n return entry.snapshot.state === 'pending' && !this.taskToolNames.has(id)\n })\n if (genericPending) return genericPending[0]\n const genericAvailable = entries.find(([id, entry]) => {\n return isAvailable(entry) && !this.taskToolNames.has(id)\n })\n return genericAvailable?.[0] ?? null\n }\n\n private existingSnapshot(taskId: string): ServerTaskSnapshot | undefined {\n return this.tasks.get(taskId)?.snapshot\n }\n\n /**\n * Apply a tool lifecycle chunk. Returns the SSE event lines (already\n * `data: ...\\n\\n`-formatted) that should be injected ahead of forwarding\n * the original chunk to the client.\n */\n handleToolChunk(chunk: ToolChunk): string[] {\n if (!chunk || typeof chunk.type !== 'string') return []\n const toolCallId = typeof chunk.toolCallId === 'string' ? chunk.toolCallId : null\n const toolName = normalizeTaskPlanToolName(chunk.toolName)\n if (isTaskPlanToolName(toolName)) {\n if (toolCallId) this.internalToolCallIds.add(toolCallId)\n if (chunk.type === 'tool-input-available') {\n return this.handleAgentAuthoredPlan(chunk.input)\n }\n return []\n }\n if (!toolCallId) return []\n if (this.internalToolCallIds.has(toolCallId)) return []\n const taskId = this.resolveTaskIdForToolCall(toolCallId, toolName)\n const existing = this.existingSnapshot(taskId)\n const source = existing?.source ?? 'runtime'\n const runtimeLabel = deriveTaskLabel(toolName)\n const runtimeDetail = toolName\n let nextSnapshot: ServerTaskSnapshot | null = null\n switch (chunk.type) {\n case 'tool-input-start':\n nextSnapshot = this.upsert(taskId, {\n label: source === 'agent' ? existing?.label : runtimeLabel,\n state: 'running',\n source,\n toolCallId,\n detail: source === 'agent' ? existing?.detail : runtimeDetail,\n })\n break\n case 'tool-input-available':\n // Runtime-derived tasks can refine the label when the SDK includes a\n // richer toolName on input-available. Agent-authored tasks keep the\n // safe label the model supplied through `meta.update_task_plan`.\n nextSnapshot = this.upsert(taskId, {\n label: source === 'agent' ? existing?.label : runtimeLabel,\n state: 'running',\n source,\n toolCallId,\n detail: source === 'agent' ? existing?.detail : runtimeDetail,\n })\n break\n case 'tool-output-available':\n nextSnapshot = this.upsert(taskId, {\n state: 'done',\n source,\n toolCallId,\n })\n break\n case 'tool-output-error':\n case 'tool-input-error':\n nextSnapshot = this.upsert(taskId, {\n state: 'failed',\n source,\n toolCallId,\n })\n break\n default:\n return []\n }\n if (!nextSnapshot) return []\n return this.emitForSnapshot(taskId, nextSnapshot)\n }\n\n private emitForSnapshot(id: string, snapshot: ServerTaskSnapshot): string[] {\n const entry = this.tasks.get(id)\n if (!entry) return []\n const lines: string[] = []\n if (!this.snapshotEmitted) {\n return this.emitFullSnapshot()\n }\n if (!entry.emitted) {\n // First time we surface this task: it must be part of the next snapshot\n // refresh, but to keep the protocol minimal we emit a single\n // `data-agent-task-update` carrying the new task \u2014 clients merge by id.\n lines.push(\n formatSseEvent({\n type: 'data-agent-task-update',\n planId: this.planId,\n task: snapshot,\n }),\n )\n entry.emitted = true\n return lines\n }\n lines.push(\n formatSseEvent({\n type: 'data-agent-task-update',\n planId: this.planId,\n task: snapshot,\n }),\n )\n return lines\n }\n}\n\nexport function formatSseEvent(payload: Record<string, unknown>): string {\n return `data: ${JSON.stringify(payload)}\\n\\n`\n}\n\n/**\n * Wrap a streaming `Response` and interleave `data-agent-task-plan` /\n * `data-agent-task-update` SSE chunks. The wrapper does not consume the\n * stream \u2014 it pipes bytes through and only parses event boundaries to know\n * when to inject extra chunks.\n */\nexport function injectTaskPlanIntoStream(\n baseResponse: Response,\n planId: string,\n): Response {\n const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()\n const writer = writable.getWriter()\n const accumulator = new TaskPlanAccumulator(planId)\n\n async function pump(): Promise<void> {\n if (!baseResponse.body) {\n await writer.close()\n return\n }\n const reader = baseResponse.body.getReader()\n let textBuffer = ''\n try {\n for (;;) {\n const { value, done } = await reader.read()\n if (done) break\n if (!value) continue\n textBuffer += SSE_DECODER.decode(value, { stream: true })\n textBuffer = await flushBuffer(textBuffer, accumulator, writer)\n }\n const tail = SSE_DECODER.decode()\n if (tail) {\n textBuffer += tail\n }\n if (textBuffer.length > 0) {\n // Best-effort flush of any trailing bytes (the AI SDK always\n // terminates events with `\\n\\n` so this path is rare).\n await writer.write(SSE_ENCODER.encode(textBuffer))\n }\n } catch {\n // Surface upstream aborts to the downstream consumer by closing the\n // writer \u2014 propagating the error would corrupt the SSE stream.\n } finally {\n reader.releaseLock()\n await writer.close().catch(() => undefined)\n }\n }\n\n void pump()\n return new Response(readable, {\n status: baseResponse.status,\n headers: baseResponse.headers,\n })\n}\n\nasync function flushBuffer(\n buffer: string,\n accumulator: TaskPlanAccumulator,\n writer: WritableStreamDefaultWriter<Uint8Array>,\n): Promise<string> {\n let rest = buffer\n for (;;) {\n const boundary = rest.indexOf('\\n\\n')\n if (boundary === -1) break\n const eventBlock = rest.slice(0, boundary + 2)\n rest = rest.slice(boundary + 2)\n const injected = inspectEventBlock(eventBlock, accumulator)\n for (const line of injected.before) {\n await writer.write(SSE_ENCODER.encode(line))\n }\n await writer.write(SSE_ENCODER.encode(eventBlock))\n for (const line of injected.after) {\n await writer.write(SSE_ENCODER.encode(line))\n }\n }\n return rest\n}\n\ninterface InjectedLines {\n before: string[]\n after: string[]\n}\n\nfunction inspectEventBlock(\n eventBlock: string,\n accumulator: TaskPlanAccumulator,\n): InjectedLines {\n const dataPayload = extractDataPayload(eventBlock)\n if (!dataPayload || dataPayload === '[DONE]') {\n return { before: [], after: [] }\n }\n let parsed: ToolChunk | null = null\n try {\n parsed = JSON.parse(dataPayload)\n } catch {\n return { before: [], after: [] }\n }\n if (!parsed || typeof parsed.type !== 'string') {\n return { before: [], after: [] }\n }\n const type = parsed.type\n const injected = accumulator.handleToolChunk(parsed)\n if (injected.length === 0) {\n return { before: [], after: [] }\n }\n // Tool-input-start gets the plan event BEFORE the original (so the row\n // appears at the same time as the tool starts). Output / error events\n // get the plan event AFTER so the row updates only once the tool result\n // is visible in the existing tool-call detail row.\n if (type === 'tool-input-start' || type === 'tool-input-available') {\n return { before: injected, after: [] }\n }\n return { before: [], after: injected }\n}\n\nfunction extractDataPayload(eventBlock: string): string | null {\n const lines = eventBlock.split('\\n')\n const dataLines: string[] = []\n for (const line of lines) {\n if (line.startsWith('data: ')) {\n dataLines.push(line.slice(6))\n } else if (line.startsWith('data:')) {\n dataLines.push(line.slice(5))\n }\n }\n if (dataLines.length === 0) return null\n return dataLines.join('\\n')\n}\n"],
4
+ "sourcesContent": ["/**\n * Visible AI chat agent task plan \u2014 server-side SSE injector.\n *\n * Spec: `.ai/specs/implemented/2026-05-13-ai-chat-visible-task-plan.md`.\n *\n * Wraps a streaming `Response` produced by `streamText().toUIMessageStreamResponse()`\n * (or the equivalent `ToolLoopAgent.stream(...).toUIMessageStreamResponse()`)\n * and interleaves additive `data-agent-task-plan` / `data-agent-task-update`\n * SSE chunks alongside the AI SDK tool lifecycle chunks. The original chunks\n * are passed through unchanged so existing clients that ignore unknown chunk\n * types continue to work.\n *\n * The injector derives task labels and states from the SDK tool lifecycle:\n * - `tool-input-start` \u2192 create/update task with state `running`\n * - `tool-input-available` \u2192 keep task `running` (label may be refined)\n * - `tool-output-available` \u2192 mark task `done`\n * - `tool-output-error` \u2192 mark task `failed`\n * - `tool-input-error` \u2192 mark task `failed`\n *\n * Agent-authored labels flow through the reserved non-mutation\n * `meta.update_task_plan` tool. Its input is sanitized before the plan reaches\n * the client; the raw meta-tool call is still passed through for older clients\n * but the visible plan uses only the safe labels.\n */\n\nimport {\n TASK_PLAN_LABEL_MAX_CHARS,\n isTaskPlanToolName,\n normalizeTaskPlanToolName,\n sanitizeAgentTaskPlanInput,\n} from './task-plan-labels'\n\nconst SSE_ENCODER = new TextEncoder()\nconst SSE_DECODER = new TextDecoder()\n\n/**\n * Mirrors the client-side `AiAgentTaskSnapshot` so server and client agree on\n * the wire format. Kept locally in this module to avoid pulling a UI package\n * dependency into the runtime \u2014 the shape is small and only ever serialized\n * to JSON for the SSE chunks below.\n */\nexport interface ServerTaskSnapshot {\n id: string\n label: string\n state: 'pending' | 'running' | 'done' | 'failed' | 'skipped'\n detail?: string\n source: 'runtime' | 'agent'\n toolCallId?: string\n}\n\nconst TERMINAL_STATES: ReadonlySet<ServerTaskSnapshot['state']> = new Set([\n 'done',\n 'failed',\n 'skipped',\n])\n\nconst TASK_LABEL_MAX_CHARS = TASK_PLAN_LABEL_MAX_CHARS\n\n/**\n * Convert a raw model-sanitized tool name (e.g. `customers__list_people`) to a\n * compact operator-facing label (e.g. `Customers list people`). The trailing\n * segment is title-cased so the plan reads like a checklist instead of a code\n * trace.\n */\nexport function deriveTaskLabel(toolName: string | undefined): string {\n if (typeof toolName !== 'string' || toolName.length === 0) {\n return 'Tool call'\n }\n const display = toolName.replace(/__/g, '.')\n const segments = display.split('.')\n const lastSegment = segments[segments.length - 1] ?? display\n const humanized = lastSegment.replace(/_/g, ' ').trim()\n if (humanized.length === 0) return display.slice(0, TASK_LABEL_MAX_CHARS)\n const titled = humanized.charAt(0).toUpperCase() + humanized.slice(1)\n if (segments.length <= 1) {\n return titled.slice(0, TASK_LABEL_MAX_CHARS)\n }\n const moduleSegment = segments[0]\n const moduleLabel = moduleSegment.charAt(0).toUpperCase() + moduleSegment.slice(1).replace(/_/g, ' ')\n const combined = `${moduleLabel} \u00B7 ${titled}`\n return combined.slice(0, TASK_LABEL_MAX_CHARS)\n}\n\ntype AccumulatorEntry = {\n snapshot: ServerTaskSnapshot\n emitted: boolean\n}\n\ntype ToolChunk = {\n type?: unknown\n toolCallId?: unknown\n toolName?: unknown\n input?: unknown\n}\n\n/**\n * Encapsulates the per-turn task-plan state. Exposed for unit tests so the\n * derivation logic can be exercised without standing up a full SSE pipeline.\n */\nexport class TaskPlanAccumulator {\n private readonly tasks = new Map<string, AccumulatorEntry>()\n private readonly toolCallToTaskId = new Map<string, string>()\n private readonly taskToolNames = new Map<string, string>()\n private readonly internalToolCallIds = new Set<string>()\n private snapshotEmitted = false\n private hasAgentAuthoredPlan = false\n\n constructor(public readonly planId: string) {}\n\n private upsert(\n id: string,\n patch: Partial<ServerTaskSnapshot> & { label?: string; toolCallId?: string },\n ): ServerTaskSnapshot {\n const existing = this.tasks.get(id)\n if (!existing) {\n const created: ServerTaskSnapshot = {\n id,\n label: patch.label ?? 'Tool call',\n state: patch.state ?? 'running',\n source: patch.source ?? 'runtime',\n detail: patch.detail,\n toolCallId: patch.toolCallId,\n }\n this.tasks.set(id, { snapshot: created, emitted: false })\n return created\n }\n const current = existing.snapshot\n const nextState = TERMINAL_STATES.has(current.state) ? current.state : patch.state ?? current.state\n const merged: ServerTaskSnapshot = {\n id: current.id,\n label: patch.label ?? current.label,\n state: nextState,\n source: patch.source ?? current.source,\n detail: patch.detail ?? current.detail,\n toolCallId: patch.toolCallId ?? current.toolCallId,\n }\n this.tasks.set(id, { snapshot: merged, emitted: existing.emitted })\n return merged\n }\n\n private makeUniqueTaskId(baseId: string): string {\n let candidate = baseId\n let suffix = 2\n while (this.tasks.has(candidate)) {\n candidate = `${baseId}-${suffix}`\n suffix += 1\n }\n return candidate\n }\n\n private emitFullSnapshot(): string[] {\n if (this.tasks.size === 0) return []\n this.snapshotEmitted = true\n const initialTasks = Array.from(this.tasks.values()).map((e) => e.snapshot)\n for (const e of this.tasks.values()) e.emitted = true\n return [\n formatSseEvent({\n type: 'data-agent-task-plan',\n planId: this.planId,\n tasks: initialTasks,\n }),\n ]\n }\n\n private handleAgentAuthoredPlan(input: unknown): string[] {\n const plan = sanitizeAgentTaskPlanInput(input)\n if (plan.tasks.length === 0) return []\n\n this.tasks.clear()\n this.toolCallToTaskId.clear()\n this.taskToolNames.clear()\n this.snapshotEmitted = false\n this.hasAgentAuthoredPlan = true\n\n plan.tasks.forEach((task, index) => {\n const id = this.makeUniqueTaskId(task.id ?? `agent-plan-${index + 1}`)\n const snapshot: ServerTaskSnapshot = {\n id,\n label: task.label,\n state: 'pending',\n source: 'agent',\n detail: task.detail,\n }\n this.tasks.set(id, { snapshot, emitted: false })\n if (task.toolName) {\n this.taskToolNames.set(id, task.toolName)\n }\n })\n\n return this.emitFullSnapshot()\n }\n\n private resolveTaskIdForToolCall(toolCallId: string, toolName: string | undefined): string {\n const existing = this.toolCallToTaskId.get(toolCallId)\n if (existing) return existing\n const plannedTaskId = this.findPlannedTaskId(toolName)\n if (plannedTaskId) {\n this.toolCallToTaskId.set(toolCallId, plannedTaskId)\n return plannedTaskId\n }\n this.toolCallToTaskId.set(toolCallId, toolCallId)\n return toolCallId\n }\n\n private findPlannedTaskId(toolName: string | undefined): string | null {\n if (!this.hasAgentAuthoredPlan) return null\n const entries = Array.from(this.tasks.entries())\n const isAvailable = (entry: AccumulatorEntry) => !TERMINAL_STATES.has(entry.snapshot.state)\n if (toolName) {\n const exactPending = entries.find(([id, entry]) => {\n return entry.snapshot.state === 'pending' && this.taskToolNames.get(id) === toolName\n })\n if (exactPending) return exactPending[0]\n const exactAvailable = entries.find(([id, entry]) => {\n return isAvailable(entry) && this.taskToolNames.get(id) === toolName\n })\n if (exactAvailable) return exactAvailable[0]\n }\n const genericPending = entries.find(([id, entry]) => {\n return entry.snapshot.state === 'pending' && !this.taskToolNames.has(id)\n })\n if (genericPending) return genericPending[0]\n const genericAvailable = entries.find(([id, entry]) => {\n return isAvailable(entry) && !this.taskToolNames.has(id)\n })\n return genericAvailable?.[0] ?? null\n }\n\n private existingSnapshot(taskId: string): ServerTaskSnapshot | undefined {\n return this.tasks.get(taskId)?.snapshot\n }\n\n /**\n * Apply a tool lifecycle chunk. Returns the SSE event lines (already\n * `data: ...\\n\\n`-formatted) that should be injected ahead of forwarding\n * the original chunk to the client.\n */\n handleToolChunk(chunk: ToolChunk): string[] {\n if (!chunk || typeof chunk.type !== 'string') return []\n const toolCallId = typeof chunk.toolCallId === 'string' ? chunk.toolCallId : null\n const toolName = normalizeTaskPlanToolName(chunk.toolName)\n if (isTaskPlanToolName(toolName)) {\n if (toolCallId) this.internalToolCallIds.add(toolCallId)\n if (chunk.type === 'tool-input-available') {\n return this.handleAgentAuthoredPlan(chunk.input)\n }\n return []\n }\n if (!toolCallId) return []\n if (this.internalToolCallIds.has(toolCallId)) return []\n const taskId = this.resolveTaskIdForToolCall(toolCallId, toolName)\n const existing = this.existingSnapshot(taskId)\n const source = existing?.source ?? 'runtime'\n const runtimeLabel = deriveTaskLabel(toolName)\n const runtimeDetail = toolName\n let nextSnapshot: ServerTaskSnapshot | null = null\n switch (chunk.type) {\n case 'tool-input-start':\n nextSnapshot = this.upsert(taskId, {\n label: source === 'agent' ? existing?.label : runtimeLabel,\n state: 'running',\n source,\n toolCallId,\n detail: source === 'agent' ? existing?.detail : runtimeDetail,\n })\n break\n case 'tool-input-available':\n // Runtime-derived tasks can refine the label when the SDK includes a\n // richer toolName on input-available. Agent-authored tasks keep the\n // safe label the model supplied through `meta.update_task_plan`.\n nextSnapshot = this.upsert(taskId, {\n label: source === 'agent' ? existing?.label : runtimeLabel,\n state: 'running',\n source,\n toolCallId,\n detail: source === 'agent' ? existing?.detail : runtimeDetail,\n })\n break\n case 'tool-output-available':\n nextSnapshot = this.upsert(taskId, {\n state: 'done',\n source,\n toolCallId,\n })\n break\n case 'tool-output-error':\n case 'tool-input-error':\n nextSnapshot = this.upsert(taskId, {\n state: 'failed',\n source,\n toolCallId,\n })\n break\n default:\n return []\n }\n if (!nextSnapshot) return []\n return this.emitForSnapshot(taskId, nextSnapshot)\n }\n\n private emitForSnapshot(id: string, snapshot: ServerTaskSnapshot): string[] {\n const entry = this.tasks.get(id)\n if (!entry) return []\n const lines: string[] = []\n if (!this.snapshotEmitted) {\n return this.emitFullSnapshot()\n }\n if (!entry.emitted) {\n // First time we surface this task: it must be part of the next snapshot\n // refresh, but to keep the protocol minimal we emit a single\n // `data-agent-task-update` carrying the new task \u2014 clients merge by id.\n lines.push(\n formatSseEvent({\n type: 'data-agent-task-update',\n planId: this.planId,\n task: snapshot,\n }),\n )\n entry.emitted = true\n return lines\n }\n lines.push(\n formatSseEvent({\n type: 'data-agent-task-update',\n planId: this.planId,\n task: snapshot,\n }),\n )\n return lines\n }\n}\n\nexport function formatSseEvent(payload: Record<string, unknown>): string {\n return `data: ${JSON.stringify(payload)}\\n\\n`\n}\n\n/**\n * Wrap a streaming `Response` and interleave `data-agent-task-plan` /\n * `data-agent-task-update` SSE chunks. The wrapper does not consume the\n * stream \u2014 it pipes bytes through and only parses event boundaries to know\n * when to inject extra chunks.\n */\nexport function injectTaskPlanIntoStream(\n baseResponse: Response,\n planId: string,\n): Response {\n const { readable, writable } = new TransformStream<Uint8Array, Uint8Array>()\n const writer = writable.getWriter()\n const accumulator = new TaskPlanAccumulator(planId)\n\n async function pump(): Promise<void> {\n if (!baseResponse.body) {\n await writer.close()\n return\n }\n const reader = baseResponse.body.getReader()\n let textBuffer = ''\n try {\n for (;;) {\n const { value, done } = await reader.read()\n if (done) break\n if (!value) continue\n textBuffer += SSE_DECODER.decode(value, { stream: true })\n textBuffer = await flushBuffer(textBuffer, accumulator, writer)\n }\n const tail = SSE_DECODER.decode()\n if (tail) {\n textBuffer += tail\n }\n if (textBuffer.length > 0) {\n // Best-effort flush of any trailing bytes (the AI SDK always\n // terminates events with `\\n\\n` so this path is rare).\n await writer.write(SSE_ENCODER.encode(textBuffer))\n }\n } catch {\n // Surface upstream aborts to the downstream consumer by closing the\n // writer \u2014 propagating the error would corrupt the SSE stream.\n } finally {\n reader.releaseLock()\n await writer.close().catch(() => undefined)\n }\n }\n\n void pump()\n return new Response(readable, {\n status: baseResponse.status,\n headers: baseResponse.headers,\n })\n}\n\nasync function flushBuffer(\n buffer: string,\n accumulator: TaskPlanAccumulator,\n writer: WritableStreamDefaultWriter<Uint8Array>,\n): Promise<string> {\n let rest = buffer\n for (;;) {\n const boundary = rest.indexOf('\\n\\n')\n if (boundary === -1) break\n const eventBlock = rest.slice(0, boundary + 2)\n rest = rest.slice(boundary + 2)\n const injected = inspectEventBlock(eventBlock, accumulator)\n for (const line of injected.before) {\n await writer.write(SSE_ENCODER.encode(line))\n }\n await writer.write(SSE_ENCODER.encode(eventBlock))\n for (const line of injected.after) {\n await writer.write(SSE_ENCODER.encode(line))\n }\n }\n return rest\n}\n\ninterface InjectedLines {\n before: string[]\n after: string[]\n}\n\nfunction inspectEventBlock(\n eventBlock: string,\n accumulator: TaskPlanAccumulator,\n): InjectedLines {\n const dataPayload = extractDataPayload(eventBlock)\n if (!dataPayload || dataPayload === '[DONE]') {\n return { before: [], after: [] }\n }\n let parsed: ToolChunk | null = null\n try {\n parsed = JSON.parse(dataPayload)\n } catch {\n return { before: [], after: [] }\n }\n if (!parsed || typeof parsed.type !== 'string') {\n return { before: [], after: [] }\n }\n const type = parsed.type\n const injected = accumulator.handleToolChunk(parsed)\n if (injected.length === 0) {\n return { before: [], after: [] }\n }\n // Tool-input-start gets the plan event BEFORE the original (so the row\n // appears at the same time as the tool starts). Output / error events\n // get the plan event AFTER so the row updates only once the tool result\n // is visible in the existing tool-call detail row.\n if (type === 'tool-input-start' || type === 'tool-input-available') {\n return { before: injected, after: [] }\n }\n return { before: [], after: injected }\n}\n\nfunction extractDataPayload(eventBlock: string): string | null {\n const lines = eventBlock.split('\\n')\n const dataLines: string[] = []\n for (const line of lines) {\n if (line.startsWith('data: ')) {\n dataLines.push(line.slice(6))\n } else if (line.startsWith('data:')) {\n dataLines.push(line.slice(5))\n }\n }\n if (dataLines.length === 0) return null\n return dataLines.join('\\n')\n}\n"],
5
5
  "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -1,5 +1,12 @@
1
- const PENDING_ACTION_CLEANUP_SCHEDULE_ID = "ai_assistant:pending-action-cleanup";
2
- const TOKEN_USAGE_PRUNE_SCHEDULE_ID = "ai_assistant:token-usage-prune";
1
+ import { createHash } from "node:crypto";
2
+ const PENDING_ACTION_CLEANUP_SCHEDULE_KEY = "ai_assistant:pending-action-cleanup";
3
+ const TOKEN_USAGE_PRUNE_SCHEDULE_KEY = "ai_assistant:token-usage-prune";
4
+ function stableScheduleUuid(stableKey) {
5
+ const hex = createHash("sha256").update(stableKey).digest("hex");
6
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`;
7
+ }
8
+ const PENDING_ACTION_CLEANUP_SCHEDULE_ID = stableScheduleUuid(PENDING_ACTION_CLEANUP_SCHEDULE_KEY);
9
+ const TOKEN_USAGE_PRUNE_SCHEDULE_ID = stableScheduleUuid(TOKEN_USAGE_PRUNE_SCHEDULE_KEY);
3
10
  async function ensurePendingActionCleanupSchedule(container) {
4
11
  if (!container) return;
5
12
  let schedulerService;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../src/modules/ai_assistant/setup.ts"],
4
- "sourcesContent": ["import type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\n\nconst PENDING_ACTION_CLEANUP_SCHEDULE_ID = 'ai_assistant:pending-action-cleanup'\nconst TOKEN_USAGE_PRUNE_SCHEDULE_ID = 'ai_assistant:token-usage-prune'\n\n/**\n * System-scoped recurring schedule: every 5 minutes, enqueue a job to the\n * `ai-pending-action-cleanup` queue so the worker can sweep rows whose TTL\n * elapsed without any confirm/cancel activity (Step 5.12). The schedule id\n * is stable and `scheduler.register()` is an upsert, so calling this from\n * every tenant bootstrap stays idempotent.\n */\nasync function ensurePendingActionCleanupSchedule(\n container: import('awilix').AwilixContainer | undefined,\n): Promise<void> {\n if (!container) return\n let schedulerService:\n | {\n register: (registration: Record<string, unknown>) => Promise<void>\n }\n | undefined\n try {\n schedulerService = container.resolve('schedulerService')\n } catch {\n schedulerService = undefined\n }\n if (!schedulerService) return\n try {\n await schedulerService.register({\n id: PENDING_ACTION_CLEANUP_SCHEDULE_ID,\n name: 'AI pending-action cleanup',\n description:\n 'Sweep pending AI mutation approvals whose TTL elapsed without confirm/cancel and flip them to expired.',\n scopeType: 'system',\n scheduleType: 'interval',\n scheduleValue: '5m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'ai-pending-action-cleanup',\n targetPayload: {},\n sourceType: 'module',\n sourceModule: 'ai_assistant',\n isEnabled: true,\n })\n } catch (error) {\n console.warn(\n '[ai_assistant] Failed to register pending-action cleanup schedule:',\n error instanceof Error ? error.message : error,\n )\n }\n}\n\n/**\n * System-scoped daily schedule: enqueue a job to the `ai-token-usage-prune`\n * queue to prune events older than the retention window and reconcile the\n * daily rollup session counts.\n *\n * Phase 6.4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nasync function ensureTokenUsagePruneSchedule(\n container: import('awilix').AwilixContainer | undefined,\n): Promise<void> {\n if (!container) return\n let schedulerService:\n | {\n register: (registration: Record<string, unknown>) => Promise<void>\n }\n | undefined\n try {\n schedulerService = container.resolve('schedulerService')\n } catch {\n schedulerService = undefined\n }\n if (!schedulerService) return\n try {\n await schedulerService.register({\n id: TOKEN_USAGE_PRUNE_SCHEDULE_ID,\n name: 'AI token-usage prune',\n description:\n 'Delete ai_token_usage_events rows older than AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS (default 90) and reconcile session_count on the daily rollup.',\n scopeType: 'system',\n scheduleType: 'interval',\n scheduleValue: '24h',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'ai-token-usage-prune',\n targetPayload: {},\n sourceType: 'module',\n sourceModule: 'ai_assistant',\n isEnabled: true,\n })\n } catch (error) {\n console.warn(\n '[ai_assistant] Failed to register token-usage prune schedule:',\n error instanceof Error ? error.message : error,\n )\n }\n}\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n admin: [\n 'ai_assistant.view',\n 'ai_assistant.settings.manage',\n 'ai_assistant.conversations.manage',\n 'ai_assistant.conversations.share',\n 'ai_assistant.mcp.serve',\n 'ai_assistant.tools.list',\n 'ai_assistant.mcp_servers.view',\n 'ai_assistant.mcp_servers.manage',\n ],\n employee: ['ai_assistant.view', 'ai_assistant.conversations.share'],\n },\n\n async seedDefaults({ container }) {\n await ensurePendingActionCleanupSchedule(container)\n await ensureTokenUsagePruneSchedule(container)\n },\n}\n\nexport default setup\n"],
5
- "mappings": "AAEA,MAAM,qCAAqC;AAC3C,MAAM,gCAAgC;AAStC,eAAe,mCACb,WACe;AACf,MAAI,CAAC,UAAW;AAChB,MAAI;AAKJ,MAAI;AACF,uBAAmB,UAAU,QAAQ,kBAAkB;AAAA,EACzD,QAAQ;AACN,uBAAmB;AAAA,EACrB;AACA,MAAI,CAAC,iBAAkB;AACvB,MAAI;AACF,UAAM,iBAAiB,SAAS;AAAA,MAC9B,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,aACE;AAAA,MACF,WAAW;AAAA,MACX,cAAc;AAAA,MACd,eAAe;AAAA,MACf,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,eAAe,CAAC;AAAA,MAChB,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,WAAW;AAAA,IACb,CAAC;AAAA,EACH,SAAS,OAAO;AACd,YAAQ;AAAA,MACN;AAAA,MACA,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAC3C;AAAA,EACF;AACF;AASA,eAAe,8BACb,WACe;AACf,MAAI,CAAC,UAAW;AAChB,MAAI;AAKJ,MAAI;AACF,uBAAmB,UAAU,QAAQ,kBAAkB;AAAA,EACzD,QAAQ;AACN,uBAAmB;AAAA,EACrB;AACA,MAAI,CAAC,iBAAkB;AACvB,MAAI;AACF,UAAM,iBAAiB,SAAS;AAAA,MAC9B,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,aACE;AAAA,MACF,WAAW;AAAA,MACX,cAAc;AAAA,MACd,eAAe;AAAA,MACf,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,eAAe,CAAC;AAAA,MAChB,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,WAAW;AAAA,IACb,CAAC;AAAA,EACH,SAAS,OAAO;AACd,YAAQ;AAAA,MACN;AAAA,MACA,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAC3C;AAAA,EACF;AACF;AAEO,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,OAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,IACA,UAAU,CAAC,qBAAqB,kCAAkC;AAAA,EACpE;AAAA,EAEA,MAAM,aAAa,EAAE,UAAU,GAAG;AAChC,UAAM,mCAAmC,SAAS;AAClD,UAAM,8BAA8B,SAAS;AAAA,EAC/C;AACF;AAEA,IAAO,gBAAQ;",
4
+ "sourcesContent": ["import { createHash } from 'node:crypto'\nimport type { ModuleSetupConfig } from '@open-mercato/shared/modules/setup'\n\nconst PENDING_ACTION_CLEANUP_SCHEDULE_KEY = 'ai_assistant:pending-action-cleanup'\nconst TOKEN_USAGE_PRUNE_SCHEDULE_KEY = 'ai_assistant:token-usage-prune'\n\nfunction stableScheduleUuid(stableKey: string): string {\n const hex = createHash('sha256').update(stableKey).digest('hex')\n return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20, 32)}`\n}\n\nconst PENDING_ACTION_CLEANUP_SCHEDULE_ID = stableScheduleUuid(PENDING_ACTION_CLEANUP_SCHEDULE_KEY)\nconst TOKEN_USAGE_PRUNE_SCHEDULE_ID = stableScheduleUuid(TOKEN_USAGE_PRUNE_SCHEDULE_KEY)\n\n/**\n * System-scoped recurring schedule: every 5 minutes, enqueue a job to the\n * `ai-pending-action-cleanup` queue so the worker can sweep rows whose TTL\n * elapsed without any confirm/cancel activity (Step 5.12). The schedule id\n * is stable and `scheduler.register()` is an upsert, so calling this from\n * every tenant bootstrap stays idempotent.\n */\nasync function ensurePendingActionCleanupSchedule(\n container: import('awilix').AwilixContainer | undefined,\n): Promise<void> {\n if (!container) return\n let schedulerService:\n | {\n register: (registration: Record<string, unknown>) => Promise<void>\n }\n | undefined\n try {\n schedulerService = container.resolve('schedulerService')\n } catch {\n schedulerService = undefined\n }\n if (!schedulerService) return\n try {\n await schedulerService.register({\n id: PENDING_ACTION_CLEANUP_SCHEDULE_ID,\n name: 'AI pending-action cleanup',\n description:\n 'Sweep pending AI mutation approvals whose TTL elapsed without confirm/cancel and flip them to expired.',\n scopeType: 'system',\n scheduleType: 'interval',\n scheduleValue: '5m',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'ai-pending-action-cleanup',\n targetPayload: {},\n sourceType: 'module',\n sourceModule: 'ai_assistant',\n isEnabled: true,\n })\n } catch (error) {\n console.warn(\n '[ai_assistant] Failed to register pending-action cleanup schedule:',\n error instanceof Error ? error.message : error,\n )\n }\n}\n\n/**\n * System-scoped daily schedule: enqueue a job to the `ai-token-usage-prune`\n * queue to prune events older than the retention window and reconcile the\n * daily rollup session counts.\n *\n * Phase 6.4 of spec `2026-04-28-ai-agents-agentic-loop-controls`.\n */\nasync function ensureTokenUsagePruneSchedule(\n container: import('awilix').AwilixContainer | undefined,\n): Promise<void> {\n if (!container) return\n let schedulerService:\n | {\n register: (registration: Record<string, unknown>) => Promise<void>\n }\n | undefined\n try {\n schedulerService = container.resolve('schedulerService')\n } catch {\n schedulerService = undefined\n }\n if (!schedulerService) return\n try {\n await schedulerService.register({\n id: TOKEN_USAGE_PRUNE_SCHEDULE_ID,\n name: 'AI token-usage prune',\n description:\n 'Delete ai_token_usage_events rows older than AI_TOKEN_USAGE_EVENTS_RETENTION_DAYS (default 90) and reconcile session_count on the daily rollup.',\n scopeType: 'system',\n scheduleType: 'interval',\n scheduleValue: '24h',\n timezone: 'UTC',\n targetType: 'queue',\n targetQueue: 'ai-token-usage-prune',\n targetPayload: {},\n sourceType: 'module',\n sourceModule: 'ai_assistant',\n isEnabled: true,\n })\n } catch (error) {\n console.warn(\n '[ai_assistant] Failed to register token-usage prune schedule:',\n error instanceof Error ? error.message : error,\n )\n }\n}\n\nexport const setup: ModuleSetupConfig = {\n defaultRoleFeatures: {\n admin: [\n 'ai_assistant.view',\n 'ai_assistant.settings.manage',\n 'ai_assistant.conversations.manage',\n 'ai_assistant.conversations.share',\n 'ai_assistant.mcp.serve',\n 'ai_assistant.tools.list',\n 'ai_assistant.mcp_servers.view',\n 'ai_assistant.mcp_servers.manage',\n ],\n employee: ['ai_assistant.view', 'ai_assistant.conversations.share'],\n },\n\n async seedDefaults({ container }) {\n await ensurePendingActionCleanupSchedule(container)\n await ensureTokenUsagePruneSchedule(container)\n },\n}\n\nexport default setup\n"],
5
+ "mappings": "AAAA,SAAS,kBAAkB;AAG3B,MAAM,sCAAsC;AAC5C,MAAM,iCAAiC;AAEvC,SAAS,mBAAmB,WAA2B;AACrD,QAAM,MAAM,WAAW,QAAQ,EAAE,OAAO,SAAS,EAAE,OAAO,KAAK;AAC/D,SAAO,GAAG,IAAI,MAAM,GAAG,CAAC,CAAC,IAAI,IAAI,MAAM,GAAG,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC,IAAI,IAAI,MAAM,IAAI,EAAE,CAAC;AAC9G;AAEA,MAAM,qCAAqC,mBAAmB,mCAAmC;AACjG,MAAM,gCAAgC,mBAAmB,8BAA8B;AASvF,eAAe,mCACb,WACe;AACf,MAAI,CAAC,UAAW;AAChB,MAAI;AAKJ,MAAI;AACF,uBAAmB,UAAU,QAAQ,kBAAkB;AAAA,EACzD,QAAQ;AACN,uBAAmB;AAAA,EACrB;AACA,MAAI,CAAC,iBAAkB;AACvB,MAAI;AACF,UAAM,iBAAiB,SAAS;AAAA,MAC9B,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,aACE;AAAA,MACF,WAAW;AAAA,MACX,cAAc;AAAA,MACd,eAAe;AAAA,MACf,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,eAAe,CAAC;AAAA,MAChB,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,WAAW;AAAA,IACb,CAAC;AAAA,EACH,SAAS,OAAO;AACd,YAAQ;AAAA,MACN;AAAA,MACA,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAC3C;AAAA,EACF;AACF;AASA,eAAe,8BACb,WACe;AACf,MAAI,CAAC,UAAW;AAChB,MAAI;AAKJ,MAAI;AACF,uBAAmB,UAAU,QAAQ,kBAAkB;AAAA,EACzD,QAAQ;AACN,uBAAmB;AAAA,EACrB;AACA,MAAI,CAAC,iBAAkB;AACvB,MAAI;AACF,UAAM,iBAAiB,SAAS;AAAA,MAC9B,IAAI;AAAA,MACJ,MAAM;AAAA,MACN,aACE;AAAA,MACF,WAAW;AAAA,MACX,cAAc;AAAA,MACd,eAAe;AAAA,MACf,UAAU;AAAA,MACV,YAAY;AAAA,MACZ,aAAa;AAAA,MACb,eAAe,CAAC;AAAA,MAChB,YAAY;AAAA,MACZ,cAAc;AAAA,MACd,WAAW;AAAA,IACb,CAAC;AAAA,EACH,SAAS,OAAO;AACd,YAAQ;AAAA,MACN;AAAA,MACA,iBAAiB,QAAQ,MAAM,UAAU;AAAA,IAC3C;AAAA,EACF;AACF;AAEO,MAAM,QAA2B;AAAA,EACtC,qBAAqB;AAAA,IACnB,OAAO;AAAA,MACL;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,IACF;AAAA,IACA,UAAU,CAAC,qBAAqB,kCAAkC;AAAA,EACpE;AAAA,EAEA,MAAM,aAAa,EAAE,UAAU,GAAG;AAChC,UAAM,mCAAmC,SAAS;AAClD,UAAM,8BAA8B,SAAS;AAAA,EAC/C;AACF;AAEA,IAAO,gBAAQ;",
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.5337.1.534b781eac",
3
+ "version": "0.6.5",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22.0.0"
@@ -85,11 +85,11 @@
85
85
  }
86
86
  },
87
87
  "dependencies": {
88
- "@ai-sdk/anthropic": "^3.0.81",
89
- "@ai-sdk/google": "^3.0.80",
90
- "@ai-sdk/openai": "^3.0.68",
88
+ "@ai-sdk/anthropic": "^3.0.84",
89
+ "@ai-sdk/google": "^3.0.82",
90
+ "@ai-sdk/openai": "^3.0.71",
91
91
  "@modelcontextprotocol/sdk": "^1.29.0",
92
- "ai": "^6.0.198",
92
+ "ai": "^6.0.205",
93
93
  "cmdk": "^1.0.0",
94
94
  "framer-motion": "^12.40.0",
95
95
  "isolated-vm": "^7.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.5337.1.534b781eac",
102
- "@open-mercato/ui": "0.6.5-develop.5337.1.534b781eac",
101
+ "@open-mercato/shared": "0.6.5",
102
+ "@open-mercato/ui": "0.6.5",
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.5337.1.534b781eac",
109
- "@open-mercato/shared": "0.6.5-develop.5337.1.534b781eac",
110
- "@open-mercato/ui": "0.6.5-develop.5337.1.534b781eac",
108
+ "@open-mercato/cli": "0.6.5",
109
+ "@open-mercato/shared": "0.6.5",
110
+ "@open-mercato/ui": "0.6.5",
111
111
  "@types/react": "^19.2.17",
112
112
  "@types/react-dom": "^19.2.3",
113
113
  "react": "19.2.7",
@@ -122,6 +122,5 @@
122
122
  "type": "git",
123
123
  "url": "https://github.com/open-mercato/open-mercato",
124
124
  "directory": "packages/ai-assistant"
125
- },
126
- "stableVersion": "0.6.4"
125
+ }
127
126
  }
@@ -0,0 +1,63 @@
1
+ import type { ModuleCli } from '@open-mercato/shared/modules/registry'
2
+ import cliCommands from '../cli'
3
+
4
+ const mockRunMcpServer = jest.fn(async () => undefined)
5
+
6
+ jest.mock('@open-mercato/shared/lib/di/container', () => ({
7
+ createRequestContainer: jest.fn(async () => ({})),
8
+ getDiRegistrars: jest.fn(() => []),
9
+ }))
10
+
11
+ jest.mock('../lib/mcp-server', () => ({
12
+ runMcpServer: (...args: unknown[]) => mockRunMcpServer(...args),
13
+ }))
14
+
15
+ function getMcpServe(): ModuleCli {
16
+ const cmd = (cliCommands as ModuleCli[]).find((c) => c.command === 'mcp:serve')
17
+ if (!cmd) throw new Error('mcp:serve command not found')
18
+ return cmd
19
+ }
20
+
21
+ describe('mcp:serve API key resolution (issue #2669)', () => {
22
+ const originalEnv = process.env.OPEN_MERCATO_API_KEY
23
+
24
+ beforeEach(() => {
25
+ jest.clearAllMocks()
26
+ delete process.env.OPEN_MERCATO_API_KEY
27
+ })
28
+
29
+ afterEach(() => {
30
+ if (originalEnv === undefined) delete process.env.OPEN_MERCATO_API_KEY
31
+ else process.env.OPEN_MERCATO_API_KEY = originalEnv
32
+ })
33
+
34
+ it('reads the key from OPEN_MERCATO_API_KEY when --api-key is absent', async () => {
35
+ process.env.OPEN_MERCATO_API_KEY = 'omk_env.secret'
36
+
37
+ await getMcpServe().run([])
38
+
39
+ expect(mockRunMcpServer).toHaveBeenCalledTimes(1)
40
+ expect(mockRunMcpServer).toHaveBeenCalledWith(
41
+ expect.objectContaining({ apiKeySecret: 'omk_env.secret' }),
42
+ )
43
+ })
44
+
45
+ it('still accepts the --api-key flag and prefers it over the env var', async () => {
46
+ process.env.OPEN_MERCATO_API_KEY = 'omk_env.secret'
47
+
48
+ await getMcpServe().run(['--api-key', 'omk_flag.secret'])
49
+
50
+ expect(mockRunMcpServer).toHaveBeenCalledWith(
51
+ expect.objectContaining({ apiKeySecret: 'omk_flag.secret' }),
52
+ )
53
+ })
54
+
55
+ it('prints usage and does not start the server when neither key nor tenant is provided', async () => {
56
+ const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
57
+
58
+ await getMcpServe().run([])
59
+
60
+ expect(mockRunMcpServer).not.toHaveBeenCalled()
61
+ errorSpy.mockRestore()
62
+ })
63
+ })
@@ -0,0 +1,33 @@
1
+ import { setup } from '../setup'
2
+
3
+ const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
4
+
5
+ function makeContainer() {
6
+ const register = jest.fn().mockResolvedValue(undefined)
7
+ return {
8
+ register,
9
+ container: {
10
+ resolve(name: string) {
11
+ if (name !== 'schedulerService') throw new Error(`unexpected registration: ${name}`)
12
+ return { register }
13
+ },
14
+ },
15
+ }
16
+ }
17
+
18
+ describe('ai_assistant setup schedules', () => {
19
+ it('registers module schedules with uuid ids accepted by the scheduler table', async () => {
20
+ const { container, register } = makeContainer()
21
+
22
+ await setup.seedDefaults?.({ container } as never)
23
+
24
+ expect(register).toHaveBeenCalledTimes(2)
25
+ const ids = register.mock.calls.map(([registration]) => registration.id)
26
+
27
+ expect(ids).toHaveLength(2)
28
+ expect(new Set(ids).size).toBe(2)
29
+ expect(ids).not.toContain('ai_assistant:pending-action-cleanup')
30
+ expect(ids).not.toContain('ai_assistant:token-usage-prune')
31
+ expect(ids.every((id) => UUID_RE.test(id))).toBe(true)
32
+ })
33
+ })
@@ -12,6 +12,7 @@ import {
12
12
  resetAgentRegistryForTests,
13
13
  seedAgentRegistryForTests,
14
14
  } from '../../lib/agent-registry'
15
+ import * as agentRegistry from '../../lib/agent-registry'
15
16
  import metaAiTools from '../meta-pack'
16
17
 
17
18
  function findTool(name: string) {
@@ -262,6 +263,34 @@ describe('meta.update_task_plan', () => {
262
263
  })
263
264
  })
264
265
 
266
+ describe('meta-pack registry bootstrap', () => {
267
+ let loadSpy: jest.SpyInstance
268
+
269
+ beforeEach(() => {
270
+ resetAgentRegistryForTests()
271
+ loadSpy = jest
272
+ .spyOn(agentRegistry, 'loadAgentRegistry')
273
+ .mockResolvedValue(undefined)
274
+ })
275
+
276
+ afterEach(() => {
277
+ loadSpy.mockRestore()
278
+ resetAgentRegistryForTests()
279
+ })
280
+
281
+ it('lazy-loads the registry before listing agents (standalone MCP has no bootstrap)', async () => {
282
+ const tool = findTool('meta.list_agents')
283
+ await tool.handler({}, makeCtx() as any)
284
+ expect(loadSpy).toHaveBeenCalledTimes(1)
285
+ })
286
+
287
+ it('lazy-loads the registry before describing an agent', async () => {
288
+ const tool = findTool('meta.describe_agent')
289
+ await tool.handler({ agentId: 'whatever' }, makeCtx() as any)
290
+ expect(loadSpy).toHaveBeenCalledTimes(1)
291
+ })
292
+ })
293
+
265
294
  describe('meta-pack tool surface', () => {
266
295
  it('exports the read-only meta tools', () => {
267
296
  const names = metaAiTools.map((tool) => tool.name)
@@ -9,7 +9,7 @@
9
9
  */
10
10
  import { z } from 'zod'
11
11
  import type { AiAgentDefinition } from '../lib/ai-agent-definition'
12
- import { listAgents, getAgent } from '../lib/agent-registry'
12
+ import { listAgents, getAgent, loadAgentRegistry } from '../lib/agent-registry'
13
13
  import { hasRequiredFeatures } from '../lib/auth'
14
14
  import { defineAiTool } from '../lib/ai-tool-definition'
15
15
  import {
@@ -91,6 +91,10 @@ const listAgentsTool = defineAiTool({
91
91
  const input = listAgentsInput.parse(rawInput)
92
92
  let all: AiAgentDefinition[] = []
93
93
  try {
94
+ // Lazy-load the registry on first use. The in-app agents route loads it
95
+ // at request time; standalone MCP servers have no such bootstrap, so the
96
+ // tool must ensure it itself. Idempotent — subsequent calls are no-ops.
97
+ await loadAgentRegistry()
94
98
  all = listAgents()
95
99
  } catch {
96
100
  all = []
@@ -123,6 +127,8 @@ const describeAgentTool = defineAiTool({
123
127
  const input = describeAgentInput.parse(rawInput)
124
128
  let agent: AiAgentDefinition | undefined
125
129
  try {
130
+ // Lazy-load the registry on first use (see meta.list_agents). Idempotent.
131
+ await loadAgentRegistry()
126
132
  agent = getAgent(input.agentId)
127
133
  } catch {
128
134
  agent = undefined