@open-mercato/ai-assistant 0.5.1-develop.3036.f02c281f23 → 0.5.1-develop.3045.b4b3320cc2

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 (273) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/AGENTS.md +361 -0
  3. package/README.md +5 -0
  4. package/dist/index.js +154 -0
  5. package/dist/index.js.map +2 -2
  6. package/dist/modules/ai_assistant/__integration__/TC-AI-002-agent-policy.spec.js +73 -0
  7. package/dist/modules/ai_assistant/__integration__/TC-AI-002-agent-policy.spec.js.map +7 -0
  8. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-SETTINGS-005-settings-page.spec.js +484 -0
  9. package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-SETTINGS-005-settings-page.spec.js.map +7 -0
  10. package/dist/modules/ai_assistant/__integration__/TC-AI-PLAYGROUND-004-playground.spec.js +251 -0
  11. package/dist/modules/ai_assistant/__integration__/TC-AI-PLAYGROUND-004-playground.spec.js.map +7 -0
  12. package/dist/modules/ai_assistant/__integration__/TC-INT-AI-TOOLS.spec.js +91 -0
  13. package/dist/modules/ai_assistant/__integration__/TC-INT-AI-TOOLS.spec.js.map +7 -0
  14. package/dist/modules/ai_assistant/ai-tools/attachments-pack.js +202 -0
  15. package/dist/modules/ai_assistant/ai-tools/attachments-pack.js.map +7 -0
  16. package/dist/modules/ai_assistant/ai-tools/meta-pack.js +121 -0
  17. package/dist/modules/ai_assistant/ai-tools/meta-pack.js.map +7 -0
  18. package/dist/modules/ai_assistant/ai-tools/search-pack.js +94 -0
  19. package/dist/modules/ai_assistant/ai-tools/search-pack.js.map +7 -0
  20. package/dist/modules/ai_assistant/ai-tools.js +14 -0
  21. package/dist/modules/ai_assistant/ai-tools.js.map +7 -0
  22. package/dist/modules/ai_assistant/api/ai/actions/[id]/cancel/route.js +175 -0
  23. package/dist/modules/ai_assistant/api/ai/actions/[id]/cancel/route.js.map +7 -0
  24. package/dist/modules/ai_assistant/api/ai/actions/[id]/confirm/route.js +174 -0
  25. package/dist/modules/ai_assistant/api/ai/actions/[id]/confirm/route.js.map +7 -0
  26. package/dist/modules/ai_assistant/api/ai/actions/[id]/route.js +101 -0
  27. package/dist/modules/ai_assistant/api/ai/actions/[id]/route.js.map +7 -0
  28. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/mutation-policy/route.js +311 -0
  29. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/mutation-policy/route.js.map +7 -0
  30. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/prompt-override/route.js +246 -0
  31. package/dist/modules/ai_assistant/api/ai/agents/[agentId]/prompt-override/route.js.map +7 -0
  32. package/dist/modules/ai_assistant/api/ai/agents/route.js +94 -0
  33. package/dist/modules/ai_assistant/api/ai/agents/route.js.map +7 -0
  34. package/dist/modules/ai_assistant/api/ai/chat/route.js +173 -0
  35. package/dist/modules/ai_assistant/api/ai/chat/route.js.map +7 -0
  36. package/dist/modules/ai_assistant/api/ai/run-object/route.js +167 -0
  37. package/dist/modules/ai_assistant/api/ai/run-object/route.js.map +7 -0
  38. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +1111 -0
  39. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +7 -0
  40. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/page.js +10 -0
  41. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/page.js.map +7 -0
  42. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/page.meta.js +28 -0
  43. package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/page.meta.js.map +7 -0
  44. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +10 -0
  45. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +7 -0
  46. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.meta.js +30 -0
  47. package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.meta.js.map +7 -0
  48. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js +4 -6
  49. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js.map +2 -2
  50. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js +1 -21
  51. package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js.map +2 -2
  52. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +462 -0
  53. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +7 -0
  54. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/page.js +10 -0
  55. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/page.js.map +7 -0
  56. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/page.meta.js +28 -0
  57. package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/page.meta.js.map +7 -0
  58. package/dist/modules/ai_assistant/cli.js +78 -12
  59. package/dist/modules/ai_assistant/cli.js.map +2 -2
  60. package/dist/modules/ai_assistant/data/entities/AiAgentMutationPolicyOverride.js +5 -0
  61. package/dist/modules/ai_assistant/data/entities/AiAgentMutationPolicyOverride.js.map +7 -0
  62. package/dist/modules/ai_assistant/data/entities/AiAgentPromptOverride.js +5 -0
  63. package/dist/modules/ai_assistant/data/entities/AiAgentPromptOverride.js.map +7 -0
  64. package/dist/modules/ai_assistant/data/entities/AiPendingAction.js +5 -0
  65. package/dist/modules/ai_assistant/data/entities/AiPendingAction.js.map +7 -0
  66. package/dist/modules/ai_assistant/data/entities.js +228 -0
  67. package/dist/modules/ai_assistant/data/entities.js.map +7 -0
  68. package/dist/modules/ai_assistant/data/repositories/AiAgentMutationPolicyOverrideRepository.js +95 -0
  69. package/dist/modules/ai_assistant/data/repositories/AiAgentMutationPolicyOverrideRepository.js.map +7 -0
  70. package/dist/modules/ai_assistant/data/repositories/AiAgentPromptOverrideRepository.js +95 -0
  71. package/dist/modules/ai_assistant/data/repositories/AiAgentPromptOverrideRepository.js.map +7 -0
  72. package/dist/modules/ai_assistant/data/repositories/AiPendingActionRepository.js +223 -0
  73. package/dist/modules/ai_assistant/data/repositories/AiPendingActionRepository.js.map +7 -0
  74. package/dist/modules/ai_assistant/events.js +33 -0
  75. package/dist/modules/ai_assistant/events.js.map +7 -0
  76. package/dist/modules/ai_assistant/i18n/de.json +252 -0
  77. package/dist/modules/ai_assistant/i18n/en.json +252 -0
  78. package/dist/modules/ai_assistant/i18n/es.json +252 -0
  79. package/dist/modules/ai_assistant/i18n/pl.json +252 -0
  80. package/dist/modules/ai_assistant/lib/agent-policy.js +168 -0
  81. package/dist/modules/ai_assistant/lib/agent-policy.js.map +7 -0
  82. package/dist/modules/ai_assistant/lib/agent-registry.js +195 -0
  83. package/dist/modules/ai_assistant/lib/agent-registry.js.map +7 -0
  84. package/dist/modules/ai_assistant/lib/agent-runtime.js +451 -0
  85. package/dist/modules/ai_assistant/lib/agent-runtime.js.map +7 -0
  86. package/dist/modules/ai_assistant/lib/agent-tools.js +223 -0
  87. package/dist/modules/ai_assistant/lib/agent-tools.js.map +7 -0
  88. package/dist/modules/ai_assistant/lib/agent-transport.js +25 -0
  89. package/dist/modules/ai_assistant/lib/agent-transport.js.map +7 -0
  90. package/dist/modules/ai_assistant/lib/ai-agent-definition.js +11 -0
  91. package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +7 -0
  92. package/dist/modules/ai_assistant/lib/ai-agents-generated.d.js +1 -0
  93. package/dist/modules/ai_assistant/lib/ai-agents-generated.d.js.map +7 -0
  94. package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js +239 -0
  95. package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js.map +7 -0
  96. package/dist/modules/ai_assistant/lib/ai-overrides.js +189 -0
  97. package/dist/modules/ai_assistant/lib/ai-overrides.js.map +7 -0
  98. package/dist/modules/ai_assistant/lib/ai-tool-definition.js +7 -0
  99. package/dist/modules/ai_assistant/lib/ai-tool-definition.js.map +7 -0
  100. package/dist/modules/ai_assistant/lib/ai-tools-generated.d.js +1 -0
  101. package/dist/modules/ai_assistant/lib/ai-tools-generated.d.js.map +7 -0
  102. package/dist/modules/ai_assistant/lib/api-backed-tool.js +48 -0
  103. package/dist/modules/ai_assistant/lib/api-backed-tool.js.map +7 -0
  104. package/dist/modules/ai_assistant/lib/attachment-bridge-types.js +1 -0
  105. package/dist/modules/ai_assistant/lib/attachment-bridge-types.js.map +7 -0
  106. package/dist/modules/ai_assistant/lib/attachment-parts.js +276 -0
  107. package/dist/modules/ai_assistant/lib/attachment-parts.js.map +7 -0
  108. package/dist/modules/ai_assistant/lib/model-factory.js +68 -0
  109. package/dist/modules/ai_assistant/lib/model-factory.js.map +7 -0
  110. package/dist/modules/ai_assistant/lib/pending-action-cancel.js +86 -0
  111. package/dist/modules/ai_assistant/lib/pending-action-cancel.js.map +7 -0
  112. package/dist/modules/ai_assistant/lib/pending-action-client.js +35 -0
  113. package/dist/modules/ai_assistant/lib/pending-action-client.js.map +7 -0
  114. package/dist/modules/ai_assistant/lib/pending-action-executor.js +243 -0
  115. package/dist/modules/ai_assistant/lib/pending-action-executor.js.map +7 -0
  116. package/dist/modules/ai_assistant/lib/pending-action-recheck.js +246 -0
  117. package/dist/modules/ai_assistant/lib/pending-action-recheck.js.map +7 -0
  118. package/dist/modules/ai_assistant/lib/pending-action-types.js +70 -0
  119. package/dist/modules/ai_assistant/lib/pending-action-types.js.map +7 -0
  120. package/dist/modules/ai_assistant/lib/prepare-mutation.js +315 -0
  121. package/dist/modules/ai_assistant/lib/prepare-mutation.js.map +7 -0
  122. package/dist/modules/ai_assistant/lib/prompt-composition-types.js +7 -0
  123. package/dist/modules/ai_assistant/lib/prompt-composition-types.js.map +7 -0
  124. package/dist/modules/ai_assistant/lib/prompt-override-merge.js +175 -0
  125. package/dist/modules/ai_assistant/lib/prompt-override-merge.js.map +7 -0
  126. package/dist/modules/ai_assistant/lib/schema-utils.js +5 -1
  127. package/dist/modules/ai_assistant/lib/schema-utils.js.map +2 -2
  128. package/dist/modules/ai_assistant/lib/tool-executor.js +13 -2
  129. package/dist/modules/ai_assistant/lib/tool-executor.js.map +2 -2
  130. package/dist/modules/ai_assistant/lib/tool-loader.js +86 -11
  131. package/dist/modules/ai_assistant/lib/tool-loader.js.map +2 -2
  132. package/dist/modules/ai_assistant/lib/tool-test-fixtures.js +120 -0
  133. package/dist/modules/ai_assistant/lib/tool-test-fixtures.js.map +7 -0
  134. package/dist/modules/ai_assistant/lib/tool-test-runner.js +418 -0
  135. package/dist/modules/ai_assistant/lib/tool-test-runner.js.map +7 -0
  136. package/dist/modules/ai_assistant/migrations/Migration20260419100521.js +17 -0
  137. package/dist/modules/ai_assistant/migrations/Migration20260419100521.js.map +7 -0
  138. package/dist/modules/ai_assistant/migrations/Migration20260419132948.js +16 -0
  139. package/dist/modules/ai_assistant/migrations/Migration20260419132948.js.map +7 -0
  140. package/dist/modules/ai_assistant/migrations/Migration20260419134235.js +17 -0
  141. package/dist/modules/ai_assistant/migrations/Migration20260419134235.js.map +7 -0
  142. package/dist/modules/ai_assistant/setup.js +36 -0
  143. package/dist/modules/ai_assistant/setup.js.map +2 -2
  144. package/dist/modules/ai_assistant/workers/ai-pending-action-cleanup.js +161 -0
  145. package/dist/modules/ai_assistant/workers/ai-pending-action-cleanup.js.map +7 -0
  146. package/generated/entities/ai_agent_mutation_policy_override/index.ts +9 -0
  147. package/generated/entities/ai_agent_prompt_override/index.ts +10 -0
  148. package/generated/entities/ai_pending_action/index.ts +24 -0
  149. package/generated/entities.ids.generated.ts +13 -0
  150. package/generated/entity-fields-registry.ts +57 -0
  151. package/jest.config.cjs +7 -0
  152. package/package.json +4 -4
  153. package/src/index.ts +215 -0
  154. package/src/modules/ai_assistant/__integration__/README.md +5 -0
  155. package/src/modules/ai_assistant/__integration__/TC-AI-002-agent-policy.spec.ts +115 -0
  156. package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-SETTINGS-005-settings-page.spec.ts +574 -0
  157. package/src/modules/ai_assistant/__integration__/TC-AI-PLAYGROUND-004-playground.spec.ts +333 -0
  158. package/src/modules/ai_assistant/__integration__/TC-INT-AI-TOOLS.spec.ts +135 -0
  159. package/src/modules/ai_assistant/__tests__/events.test.ts +145 -0
  160. package/src/modules/ai_assistant/__tests__/integration/pending-action-contract.test.ts +1015 -0
  161. package/src/modules/ai_assistant/__tests__/integration/ws-c-attachment-bridge.test.ts +235 -0
  162. package/src/modules/ai_assistant/__tests__/integration/ws-c-policy-and-tools.test.ts +330 -0
  163. package/src/modules/ai_assistant/__tests__/integration/ws-c-tool-pack-coverage.test.ts +285 -0
  164. package/src/modules/ai_assistant/ai-tools/__tests__/attachments-pack.test.ts +322 -0
  165. package/src/modules/ai_assistant/ai-tools/__tests__/meta-pack.test.ts +218 -0
  166. package/src/modules/ai_assistant/ai-tools/__tests__/search-pack.test.ts +192 -0
  167. package/src/modules/ai_assistant/ai-tools/attachments-pack.ts +269 -0
  168. package/src/modules/ai_assistant/ai-tools/meta-pack.ts +140 -0
  169. package/src/modules/ai_assistant/ai-tools/search-pack.ts +122 -0
  170. package/src/modules/ai_assistant/ai-tools.ts +21 -0
  171. package/src/modules/ai_assistant/api/ai/actions/[id]/__tests__/route.test.ts +222 -0
  172. package/src/modules/ai_assistant/api/ai/actions/[id]/cancel/__tests__/route.test.ts +286 -0
  173. package/src/modules/ai_assistant/api/ai/actions/[id]/cancel/route.ts +237 -0
  174. package/src/modules/ai_assistant/api/ai/actions/[id]/confirm/__tests__/route.test.ts +339 -0
  175. package/src/modules/ai_assistant/api/ai/actions/[id]/confirm/route.ts +229 -0
  176. package/src/modules/ai_assistant/api/ai/actions/[id]/route.ts +142 -0
  177. package/src/modules/ai_assistant/api/ai/agents/[agentId]/mutation-policy/__tests__/route.test.ts +367 -0
  178. package/src/modules/ai_assistant/api/ai/agents/[agentId]/mutation-policy/route.ts +380 -0
  179. package/src/modules/ai_assistant/api/ai/agents/[agentId]/prompt-override/__tests__/route.test.ts +333 -0
  180. package/src/modules/ai_assistant/api/ai/agents/[agentId]/prompt-override/route.ts +307 -0
  181. package/src/modules/ai_assistant/api/ai/agents/route.ts +107 -0
  182. package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +282 -0
  183. package/src/modules/ai_assistant/api/ai/chat/route.ts +207 -0
  184. package/src/modules/ai_assistant/api/ai/run-object/__tests__/route.test.ts +282 -0
  185. package/src/modules/ai_assistant/api/ai/run-object/route.ts +204 -0
  186. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +1419 -0
  187. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/page.meta.ts +26 -0
  188. package/src/modules/ai_assistant/backend/config/ai-assistant/agents/page.tsx +12 -0
  189. package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.meta.ts +28 -0
  190. package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +12 -0
  191. package/src/modules/ai_assistant/backend/config/ai-assistant/page.meta.ts +8 -23
  192. package/src/modules/ai_assistant/backend/config/ai-assistant/page.tsx +15 -10
  193. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +604 -0
  194. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/page.meta.ts +26 -0
  195. package/src/modules/ai_assistant/backend/config/ai-assistant/playground/page.tsx +12 -0
  196. package/src/modules/ai_assistant/cli.ts +99 -24
  197. package/src/modules/ai_assistant/data/__tests__/schema-unique-indexes.test.ts +69 -0
  198. package/src/modules/ai_assistant/data/entities/AiAgentMutationPolicyOverride.ts +7 -0
  199. package/src/modules/ai_assistant/data/entities/AiAgentPromptOverride.ts +7 -0
  200. package/src/modules/ai_assistant/data/entities/AiPendingAction.ts +7 -0
  201. package/src/modules/ai_assistant/data/entities.ts +270 -0
  202. package/src/modules/ai_assistant/data/repositories/AiAgentMutationPolicyOverrideRepository.ts +129 -0
  203. package/src/modules/ai_assistant/data/repositories/AiAgentPromptOverrideRepository.ts +132 -0
  204. package/src/modules/ai_assistant/data/repositories/AiPendingActionRepository.ts +334 -0
  205. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentMutationPolicyOverrideRepository.test.ts +195 -0
  206. package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentPromptOverrideRepository.test.ts +197 -0
  207. package/src/modules/ai_assistant/data/repositories/__tests__/AiPendingActionRepository.test.ts +357 -0
  208. package/src/modules/ai_assistant/events.ts +112 -0
  209. package/src/modules/ai_assistant/i18n/de.json +252 -0
  210. package/src/modules/ai_assistant/i18n/en.json +252 -0
  211. package/src/modules/ai_assistant/i18n/es.json +252 -0
  212. package/src/modules/ai_assistant/i18n/pl.json +252 -0
  213. package/src/modules/ai_assistant/lib/__tests__/agent-policy.mutation-override.test.ts +203 -0
  214. package/src/modules/ai_assistant/lib/__tests__/agent-policy.test.ts +385 -0
  215. package/src/modules/ai_assistant/lib/__tests__/agent-registry.test.ts +217 -0
  216. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-object.test.ts +329 -0
  217. package/src/modules/ai_assistant/lib/__tests__/agent-runtime-parity.test.ts +573 -0
  218. package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +291 -0
  219. package/src/modules/ai_assistant/lib/__tests__/agent-tools.test.ts +172 -0
  220. package/src/modules/ai_assistant/lib/__tests__/agent-transport.test.ts +41 -0
  221. package/src/modules/ai_assistant/lib/__tests__/ai-agent-definition.test.ts +183 -0
  222. package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +432 -0
  223. package/src/modules/ai_assistant/lib/__tests__/ai-overrides.test.ts +308 -0
  224. package/src/modules/ai_assistant/lib/__tests__/api-backed-tool.test.ts +302 -0
  225. package/src/modules/ai_assistant/lib/__tests__/attachment-bridge-and-prompt-types.test.ts +188 -0
  226. package/src/modules/ai_assistant/lib/__tests__/attachment-parts.test.ts +531 -0
  227. package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +263 -0
  228. package/src/modules/ai_assistant/lib/__tests__/model-factory.integration.test.ts +183 -0
  229. package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +168 -0
  230. package/src/modules/ai_assistant/lib/__tests__/pending-action-cancel.test.ts +235 -0
  231. package/src/modules/ai_assistant/lib/__tests__/pending-action-client.test.ts +148 -0
  232. package/src/modules/ai_assistant/lib/__tests__/pending-action-executor.test.ts +348 -0
  233. package/src/modules/ai_assistant/lib/__tests__/pending-action-recheck.test.ts +378 -0
  234. package/src/modules/ai_assistant/lib/__tests__/phase-0-additive-contract.test.ts +299 -0
  235. package/src/modules/ai_assistant/lib/__tests__/prepare-mutation.test.ts +610 -0
  236. package/src/modules/ai_assistant/lib/__tests__/prompt-override-merge.test.ts +136 -0
  237. package/src/modules/ai_assistant/lib/__tests__/tool-loader.test.ts +125 -0
  238. package/src/modules/ai_assistant/lib/agent-policy.ts +270 -0
  239. package/src/modules/ai_assistant/lib/agent-registry.ts +277 -0
  240. package/src/modules/ai_assistant/lib/agent-runtime.ts +751 -0
  241. package/src/modules/ai_assistant/lib/agent-tools.ts +396 -0
  242. package/src/modules/ai_assistant/lib/agent-transport.ts +51 -0
  243. package/src/modules/ai_assistant/lib/ai-agent-definition.ts +86 -0
  244. package/src/modules/ai_assistant/lib/ai-agents-generated.d.ts +18 -0
  245. package/src/modules/ai_assistant/lib/ai-api-operation-runner.ts +333 -0
  246. package/src/modules/ai_assistant/lib/ai-overrides.ts +389 -0
  247. package/src/modules/ai_assistant/lib/ai-tool-definition.ts +7 -0
  248. package/src/modules/ai_assistant/lib/ai-tools-generated.d.ts +7 -0
  249. package/src/modules/ai_assistant/lib/api-backed-tool.ts +85 -0
  250. package/src/modules/ai_assistant/lib/attachment-bridge-types.ts +24 -0
  251. package/src/modules/ai_assistant/lib/attachment-parts.ts +433 -0
  252. package/src/modules/ai_assistant/lib/model-factory.ts +212 -0
  253. package/src/modules/ai_assistant/lib/pending-action-cancel.ts +179 -0
  254. package/src/modules/ai_assistant/lib/pending-action-client.ts +126 -0
  255. package/src/modules/ai_assistant/lib/pending-action-executor.ts +424 -0
  256. package/src/modules/ai_assistant/lib/pending-action-recheck.ts +410 -0
  257. package/src/modules/ai_assistant/lib/pending-action-types.ts +194 -0
  258. package/src/modules/ai_assistant/lib/prepare-mutation.ts +448 -0
  259. package/src/modules/ai_assistant/lib/prompt-composition-types.ts +24 -0
  260. package/src/modules/ai_assistant/lib/prompt-override-merge.ts +253 -0
  261. package/src/modules/ai_assistant/lib/schema-utils.ts +14 -2
  262. package/src/modules/ai_assistant/lib/tool-executor.ts +25 -3
  263. package/src/modules/ai_assistant/lib/tool-loader.ts +159 -13
  264. package/src/modules/ai_assistant/lib/tool-test-fixtures.ts +160 -0
  265. package/src/modules/ai_assistant/lib/tool-test-runner.ts +596 -0
  266. package/src/modules/ai_assistant/lib/types.ts +105 -2
  267. package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +871 -0
  268. package/src/modules/ai_assistant/migrations/Migration20260419100521.ts +17 -0
  269. package/src/modules/ai_assistant/migrations/Migration20260419132948.ts +16 -0
  270. package/src/modules/ai_assistant/migrations/Migration20260419134235.ts +17 -0
  271. package/src/modules/ai_assistant/setup.ts +53 -0
  272. package/src/modules/ai_assistant/workers/__tests__/ai-pending-action-cleanup.test.ts +333 -0
  273. package/src/modules/ai_assistant/workers/ai-pending-action-cleanup.ts +269 -0
@@ -0,0 +1,263 @@
1
+ /**
2
+ * Step 5.16 — Phase 3 WS-D integration tests for the execution-budget
3
+ * (`maxSteps`) contract on `runAiAgentText` and `runAiAgentObject`.
4
+ *
5
+ * Pins the Step 3.4 / 3.5 `stopWhen: stepCountIs(agent.maxSteps)` plumbing:
6
+ *
7
+ * - agent declares `maxSteps: n (n > 0)` → `stopWhen: stepCountIs(n)`
8
+ * - agent omits `maxSteps` (or sets 0) → no `stopWhen` on the SDK args
9
+ * - `runAiAgentObject` preserves the exact same precedence — object-mode
10
+ * must not silently diverge from chat-mode (spec §1.5).
11
+ *
12
+ * The Step description also enumerates a "caller-passed stopWhen overrides
13
+ * the agent's maxSteps" scenario. The current `RunAiAgentTextInput` /
14
+ * `RunAiAgentObjectInput` shapes do NOT expose a per-call override surface
15
+ * (only `modelOverride`). Introducing a public `maxStepsOverride` field
16
+ * would require production code changes, and Step 5.16 is strictly
17
+ * additive-test-only ("No new production code in this Step"). That
18
+ * scenario is therefore documented as a deliberate gap in step-5.16-checks.md
19
+ * rather than forced through a test-only seam that would misrepresent the
20
+ * public contract.
21
+ *
22
+ * The AI SDK module is stubbed at the Jest module boundary. `streamText`,
23
+ * `generateObject`, `streamObject`, `convertToModelMessages`, and
24
+ * `stepCountIs` are all replaced by jest.fn()s so the test never hits a
25
+ * real provider. The provider registry is stubbed the same way as in
26
+ * `agent-runtime.test.ts`.
27
+ */
28
+
29
+ const streamTextMock = jest.fn()
30
+ const generateObjectMock = jest.fn()
31
+ const streamObjectMock = jest.fn()
32
+ const convertToModelMessagesMock = jest.fn((messages: unknown) => messages)
33
+ const stepCountIsMock = jest.fn(
34
+ (count: number) => ({ __stopWhen: 'stepCount', count }) as const,
35
+ )
36
+
37
+ jest.mock('ai', () => {
38
+ const actual = jest.requireActual('ai')
39
+ return {
40
+ ...actual,
41
+ streamText: (...args: unknown[]) => streamTextMock(...args),
42
+ generateObject: (...args: unknown[]) => generateObjectMock(...args),
43
+ streamObject: (...args: unknown[]) => streamObjectMock(...args),
44
+ stepCountIs: (...args: unknown[]) => stepCountIsMock(...(args as [number])),
45
+ convertToModelMessages: (...args: unknown[]) => convertToModelMessagesMock(...args),
46
+ }
47
+ })
48
+
49
+ const createModelMock = jest.fn((options: { modelId: string; apiKey: string }) => ({
50
+ id: options.modelId,
51
+ apiKey: options.apiKey,
52
+ }))
53
+ const resolveApiKeyMock = jest.fn(() => 'test-api-key')
54
+
55
+ jest.mock('@open-mercato/shared/lib/ai/llm-provider-registry', () => ({
56
+ llmProviderRegistry: {
57
+ resolveFirstConfigured: () => ({
58
+ id: 'test-provider',
59
+ defaultModel: 'provider-default-model',
60
+ resolveApiKey: resolveApiKeyMock,
61
+ createModel: createModelMock,
62
+ }),
63
+ },
64
+ }))
65
+
66
+ import { z } from 'zod'
67
+ import type { AiAgentDefinition } from '../ai-agent-definition'
68
+ import {
69
+ resetAgentRegistryForTests,
70
+ seedAgentRegistryForTests,
71
+ } from '../agent-registry'
72
+ import { toolRegistry } from '../tool-registry'
73
+ import { runAiAgentObject, runAiAgentText } from '../agent-runtime'
74
+
75
+ function makeAgent(
76
+ overrides: Partial<AiAgentDefinition> & Pick<AiAgentDefinition, 'id' | 'moduleId'>,
77
+ ): AiAgentDefinition {
78
+ return {
79
+ label: `${overrides.id} label`,
80
+ description: `${overrides.id} description`,
81
+ systemPrompt: 'System prompt base.',
82
+ allowedTools: [],
83
+ ...overrides,
84
+ }
85
+ }
86
+
87
+ const baseAuth = {
88
+ tenantId: 'tenant-1',
89
+ organizationId: 'org-1',
90
+ userId: 'user-1',
91
+ features: ['*'],
92
+ isSuperAdmin: true,
93
+ }
94
+
95
+ const baseMessages = [
96
+ { role: 'user' as const, id: 'm1', parts: [{ type: 'text' as const, text: 'hi' }] },
97
+ ]
98
+
99
+ function fakeStreamResult(): {
100
+ toTextStreamResponse: jest.Mock
101
+ toUIMessageStreamResponse: jest.Mock
102
+ } {
103
+ return {
104
+ toTextStreamResponse: jest.fn(
105
+ () =>
106
+ new Response('streamed', {
107
+ status: 200,
108
+ headers: { 'Content-Type': 'text/event-stream' },
109
+ }),
110
+ ),
111
+ toUIMessageStreamResponse: jest.fn(
112
+ () =>
113
+ new Response('streamed', {
114
+ status: 200,
115
+ headers: { 'Content-Type': 'text/event-stream' },
116
+ }),
117
+ ),
118
+ }
119
+ }
120
+
121
+ describe('Step 5.16 — runAiAgentText maxSteps budget (integration)', () => {
122
+ beforeEach(() => {
123
+ jest.clearAllMocks()
124
+ resetAgentRegistryForTests()
125
+ toolRegistry.clear()
126
+ streamTextMock.mockImplementation(() => fakeStreamResult())
127
+ })
128
+ afterAll(() => {
129
+ resetAgentRegistryForTests()
130
+ toolRegistry.clear()
131
+ })
132
+
133
+ it('passes stopWhen: stepCountIs(agent.maxSteps) when maxSteps is a positive integer', async () => {
134
+ seedAgentRegistryForTests([
135
+ makeAgent({
136
+ id: 'customers.account_assistant',
137
+ moduleId: 'customers',
138
+ maxSteps: 3,
139
+ }),
140
+ ])
141
+ await runAiAgentText({
142
+ agentId: 'customers.account_assistant',
143
+ messages: baseMessages as never,
144
+ authContext: baseAuth,
145
+ })
146
+ expect(stepCountIsMock).toHaveBeenCalledWith(3)
147
+ const callArg = streamTextMock.mock.calls[0][0] as { stopWhen: unknown }
148
+ expect(callArg.stopWhen).toEqual({ __stopWhen: 'stepCount', count: 3 })
149
+ })
150
+
151
+ it('applies default stopWhen: stepCountIs(10) when maxSteps is undefined (tool-call-enabling default)', async () => {
152
+ // PR #1593 (commit 5873fcee5) added a default of 10 when maxSteps is
153
+ // undefined — without stopWhen the AI SDK runs a single model call and
154
+ // never executes tool calls, which makes every tool-using query return
155
+ // an empty stream. The test pins that behavior.
156
+ seedAgentRegistryForTests([
157
+ makeAgent({
158
+ id: 'customers.account_assistant',
159
+ moduleId: 'customers',
160
+ // Explicit undefined — the default case for most agents.
161
+ }),
162
+ ])
163
+ await runAiAgentText({
164
+ agentId: 'customers.account_assistant',
165
+ messages: baseMessages as never,
166
+ authContext: baseAuth,
167
+ })
168
+ expect(stepCountIsMock).toHaveBeenCalledWith(10)
169
+ const callArg = streamTextMock.mock.calls[0][0] as { stopWhen: unknown }
170
+ expect(callArg.stopWhen).toEqual({ __stopWhen: 'stepCount', count: 10 })
171
+ })
172
+
173
+ it('falls back to default stopWhen: stepCountIs(10) when maxSteps is 0', async () => {
174
+ // Spec §1.4: maxSteps must be a positive integer; 0 is treated the same
175
+ // as undefined. Post-#1593 that means the default-10 guard kicks in so
176
+ // tool calls still work, instead of short-circuiting to a single model
177
+ // call.
178
+ seedAgentRegistryForTests([
179
+ makeAgent({
180
+ id: 'customers.account_assistant',
181
+ moduleId: 'customers',
182
+ maxSteps: 0,
183
+ }),
184
+ ])
185
+ await runAiAgentText({
186
+ agentId: 'customers.account_assistant',
187
+ messages: baseMessages as never,
188
+ authContext: baseAuth,
189
+ })
190
+ expect(stepCountIsMock).toHaveBeenCalledWith(10)
191
+ const callArg = streamTextMock.mock.calls[0][0] as { stopWhen: unknown }
192
+ expect(callArg.stopWhen).toEqual({ __stopWhen: 'stepCount', count: 10 })
193
+ })
194
+ })
195
+
196
+ describe('Step 5.16 — runAiAgentObject maxSteps budget parity (integration)', () => {
197
+ const schema = z.object({ summary: z.string() })
198
+
199
+ beforeEach(() => {
200
+ jest.clearAllMocks()
201
+ resetAgentRegistryForTests()
202
+ toolRegistry.clear()
203
+ generateObjectMock.mockImplementation(async () => ({
204
+ object: { summary: 'stub' },
205
+ finishReason: 'stop',
206
+ usage: { inputTokens: 1, outputTokens: 1 },
207
+ }))
208
+ })
209
+ afterAll(() => {
210
+ resetAgentRegistryForTests()
211
+ toolRegistry.clear()
212
+ })
213
+
214
+ it('preserves agent.maxSteps → stopWhen on generateObject (object-mode parity)', async () => {
215
+ seedAgentRegistryForTests([
216
+ makeAgent({
217
+ id: 'catalog.merchandising_assistant',
218
+ moduleId: 'catalog',
219
+ executionMode: 'object',
220
+ output: {
221
+ schemaName: 'MerchandisingProposal',
222
+ schema,
223
+ mode: 'generate',
224
+ } as never,
225
+ maxSteps: 4,
226
+ }),
227
+ ])
228
+ await runAiAgentObject({
229
+ agentId: 'catalog.merchandising_assistant',
230
+ input: 'draft title variants',
231
+ authContext: baseAuth,
232
+ })
233
+ expect(stepCountIsMock).toHaveBeenCalledWith(4)
234
+ // runAiAgentObject augments the generateObject args dynamically — the
235
+ // typed SDK surface ignores stopWhen but we MUST still forward it so
236
+ // providers that honor it behave identically across chat / object.
237
+ const callArg = generateObjectMock.mock.calls[0][0] as { stopWhen?: unknown }
238
+ expect(callArg.stopWhen).toEqual({ __stopWhen: 'stepCount', count: 4 })
239
+ })
240
+
241
+ it('omits stopWhen on generateObject when the agent declares no maxSteps', async () => {
242
+ seedAgentRegistryForTests([
243
+ makeAgent({
244
+ id: 'catalog.merchandising_assistant',
245
+ moduleId: 'catalog',
246
+ executionMode: 'object',
247
+ output: {
248
+ schemaName: 'MerchandisingProposal',
249
+ schema,
250
+ mode: 'generate',
251
+ } as never,
252
+ }),
253
+ ])
254
+ await runAiAgentObject({
255
+ agentId: 'catalog.merchandising_assistant',
256
+ input: 'draft title variants',
257
+ authContext: baseAuth,
258
+ })
259
+ expect(stepCountIsMock).not.toHaveBeenCalled()
260
+ const callArg = generateObjectMock.mock.calls[0][0] as { stopWhen?: unknown }
261
+ expect('stopWhen' in callArg).toBe(false)
262
+ })
263
+ })
@@ -0,0 +1,183 @@
1
+ /**
2
+ * Step 5.16 — Phase 3 WS-D integration tests for the shared AI model
3
+ * factory (Step 5.1).
4
+ *
5
+ * Pins the full four-layer resolution chain against a provider-registry
6
+ * shim that mirrors the real `LlmProviderRegistry.resolveFirstConfigured`
7
+ * contract. The factory is stateless and re-reads env + registry on every
8
+ * `resolveModel` call, so we drive each scenario from the dep-injected
9
+ * `env` + `registry` fields (the Step 5.1 test seam) rather than mutating
10
+ * `process.env` on the shared test run. This keeps the test hermetic and
11
+ * the env cleanup trivial.
12
+ *
13
+ * Scenarios (per Step 5.16 spec):
14
+ * - callerOverride non-empty → wins over env + agent default + provider
15
+ * - env `<MODULE>_AI_MODEL` → wins over agent default + provider default
16
+ * - agentDefaultModel → wins over provider default
17
+ * - provider default → chosen last
18
+ * - no provider registered → throws `AiModelFactoryError`
19
+ * `code: 'no_provider_configured'`
20
+ * - moduleId: undefined → env-override lookup skipped (regression)
21
+ * - empty-string callerOverride → falls through to env, not override
22
+ *
23
+ * Fixture rule: every test constructs its own provider + env shim, so no
24
+ * ordering coupling exists between scenarios.
25
+ */
26
+ import type { AwilixContainer } from 'awilix'
27
+ import {
28
+ AiModelFactoryError,
29
+ createModelFactory,
30
+ type CreateModelFactoryDependencies,
31
+ } from '../model-factory'
32
+
33
+ type FakeProvider = {
34
+ id: string
35
+ defaultModel: string
36
+ resolveApiKey: () => string | null
37
+ createModel: (options: { modelId: string; apiKey: string }) => unknown
38
+ }
39
+
40
+ function makeProvider(overrides: Partial<FakeProvider> = {}): FakeProvider {
41
+ return {
42
+ id: overrides.id ?? 'test-provider',
43
+ defaultModel: overrides.defaultModel ?? 'provider-default-model',
44
+ resolveApiKey: overrides.resolveApiKey ?? (() => 'test-api-key'),
45
+ createModel:
46
+ overrides.createModel ??
47
+ ((options: { modelId: string; apiKey: string }) => ({
48
+ kind: 'fake-model',
49
+ modelId: options.modelId,
50
+ apiKey: options.apiKey,
51
+ })),
52
+ }
53
+ }
54
+
55
+ function makeDeps(
56
+ provider: FakeProvider | null,
57
+ env: Record<string, string | undefined> = {},
58
+ ): CreateModelFactoryDependencies {
59
+ return {
60
+ registry: {
61
+ resolveFirstConfigured: () =>
62
+ provider as unknown as ReturnType<
63
+ NonNullable<CreateModelFactoryDependencies['registry']>['resolveFirstConfigured']
64
+ >,
65
+ },
66
+ env,
67
+ }
68
+ }
69
+
70
+ const fakeContainer = {} as unknown as AwilixContainer
71
+
72
+ describe('Step 5.16 — model factory fallback chain (integration)', () => {
73
+ it('callerOverride wins over env + agentDefaultModel + provider default', () => {
74
+ const provider = makeProvider({ defaultModel: 'provider-default' })
75
+ const env = { INBOX_OPS_AI_MODEL: 'env-pinned' }
76
+ const factory = createModelFactory(fakeContainer, makeDeps(provider, env))
77
+ const resolution = factory.resolveModel({
78
+ moduleId: 'inbox_ops',
79
+ agentDefaultModel: 'agent-pinned',
80
+ callerOverride: 'caller-wins',
81
+ })
82
+ expect(resolution.source).toBe('caller_override')
83
+ expect(resolution.modelId).toBe('caller-wins')
84
+ // Verify the model plumbing received the resolved id (not a later layer).
85
+ expect(resolution.model).toMatchObject({
86
+ kind: 'fake-model',
87
+ modelId: 'caller-wins',
88
+ apiKey: 'test-api-key',
89
+ })
90
+ })
91
+
92
+ it('env <MODULE>_AI_MODEL wins over agentDefaultModel + provider default when moduleId is set', () => {
93
+ const provider = makeProvider({ defaultModel: 'provider-default' })
94
+ const env = { CATALOG_AI_MODEL: 'catalog-env-model' }
95
+ const factory = createModelFactory(fakeContainer, makeDeps(provider, env))
96
+ const resolution = factory.resolveModel({
97
+ moduleId: 'catalog',
98
+ agentDefaultModel: 'agent-pinned',
99
+ })
100
+ expect(resolution.source).toBe('module_env')
101
+ expect(resolution.modelId).toBe('catalog-env-model')
102
+ })
103
+
104
+ it('agentDefaultModel wins over provider default when callerOverride + env are absent', () => {
105
+ const provider = makeProvider({ defaultModel: 'provider-default' })
106
+ const factory = createModelFactory(fakeContainer, makeDeps(provider, {}))
107
+ const resolution = factory.resolveModel({
108
+ moduleId: 'inbox_ops',
109
+ agentDefaultModel: 'agent-pinned',
110
+ })
111
+ expect(resolution.source).toBe('agent_default')
112
+ expect(resolution.modelId).toBe('agent-pinned')
113
+ })
114
+
115
+ it('provider default is chosen last when no other source applies', () => {
116
+ const provider = makeProvider({ defaultModel: 'provider-last-resort' })
117
+ const factory = createModelFactory(fakeContainer, makeDeps(provider, {}))
118
+ const resolution = factory.resolveModel({})
119
+ expect(resolution.source).toBe('provider_default')
120
+ expect(resolution.modelId).toBe('provider-last-resort')
121
+ })
122
+
123
+ it('throws AiModelFactoryError with code "no_provider_configured" when no provider is registered', () => {
124
+ const factory = createModelFactory(fakeContainer, makeDeps(null))
125
+ expect(() => factory.resolveModel({})).toThrow(AiModelFactoryError)
126
+ try {
127
+ factory.resolveModel({})
128
+ fail('expected AiModelFactoryError')
129
+ } catch (err) {
130
+ expect(err).toBeInstanceOf(AiModelFactoryError)
131
+ const typed = err as AiModelFactoryError
132
+ expect(typed.code).toBe('no_provider_configured')
133
+ expect(typed.message).toMatch(/No LLM provider is configured/i)
134
+ }
135
+ })
136
+
137
+ it('moduleId: undefined skips the env-override lookup (regression)', () => {
138
+ // Even if a `<MODULE>_AI_MODEL` env var exists in the environment, the
139
+ // factory does NOT construct a module-scoped env var name when moduleId
140
+ // is undefined — it falls straight through to agentDefaultModel /
141
+ // provider default. This guards against a past bug where `String(undefined)`
142
+ // yielded the literal env name `"UNDEFINED_AI_MODEL"`.
143
+ const provider = makeProvider({ defaultModel: 'provider-default' })
144
+ const env = {
145
+ INBOX_OPS_AI_MODEL: 'should-be-ignored',
146
+ UNDEFINED_AI_MODEL: 'also-ignored',
147
+ }
148
+ const factory = createModelFactory(fakeContainer, makeDeps(provider, env))
149
+ const resolution = factory.resolveModel({
150
+ agentDefaultModel: 'agent-pinned',
151
+ })
152
+ expect(resolution.source).toBe('agent_default')
153
+ expect(resolution.modelId).toBe('agent-pinned')
154
+ })
155
+
156
+ it('empty-string callerOverride falls through to env, not treated as override', () => {
157
+ const provider = makeProvider({ defaultModel: 'provider-default' })
158
+ const env = { INBOX_OPS_AI_MODEL: 'env-pinned' }
159
+ const factory = createModelFactory(fakeContainer, makeDeps(provider, env))
160
+ const resolution = factory.resolveModel({
161
+ moduleId: 'inbox_ops',
162
+ agentDefaultModel: 'agent-pinned',
163
+ callerOverride: '',
164
+ })
165
+ expect(resolution.source).toBe('module_env')
166
+ expect(resolution.modelId).toBe('env-pinned')
167
+ })
168
+
169
+ it('whitespace-only callerOverride is treated the same as empty (falls through)', () => {
170
+ // Defense-in-depth: a caller passing `" "` (e.g. from a UI text input)
171
+ // should not bypass lower-priority layers.
172
+ const provider = makeProvider({ defaultModel: 'provider-default' })
173
+ const env = { INBOX_OPS_AI_MODEL: 'env-pinned' }
174
+ const factory = createModelFactory(fakeContainer, makeDeps(provider, env))
175
+ const resolution = factory.resolveModel({
176
+ moduleId: 'inbox_ops',
177
+ agentDefaultModel: 'agent-pinned',
178
+ callerOverride: ' ',
179
+ })
180
+ expect(resolution.source).toBe('module_env')
181
+ expect(resolution.modelId).toBe('env-pinned')
182
+ })
183
+ })
@@ -0,0 +1,168 @@
1
+ import type { AwilixContainer } from 'awilix'
2
+ import {
3
+ AiModelFactoryError,
4
+ createModelFactory,
5
+ type AiModelFactoryInput,
6
+ type CreateModelFactoryDependencies,
7
+ } from '../model-factory'
8
+
9
+ function makeProvider(overrides: Partial<{
10
+ id: string
11
+ defaultModel: string
12
+ resolveApiKey: () => string | null
13
+ createModel: (options: { modelId: string; apiKey: string }) => unknown
14
+ }> = {}) {
15
+ const createModel =
16
+ overrides.createModel ??
17
+ ((options: { modelId: string; apiKey: string }) => ({
18
+ kind: 'fake-model',
19
+ modelId: options.modelId,
20
+ apiKey: options.apiKey,
21
+ }))
22
+ return {
23
+ id: overrides.id ?? 'test-provider',
24
+ defaultModel: overrides.defaultModel ?? 'provider-default-model',
25
+ resolveApiKey: overrides.resolveApiKey ?? (() => 'test-api-key'),
26
+ createModel,
27
+ }
28
+ }
29
+
30
+ function makeFactoryDeps(
31
+ provider: ReturnType<typeof makeProvider> | null,
32
+ env: Record<string, string | undefined> = {},
33
+ ): CreateModelFactoryDependencies {
34
+ return {
35
+ registry: {
36
+ resolveFirstConfigured: () =>
37
+ provider as unknown as ReturnType<
38
+ NonNullable<CreateModelFactoryDependencies['registry']>['resolveFirstConfigured']
39
+ >,
40
+ },
41
+ env,
42
+ }
43
+ }
44
+
45
+ const fakeContainer = {} as unknown as AwilixContainer
46
+
47
+ describe('createModelFactory', () => {
48
+ it('returns the provider default when no override is supplied', () => {
49
+ const provider = makeProvider()
50
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
51
+ const resolution = factory.resolveModel({})
52
+ expect(resolution.source).toBe('provider_default')
53
+ expect(resolution.modelId).toBe('provider-default-model')
54
+ expect(resolution.providerId).toBe('test-provider')
55
+ expect(resolution.model).toMatchObject({
56
+ kind: 'fake-model',
57
+ modelId: 'provider-default-model',
58
+ apiKey: 'test-api-key',
59
+ })
60
+ })
61
+
62
+ it('prefers agentDefaultModel over provider default', () => {
63
+ const provider = makeProvider()
64
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
65
+ const resolution = factory.resolveModel({ agentDefaultModel: 'agent-pinned-model' })
66
+ expect(resolution.source).toBe('agent_default')
67
+ expect(resolution.modelId).toBe('agent-pinned-model')
68
+ })
69
+
70
+ it('prefers <MODULE>_AI_MODEL env override over agent default', () => {
71
+ const provider = makeProvider()
72
+ const env = { INBOX_OPS_AI_MODEL: 'env-pinned-model' }
73
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
74
+ const resolution = factory.resolveModel({
75
+ moduleId: 'inbox_ops',
76
+ agentDefaultModel: 'agent-pinned-model',
77
+ })
78
+ expect(resolution.source).toBe('module_env')
79
+ expect(resolution.modelId).toBe('env-pinned-model')
80
+ })
81
+
82
+ it('uppercases moduleId when deriving the env var name', () => {
83
+ const provider = makeProvider()
84
+ const env = { INBOX_OPS_AI_MODEL: 'from-env' }
85
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
86
+ const resolution = factory.resolveModel({ moduleId: 'inbox_ops' })
87
+ expect(resolution.modelId).toBe('from-env')
88
+ })
89
+
90
+ it('prefers non-empty callerOverride over every other source', () => {
91
+ const provider = makeProvider()
92
+ const env = { INBOX_OPS_AI_MODEL: 'env-pinned-model' }
93
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
94
+ const resolution = factory.resolveModel({
95
+ moduleId: 'inbox_ops',
96
+ agentDefaultModel: 'agent-pinned-model',
97
+ callerOverride: 'caller-wins',
98
+ })
99
+ expect(resolution.source).toBe('caller_override')
100
+ expect(resolution.modelId).toBe('caller-wins')
101
+ })
102
+
103
+ it('treats empty callerOverride as "no override" and falls through to env', () => {
104
+ const provider = makeProvider()
105
+ const env = { INBOX_OPS_AI_MODEL: 'env-pinned-model' }
106
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
107
+ const resolution = factory.resolveModel({
108
+ moduleId: 'inbox_ops',
109
+ agentDefaultModel: 'agent-pinned-model',
110
+ callerOverride: ' ',
111
+ })
112
+ expect(resolution.source).toBe('module_env')
113
+ expect(resolution.modelId).toBe('env-pinned-model')
114
+ })
115
+
116
+ it('skips env-override lookup when moduleId is undefined (does not crash)', () => {
117
+ const provider = makeProvider()
118
+ // Even if an `_AI_MODEL` var is present, an absent moduleId means no
119
+ // module-scoped env var name can be constructed, so the lookup is skipped.
120
+ const env = { INBOX_OPS_AI_MODEL: 'env-pinned-model' }
121
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
122
+ const resolution = factory.resolveModel({
123
+ agentDefaultModel: 'agent-pinned-model',
124
+ } satisfies AiModelFactoryInput)
125
+ expect(resolution.source).toBe('agent_default')
126
+ expect(resolution.modelId).toBe('agent-pinned-model')
127
+ })
128
+
129
+ it('throws AiModelFactoryError with code "no_provider_configured" when no provider is configured', () => {
130
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(null))
131
+ try {
132
+ factory.resolveModel({})
133
+ fail('expected AiModelFactoryError')
134
+ } catch (err) {
135
+ expect(err).toBeInstanceOf(AiModelFactoryError)
136
+ const typed = err as AiModelFactoryError
137
+ expect(typed.code).toBe('no_provider_configured')
138
+ expect(typed.message).toMatch(/No LLM provider is configured/i)
139
+ }
140
+ })
141
+
142
+ it('throws AiModelFactoryError with code "api_key_missing" when the provider returns no key', () => {
143
+ const provider = makeProvider({ resolveApiKey: () => null })
144
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider))
145
+ try {
146
+ factory.resolveModel({})
147
+ fail('expected AiModelFactoryError')
148
+ } catch (err) {
149
+ expect(err).toBeInstanceOf(AiModelFactoryError)
150
+ expect((err as AiModelFactoryError).code).toBe('api_key_missing')
151
+ }
152
+ })
153
+
154
+ it('passes the resolved modelId and apiKey through to provider.createModel', () => {
155
+ const createModel = jest.fn((options: { modelId: string; apiKey: string }) => ({
156
+ spy: true,
157
+ ...options,
158
+ }))
159
+ const provider = makeProvider({ createModel })
160
+ const env = { CATALOG_AI_MODEL: 'catalog-env-model' }
161
+ const factory = createModelFactory(fakeContainer, makeFactoryDeps(provider, env))
162
+ factory.resolveModel({ moduleId: 'catalog' })
163
+ expect(createModel).toHaveBeenCalledWith({
164
+ modelId: 'catalog-env-model',
165
+ apiKey: 'test-api-key',
166
+ })
167
+ })
168
+ })