@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,1015 @@
1
+ /**
2
+ * Step 5.17 — Phase 3 WS-D integration tests for the pending-action contract.
3
+ *
4
+ * Closes the Step 5.5 → 5.12 surface with a Jest-integration suite that drives
5
+ * the confirm executor (Step 5.8), cancel executor (Step 5.9), cleanup worker
6
+ * (Step 5.12), and the shared re-check orchestrator (Step 5.8) against a
7
+ * repository stub that mirrors the production state-machine guard. Event
8
+ * emissions are asserted against the typed Step 5.11 `emitAiAssistantEvent`
9
+ * contract via per-executor injection seams — no live LLM, no real DB, no
10
+ * real event bus.
11
+ *
12
+ * Mocks sit at narrow boundaries:
13
+ * - ORM: a hand-rolled in-memory `AiPendingActionRepository` shim that honors
14
+ * `AI_PENDING_ACTION_ALLOWED_TRANSITIONS` so illegal edges throw
15
+ * `AiPendingActionStateError`, just like the real repo.
16
+ * - Event bus: the `emitEvent` seam already present on every executor; we
17
+ * assert the event id + payload shape directly.
18
+ *
19
+ * The pending-action executor, cancel helper, re-check orchestrator, and
20
+ * cleanup worker themselves are under test and MUST NOT be mocked.
21
+ */
22
+ import { z } from 'zod'
23
+ import type { AwilixContainer } from 'awilix'
24
+ import type { AiPendingAction } from '../../data/entities'
25
+ import type { AiAgentDefinition } from '../../lib/ai-agent-definition'
26
+ import type {
27
+ AiPendingActionExecutionResult,
28
+ AiPendingActionFailedRecord,
29
+ AiPendingActionRecordDiff,
30
+ AiPendingActionStatus,
31
+ } from '../../lib/pending-action-types'
32
+ import {
33
+ AI_PENDING_ACTION_ALLOWED_TRANSITIONS,
34
+ AiPendingActionStateError,
35
+ } from '../../lib/pending-action-types'
36
+ import type { AiToolDefinition, McpToolContext } from '../../lib/types'
37
+ import {
38
+ executePendingActionConfirm,
39
+ PENDING_ACTION_CONFIRMED_EVENT_ID,
40
+ } from '../../lib/pending-action-executor'
41
+ import {
42
+ executePendingActionCancel,
43
+ PENDING_ACTION_CANCELLED_EVENT_ID,
44
+ PENDING_ACTION_EXPIRED_EVENT_ID,
45
+ } from '../../lib/pending-action-cancel'
46
+ import { runPendingActionRechecks } from '../../lib/pending-action-recheck'
47
+ import { runPendingActionCleanup } from '../../workers/ai-pending-action-cleanup'
48
+ import type {
49
+ AiActionCancelledPayload,
50
+ AiActionConfirmedPayload,
51
+ AiActionExpiredPayload,
52
+ } from '../../events'
53
+ import { resolveEffectiveMutationPolicy } from '../../lib/agent-policy'
54
+
55
+ // The recheck layer dynamic-imports the core Attachment entity for the
56
+ // cross-tenant attachment guard. The core dist build is shipped as ESM and
57
+ // ts-jest does not transform it, so we replace the module with a minimal
58
+ // mock that gives the recheck a stable class reference.
59
+ jest.mock(
60
+ '@open-mercato/core/modules/attachments/data/entities',
61
+ () => ({ Attachment: class MockAttachment {} }),
62
+ { virtual: true },
63
+ )
64
+
65
+ // findWithDecryption is used by the recheck's attachment scope guard. The
66
+ // integration mock returns an attachment row from a foreign tenant so the
67
+ // guard's cross-tenant assertion fires without a real DB.
68
+ jest.mock('@open-mercato/shared/lib/encryption/find', () => {
69
+ const actual = jest.requireActual('@open-mercato/shared/lib/encryption/find')
70
+ return {
71
+ ...actual,
72
+ findWithDecryption: jest.fn(
73
+ async (_em: unknown, _entity: unknown, where: { id?: { $in: string[] } }) => {
74
+ const ids = where?.id?.$in ?? []
75
+ return ids.map((id: string) => ({
76
+ id,
77
+ tenantId: 'tenant-other',
78
+ organizationId: null,
79
+ }))
80
+ },
81
+ ),
82
+ }
83
+ })
84
+
85
+ // --- Fixtures -------------------------------------------------------------
86
+
87
+ type Row = AiPendingAction & Record<string, unknown>
88
+
89
+ interface ActionSeed {
90
+ id?: string
91
+ tenantId?: string
92
+ organizationId?: string | null
93
+ status?: AiPendingActionStatus
94
+ agentId?: string
95
+ toolName?: string
96
+ expiresAt?: Date
97
+ recordVersion?: string | null
98
+ records?: AiPendingActionRecordDiff[] | null
99
+ attachmentIds?: string[]
100
+ executionResult?: AiPendingActionExecutionResult | null
101
+ }
102
+
103
+ const REFERENCE_CLOCK = new Date('2026-04-18T10:00:00.000Z')
104
+
105
+ function makeSeed(seed: ActionSeed = {}): Row {
106
+ return {
107
+ id: seed.id ?? 'pa_1',
108
+ tenantId: seed.tenantId ?? 'tenant-a',
109
+ organizationId: seed.organizationId === undefined ? 'org-a' : seed.organizationId,
110
+ agentId: seed.agentId ?? 'catalog.merchandising_assistant',
111
+ toolName: seed.toolName ?? 'catalog.update_product',
112
+ status: (seed.status ?? 'pending') as AiPendingActionStatus,
113
+ fieldDiff: [],
114
+ records: seed.records ?? null,
115
+ failedRecords: null,
116
+ sideEffectsSummary: null,
117
+ recordVersion: seed.recordVersion === undefined ? 'v-1' : seed.recordVersion,
118
+ attachmentIds: seed.attachmentIds ?? [],
119
+ normalizedInput: { productId: 'p-1', patch: { title: 'New' } },
120
+ queueMode: 'inline',
121
+ executionResult: seed.executionResult ?? null,
122
+ targetEntityType: 'product',
123
+ targetRecordId: 'p-1',
124
+ conversationId: null,
125
+ idempotencyKey: `idem_${seed.id ?? 'pa_1'}`,
126
+ createdByUserId: 'user-a',
127
+ createdAt: new Date('2026-04-18T09:00:00.000Z'),
128
+ expiresAt: seed.expiresAt ?? new Date('2026-04-18T11:00:00.000Z'),
129
+ resolvedAt: null,
130
+ resolvedByUserId: null,
131
+ } as unknown as Row
132
+ }
133
+
134
+ function makeAgent(
135
+ overrides: Partial<AiAgentDefinition> = {},
136
+ ): AiAgentDefinition {
137
+ return {
138
+ id: 'catalog.merchandising_assistant',
139
+ moduleId: 'catalog',
140
+ label: 'Catalog Merchandising Assistant',
141
+ description: 'Updates product titles, descriptions, media, prices.',
142
+ systemPrompt: 'System',
143
+ allowedTools: ['catalog.update_product'],
144
+ readOnly: false,
145
+ mutationPolicy: 'confirm-required',
146
+ requiredFeatures: [],
147
+ ...overrides,
148
+ } as AiAgentDefinition
149
+ }
150
+
151
+ function makeTool(
152
+ overrides: Partial<AiToolDefinition> = {},
153
+ ): AiToolDefinition {
154
+ return {
155
+ name: 'catalog.update_product',
156
+ description: 'Update product',
157
+ inputSchema: z.object({
158
+ productId: z.string(),
159
+ patch: z.object({}).passthrough(),
160
+ }),
161
+ handler: async () => ({
162
+ recordId: 'p-1',
163
+ commandName: 'catalog.product.update',
164
+ }),
165
+ isMutation: true,
166
+ ...overrides,
167
+ } as AiToolDefinition
168
+ }
169
+
170
+ function makeContainer(): AwilixContainer {
171
+ return {
172
+ resolve: (name: string) => {
173
+ if (name === 'em') return {}
174
+ throw new Error(`unexpected dep: ${name}`)
175
+ },
176
+ } as unknown as AwilixContainer
177
+ }
178
+
179
+ function makeExecCtx(overrides: Partial<{
180
+ tenantId: string
181
+ organizationId: string | null
182
+ userId: string
183
+ userFeatures: string[]
184
+ isSuperAdmin: boolean
185
+ }> = {}) {
186
+ return {
187
+ tenantId: overrides.tenantId ?? 'tenant-a',
188
+ organizationId:
189
+ overrides.organizationId === undefined ? 'org-a' : overrides.organizationId,
190
+ userId: overrides.userId ?? 'user-a',
191
+ userFeatures: overrides.userFeatures ?? ['ai_assistant.view'],
192
+ isSuperAdmin: overrides.isSuperAdmin ?? false,
193
+ container: makeContainer(),
194
+ }
195
+ }
196
+
197
+ function makeCancelCtx(overrides: Partial<{
198
+ tenantId: string
199
+ organizationId: string | null
200
+ userId: string
201
+ }> = {}) {
202
+ return {
203
+ tenantId: overrides.tenantId ?? 'tenant-a',
204
+ organizationId:
205
+ overrides.organizationId === undefined ? 'org-a' : overrides.organizationId,
206
+ userId: overrides.userId ?? 'user-a',
207
+ container: makeContainer(),
208
+ }
209
+ }
210
+
211
+ function makeAuthCtx(overrides: Partial<{
212
+ tenantId: string
213
+ organizationId: string | null
214
+ userId: string
215
+ userFeatures: string[]
216
+ isSuperAdmin: boolean
217
+ }> = {}) {
218
+ return {
219
+ tenantId: overrides.tenantId ?? 'tenant-a',
220
+ organizationId:
221
+ overrides.organizationId === undefined ? 'org-a' : overrides.organizationId,
222
+ userId: overrides.userId ?? 'user-a',
223
+ userFeatures: overrides.userFeatures ?? ['ai_assistant.view'],
224
+ isSuperAdmin: overrides.isSuperAdmin ?? false,
225
+ }
226
+ }
227
+
228
+ // --- In-memory repo that mirrors the production state-machine ---------------
229
+
230
+ interface RepoStubOptions {
231
+ seeds: Row[]
232
+ }
233
+
234
+ interface ScopeFilter {
235
+ tenantId: string
236
+ organizationId?: string | null
237
+ }
238
+
239
+ function matchesScope(row: Row, scope: ScopeFilter): boolean {
240
+ if (row.tenantId !== scope.tenantId) return false
241
+ const expectedOrg = scope.organizationId ?? null
242
+ if ((row.organizationId ?? null) !== expectedOrg) return false
243
+ return true
244
+ }
245
+
246
+ function makeRepoStub(options: RepoStubOptions) {
247
+ const store = new Map<string, Row>()
248
+ for (const row of options.seeds) {
249
+ store.set(row.id as string, { ...row })
250
+ }
251
+
252
+ const getById = jest.fn(async (id: string, scope: ScopeFilter) => {
253
+ const row = store.get(id)
254
+ if (!row) return null
255
+ if (!matchesScope(row, scope)) return null
256
+ return row
257
+ })
258
+
259
+ const setStatus = jest.fn(
260
+ async (
261
+ id: string,
262
+ next: AiPendingActionStatus,
263
+ scope: ScopeFilter,
264
+ extra?: {
265
+ now?: Date
266
+ resolvedByUserId?: string | null
267
+ executionResult?: AiPendingActionExecutionResult | null
268
+ failedRecords?: AiPendingActionFailedRecord[] | null
269
+ },
270
+ ) => {
271
+ const existing = store.get(id)
272
+ if (!existing || !matchesScope(existing, scope)) {
273
+ throw new Error(`row ${id} not found`)
274
+ }
275
+ if (existing.status === next) return existing
276
+ const allowed = AI_PENDING_ACTION_ALLOWED_TRANSITIONS[existing.status] ?? []
277
+ if (!allowed.includes(next)) {
278
+ throw new AiPendingActionStateError(existing.status, next)
279
+ }
280
+ const now = extra?.now ?? new Date()
281
+ existing.status = next
282
+ if (
283
+ next === 'confirmed' ||
284
+ next === 'cancelled' ||
285
+ next === 'expired' ||
286
+ next === 'failed'
287
+ ) {
288
+ existing.resolvedAt = (existing.resolvedAt ?? now) as never
289
+ if (extra && Object.prototype.hasOwnProperty.call(extra, 'resolvedByUserId')) {
290
+ existing.resolvedByUserId = (extra.resolvedByUserId ?? null) as never
291
+ } else if (next === 'expired') {
292
+ existing.resolvedByUserId = null as never
293
+ }
294
+ }
295
+ if (extra && Object.prototype.hasOwnProperty.call(extra, 'executionResult')) {
296
+ existing.executionResult = (extra.executionResult ?? null) as never
297
+ }
298
+ if (extra && Object.prototype.hasOwnProperty.call(extra, 'failedRecords')) {
299
+ existing.failedRecords = (extra.failedRecords ?? null) as never
300
+ }
301
+ return existing
302
+ },
303
+ )
304
+
305
+ const listExpired = jest.fn(
306
+ async (scope: ScopeFilter, now: Date, limit: number) => {
307
+ return Array.from(store.values())
308
+ .filter((row) => matchesScope(row, scope))
309
+ .filter((row) => row.status === 'pending')
310
+ .filter((row) => (row.expiresAt as Date).getTime() < now.getTime())
311
+ .sort((a, b) => (a.expiresAt as Date).getTime() - (b.expiresAt as Date).getTime())
312
+ .slice(0, limit)
313
+ },
314
+ )
315
+
316
+ return {
317
+ repo: {
318
+ getById,
319
+ setStatus,
320
+ listExpired,
321
+ } as unknown as import('../../data/repositories/AiPendingActionRepository').AiPendingActionRepository,
322
+ getById,
323
+ setStatus,
324
+ listExpired,
325
+ store,
326
+ }
327
+ }
328
+
329
+ // --- Suite ------------------------------------------------------------------
330
+
331
+ describe('Pending-action contract integration (Step 5.17)', () => {
332
+ beforeEach(() => {
333
+ jest.clearAllMocks()
334
+ })
335
+
336
+ // Scenario 1 --------------------------------------------------------------
337
+ it('scenario-1 happy path: pending → executing → confirmed with executionResult.recordId, single ai.action.confirmed', async () => {
338
+ const seed = makeSeed()
339
+ const { repo, setStatus, store } = makeRepoStub({ seeds: [seed] })
340
+ const emit = jest.fn().mockResolvedValue(undefined)
341
+
342
+ const result = await executePendingActionConfirm({
343
+ action: store.get('pa_1')!,
344
+ agent: makeAgent(),
345
+ tool: makeTool(),
346
+ ctx: makeExecCtx(),
347
+ repo,
348
+ emitEvent: emit,
349
+ now: REFERENCE_CLOCK,
350
+ })
351
+
352
+ expect(result.ok).toBe(true)
353
+ expect(result.executionResult).toEqual({
354
+ recordId: 'p-1',
355
+ commandName: 'catalog.product.update',
356
+ })
357
+ const transitions = setStatus.mock.calls.map((call) => call[1])
358
+ expect(transitions).toEqual(['confirmed', 'executing', 'confirmed'])
359
+ expect(store.get('pa_1')!.status).toBe('confirmed')
360
+ expect(store.get('pa_1')!.resolvedAt).toBeTruthy()
361
+ expect(store.get('pa_1')!.resolvedByUserId).toBe('user-a')
362
+
363
+ expect(emit).toHaveBeenCalledTimes(1)
364
+ const [emittedId, emittedPayload] = emit.mock.calls[0] as [
365
+ 'ai.action.confirmed',
366
+ AiActionConfirmedPayload,
367
+ ]
368
+ expect(emittedId).toBe(PENDING_ACTION_CONFIRMED_EVENT_ID)
369
+ expect(emittedPayload).toMatchObject({
370
+ pendingActionId: 'pa_1',
371
+ agentId: 'catalog.merchandising_assistant',
372
+ toolName: 'catalog.update_product',
373
+ status: 'confirmed',
374
+ tenantId: 'tenant-a',
375
+ organizationId: 'org-a',
376
+ userId: 'user-a',
377
+ resolvedByUserId: 'user-a',
378
+ executionResult: {
379
+ recordId: 'p-1',
380
+ commandName: 'catalog.product.update',
381
+ },
382
+ })
383
+ expect(typeof emittedPayload.resolvedAt).toBe('string')
384
+ })
385
+
386
+ // Scenario 2 --------------------------------------------------------------
387
+ it('scenario-2 cancel: pending → cancelled with reason; executionResult.error.code=cancelled_by_user, one ai.action.cancelled', async () => {
388
+ const seed = makeSeed()
389
+ const { repo, store } = makeRepoStub({ seeds: [seed] })
390
+ const emit = jest.fn().mockResolvedValue(undefined)
391
+
392
+ const result = await executePendingActionCancel({
393
+ action: store.get('pa_1')!,
394
+ ctx: makeCancelCtx(),
395
+ reason: 'Operator aborted',
396
+ repo,
397
+ emitEvent: emit,
398
+ now: REFERENCE_CLOCK,
399
+ })
400
+
401
+ expect(result.status).toBe('cancelled')
402
+ expect(result.row.status).toBe('cancelled')
403
+ expect(store.get('pa_1')!.status).toBe('cancelled')
404
+ expect(store.get('pa_1')!.resolvedByUserId).toBe('user-a')
405
+ expect(store.get('pa_1')!.resolvedAt).toBeTruthy()
406
+ expect(store.get('pa_1')!.executionResult).toMatchObject({
407
+ error: { code: 'cancelled_by_user', message: 'Operator aborted' },
408
+ })
409
+
410
+ expect(emit).toHaveBeenCalledTimes(1)
411
+ const [emittedId, emittedPayload] = emit.mock.calls[0] as [
412
+ 'ai.action.cancelled',
413
+ AiActionCancelledPayload,
414
+ ]
415
+ expect(emittedId).toBe(PENDING_ACTION_CANCELLED_EVENT_ID)
416
+ expect(emittedPayload).toMatchObject({
417
+ pendingActionId: 'pa_1',
418
+ status: 'cancelled',
419
+ resolvedByUserId: 'user-a',
420
+ reason: 'Operator aborted',
421
+ })
422
+ })
423
+
424
+ // Scenario 3 --------------------------------------------------------------
425
+ it('scenario-3 expiry via cleanup worker: pending (past expiresAt) → expired, worker emits ai.action.expired, resolvedByUserId=null', async () => {
426
+ const past = new Date(REFERENCE_CLOCK.getTime() - 60 * 60 * 1000)
427
+ const seed = makeSeed({ expiresAt: past })
428
+ const { repo, listExpired, setStatus, store } = makeRepoStub({ seeds: [seed] })
429
+ const emit = jest.fn().mockResolvedValue(undefined)
430
+
431
+ const summary = await runPendingActionCleanup({
432
+ em: {} as never,
433
+ repo,
434
+ emitEvent: emit as never,
435
+ now: REFERENCE_CLOCK,
436
+ discoverTenants: async () => [
437
+ { tenantId: 'tenant-a', organizationId: 'org-a' },
438
+ ],
439
+ })
440
+
441
+ expect(summary.rowsExpired).toBe(1)
442
+ expect(summary.rowsSkipped).toBe(0)
443
+ expect(summary.rowsErrored).toBe(0)
444
+ expect(listExpired).toHaveBeenCalled()
445
+ expect(setStatus).toHaveBeenCalledWith(
446
+ 'pa_1',
447
+ 'expired',
448
+ expect.objectContaining({ tenantId: 'tenant-a', organizationId: 'org-a' }),
449
+ expect.objectContaining({ resolvedByUserId: null }),
450
+ )
451
+ expect(store.get('pa_1')!.status).toBe('expired')
452
+ expect(store.get('pa_1')!.resolvedByUserId).toBeNull()
453
+
454
+ expect(emit).toHaveBeenCalledTimes(1)
455
+ const [emittedId, emittedPayload] = emit.mock.calls[0] as [
456
+ 'ai.action.expired',
457
+ AiActionExpiredPayload,
458
+ ]
459
+ expect(emittedId).toBe(PENDING_ACTION_EXPIRED_EVENT_ID)
460
+ expect(emittedPayload).toMatchObject({
461
+ pendingActionId: 'pa_1',
462
+ status: 'expired',
463
+ resolvedByUserId: null,
464
+ tenantId: 'tenant-a',
465
+ organizationId: 'org-a',
466
+ })
467
+ expect(typeof emittedPayload.resolvedAt).toBe('string')
468
+ expect(typeof emittedPayload.expiresAt).toBe('string')
469
+ })
470
+
471
+ // Scenario 4 --------------------------------------------------------------
472
+ it('scenario-4 expiry via opportunistic cancel path: past expiresAt flips pending → expired atomically and emits ai.action.expired', async () => {
473
+ const past = new Date(REFERENCE_CLOCK.getTime() - 60 * 60 * 1000)
474
+ const seed = makeSeed({ expiresAt: past })
475
+ const { repo, store } = makeRepoStub({ seeds: [seed] })
476
+ const emit = jest.fn().mockResolvedValue(undefined)
477
+
478
+ const result = await executePendingActionCancel({
479
+ action: store.get('pa_1')!,
480
+ ctx: makeCancelCtx(),
481
+ repo,
482
+ emitEvent: emit,
483
+ now: REFERENCE_CLOCK,
484
+ })
485
+
486
+ expect(result.status).toBe('expired')
487
+ expect(store.get('pa_1')!.status).toBe('expired')
488
+ expect(emit).toHaveBeenCalledTimes(1)
489
+ const [emittedId, emittedPayload] = emit.mock.calls[0] as [
490
+ 'ai.action.expired',
491
+ AiActionExpiredPayload,
492
+ ]
493
+ expect(emittedId).toBe(PENDING_ACTION_EXPIRED_EVENT_ID)
494
+ expect(emittedPayload.pendingActionId).toBe('pa_1')
495
+ expect(emittedPayload.resolvedByUserId).toBeNull()
496
+ })
497
+
498
+ // Scenario 5 --------------------------------------------------------------
499
+ it('scenario-5 stale-version single-record: re-check returns 412, row stays pending, no event emitted', async () => {
500
+ const seed = makeSeed({ recordVersion: 'v1' })
501
+ const { repo, store, setStatus } = makeRepoStub({ seeds: [seed] })
502
+ const emit = jest.fn().mockResolvedValue(undefined)
503
+
504
+ const staleTool = makeTool({
505
+ loadBeforeRecord: async () => ({
506
+ recordId: 'p-1',
507
+ entityType: 'product',
508
+ recordVersion: 'v2',
509
+ before: {},
510
+ }),
511
+ })
512
+
513
+ const recheck = await runPendingActionRechecks({
514
+ action: store.get('pa_1')!,
515
+ agent: makeAgent(),
516
+ tool: staleTool,
517
+ ctx: makeAuthCtx(),
518
+ now: REFERENCE_CLOCK,
519
+ })
520
+ expect(recheck.ok).toBe(false)
521
+ if (!recheck.ok) {
522
+ expect(recheck.status).toBe(412)
523
+ expect(recheck.code).toBe('stale_version')
524
+ }
525
+
526
+ expect(setStatus).not.toHaveBeenCalled()
527
+ expect(store.get('pa_1')!.status).toBe('pending')
528
+ expect(emit).not.toHaveBeenCalled()
529
+ })
530
+
531
+ // Scenario 6 --------------------------------------------------------------
532
+ it('scenario-6 stale-version batch partial: two rows live, one stale → failedRecords[] captured and confirm proceeds for survivors', async () => {
533
+ const records: AiPendingActionRecordDiff[] = [
534
+ {
535
+ recordId: 'r-1',
536
+ entityType: 'product',
537
+ label: 'Row 1',
538
+ fieldDiff: [],
539
+ recordVersion: 'v1',
540
+ },
541
+ {
542
+ recordId: 'r-2',
543
+ entityType: 'product',
544
+ label: 'Row 2',
545
+ fieldDiff: [],
546
+ recordVersion: 'v1',
547
+ },
548
+ {
549
+ recordId: 'r-3',
550
+ entityType: 'product',
551
+ label: 'Row 3',
552
+ fieldDiff: [],
553
+ recordVersion: 'v1',
554
+ },
555
+ ]
556
+ const seed = makeSeed({ records, recordVersion: null })
557
+ const { repo, store, setStatus } = makeRepoStub({ seeds: [seed] })
558
+ const emit = jest.fn().mockResolvedValue(undefined)
559
+
560
+ const bulkTool = makeTool({
561
+ name: 'catalog.bulk_update_products',
562
+ isBulk: true,
563
+ inputSchema: z.object({}).passthrough(),
564
+ loadBeforeRecords: async () => [
565
+ { recordId: 'r-1', entityType: 'product', label: 'Row 1', recordVersion: 'v1', before: {} },
566
+ { recordId: 'r-2', entityType: 'product', label: 'Row 2', recordVersion: 'v2', before: {} },
567
+ { recordId: 'r-3', entityType: 'product', label: 'Row 3', recordVersion: 'v1', before: {} },
568
+ ],
569
+ handler: async () => ({
570
+ recordId: 'batch-p',
571
+ commandName: 'catalog.bulk_update_products',
572
+ }),
573
+ })
574
+ const batchAgent = makeAgent({ allowedTools: ['catalog.bulk_update_products'] })
575
+
576
+ const recheck = await runPendingActionRechecks({
577
+ action: store.get('pa_1')!,
578
+ agent: batchAgent,
579
+ tool: bulkTool,
580
+ ctx: makeAuthCtx(),
581
+ now: REFERENCE_CLOCK,
582
+ })
583
+ expect(recheck.ok).toBe(true)
584
+ if (recheck.ok) {
585
+ expect(recheck.failedRecords).toHaveLength(1)
586
+ expect(recheck.failedRecords?.[0]).toMatchObject({
587
+ recordId: 'r-2',
588
+ error: { code: 'stale_version' },
589
+ })
590
+ }
591
+
592
+ const executed = await executePendingActionConfirm({
593
+ action: store.get('pa_1')!,
594
+ agent: batchAgent,
595
+ tool: bulkTool,
596
+ ctx: makeExecCtx(),
597
+ repo,
598
+ emitEvent: emit,
599
+ failedRecords: recheck.ok ? recheck.failedRecords ?? null : null,
600
+ now: REFERENCE_CLOCK,
601
+ })
602
+
603
+ expect(executed.ok).toBe(true)
604
+ expect(store.get('pa_1')!.status).toBe('confirmed')
605
+ expect(store.get('pa_1')!.failedRecords).toEqual([
606
+ {
607
+ recordId: 'r-2',
608
+ error: { code: 'stale_version', message: expect.any(String) },
609
+ },
610
+ ])
611
+ // First transition must carry the failedRecords onto the row.
612
+ const firstExtra = setStatus.mock.calls[0][3]
613
+ expect(firstExtra).toMatchObject({
614
+ failedRecords: [{ recordId: 'r-2', error: { code: 'stale_version' } }],
615
+ })
616
+ expect(emit).toHaveBeenCalledTimes(1)
617
+ const [, payload] = emit.mock.calls[0] as [
618
+ 'ai.action.confirmed',
619
+ AiActionConfirmedPayload,
620
+ ]
621
+ expect(payload.executionResult).toMatchObject({ recordId: 'batch-p' })
622
+ })
623
+
624
+ // Scenario 7 --------------------------------------------------------------
625
+ it('scenario-7 stale-version batch all: every record stale → 412 stale_version, row stays pending', async () => {
626
+ const records: AiPendingActionRecordDiff[] = [
627
+ { recordId: 'r-1', entityType: 'product', label: 'Row 1', fieldDiff: [], recordVersion: 'v1' },
628
+ { recordId: 'r-2', entityType: 'product', label: 'Row 2', fieldDiff: [], recordVersion: 'v1' },
629
+ ]
630
+ const seed = makeSeed({ records, recordVersion: null })
631
+ const { store, setStatus } = makeRepoStub({ seeds: [seed] })
632
+
633
+ const bulkTool = makeTool({
634
+ name: 'catalog.bulk_update_products',
635
+ isBulk: true,
636
+ inputSchema: z.object({}).passthrough(),
637
+ loadBeforeRecords: async () => [
638
+ { recordId: 'r-1', entityType: 'product', label: 'Row 1', recordVersion: 'v9', before: {} },
639
+ { recordId: 'r-2', entityType: 'product', label: 'Row 2', recordVersion: 'v9', before: {} },
640
+ ],
641
+ })
642
+ const batchAgent = makeAgent({ allowedTools: ['catalog.bulk_update_products'] })
643
+
644
+ const recheck = await runPendingActionRechecks({
645
+ action: store.get('pa_1')!,
646
+ agent: batchAgent,
647
+ tool: bulkTool,
648
+ ctx: makeAuthCtx(),
649
+ now: REFERENCE_CLOCK,
650
+ })
651
+
652
+ expect(recheck.ok).toBe(false)
653
+ if (!recheck.ok) {
654
+ expect(recheck.status).toBe(412)
655
+ expect(recheck.code).toBe('stale_version')
656
+ expect(recheck.extra).toMatchObject({ staleRecords: ['r-1', 'r-2'] })
657
+ }
658
+ expect(setStatus).not.toHaveBeenCalled()
659
+ expect(store.get('pa_1')!.status).toBe('pending')
660
+ })
661
+
662
+ // Scenario 8 --------------------------------------------------------------
663
+ it('scenario-8 cross-tenant: tenant B cannot read tenant A row (returns null / never found)', async () => {
664
+ const seed = makeSeed({ tenantId: 'tenant-a', organizationId: 'org-a' })
665
+ const { repo, store } = makeRepoStub({ seeds: [seed] })
666
+
667
+ const rowAsA = await repo.getById('pa_1', {
668
+ tenantId: 'tenant-a',
669
+ organizationId: 'org-a',
670
+ })
671
+ const rowAsB = await repo.getById('pa_1', {
672
+ tenantId: 'tenant-b',
673
+ organizationId: 'org-b',
674
+ })
675
+
676
+ expect(rowAsA).toBeTruthy()
677
+ expect(rowAsB).toBeNull()
678
+ // The route returns 404 pending_action_not_found on null, and the row is
679
+ // never mutated by a cross-tenant caller.
680
+ expect(store.get('pa_1')!.status).toBe('pending')
681
+ })
682
+
683
+ // Scenario 9 --------------------------------------------------------------
684
+ it('scenario-9 idempotent double-confirm: second confirm returns prior executionResult without re-invoking handler or re-emitting event', async () => {
685
+ const seed = makeSeed()
686
+ const { repo, store } = makeRepoStub({ seeds: [seed] })
687
+ const emit = jest.fn().mockResolvedValue(undefined)
688
+ const handler = jest.fn().mockResolvedValue({
689
+ recordId: 'p-1',
690
+ commandName: 'catalog.product.update',
691
+ })
692
+ const tool = makeTool({ handler })
693
+
694
+ const first = await executePendingActionConfirm({
695
+ action: store.get('pa_1')!,
696
+ agent: makeAgent(),
697
+ tool,
698
+ ctx: makeExecCtx(),
699
+ repo,
700
+ emitEvent: emit,
701
+ now: REFERENCE_CLOCK,
702
+ })
703
+ expect(first.ok).toBe(true)
704
+ expect(handler).toHaveBeenCalledTimes(1)
705
+ expect(emit).toHaveBeenCalledTimes(1)
706
+
707
+ const emitAfterFirst = emit.mock.calls.length
708
+ const handlerAfterFirst = handler.mock.calls.length
709
+
710
+ const second = await executePendingActionConfirm({
711
+ action: store.get('pa_1')!,
712
+ agent: makeAgent(),
713
+ tool,
714
+ ctx: makeExecCtx(),
715
+ repo,
716
+ emitEvent: emit,
717
+ now: REFERENCE_CLOCK,
718
+ })
719
+
720
+ expect(second.ok).toBe(true)
721
+ expect(second.executionResult).toEqual(first.executionResult)
722
+ expect(handler.mock.calls.length).toBe(handlerAfterFirst)
723
+ expect(emit.mock.calls.length).toBe(emitAfterFirst)
724
+ })
725
+
726
+ // Scenario 10 -------------------------------------------------------------
727
+ it('scenario-10 idempotent double-cancel: second cancel returns same result without re-emitting event', async () => {
728
+ const seed = makeSeed()
729
+ const { repo, store } = makeRepoStub({ seeds: [seed] })
730
+ const emit = jest.fn().mockResolvedValue(undefined)
731
+
732
+ const first = await executePendingActionCancel({
733
+ action: store.get('pa_1')!,
734
+ ctx: makeCancelCtx(),
735
+ reason: 'nope',
736
+ repo,
737
+ emitEvent: emit,
738
+ now: REFERENCE_CLOCK,
739
+ })
740
+ expect(first.status).toBe('cancelled')
741
+ expect(emit).toHaveBeenCalledTimes(1)
742
+
743
+ const second = await executePendingActionCancel({
744
+ action: store.get('pa_1')!,
745
+ ctx: makeCancelCtx(),
746
+ repo,
747
+ emitEvent: emit,
748
+ now: REFERENCE_CLOCK,
749
+ })
750
+ expect(second.status).toBe('cancelled')
751
+ expect(emit).toHaveBeenCalledTimes(1)
752
+ })
753
+
754
+ // Scenario 11 -------------------------------------------------------------
755
+ it('scenario-11 read-only-agent refusal: effective mutationPolicy=read-only → recheck returns 403 read_only_agent, row stays pending', async () => {
756
+ const seed = makeSeed()
757
+ const { store, setStatus } = makeRepoStub({ seeds: [seed] })
758
+
759
+ // Sanity: policy resolver agrees the override collapses to read-only.
760
+ const effective = resolveEffectiveMutationPolicy(
761
+ 'confirm-required',
762
+ 'read-only',
763
+ 'catalog.merchandising_assistant',
764
+ )
765
+ expect(effective).toBe('read-only')
766
+
767
+ const recheck = await runPendingActionRechecks({
768
+ action: store.get('pa_1')!,
769
+ agent: makeAgent({ mutationPolicy: 'confirm-required' }),
770
+ tool: makeTool(),
771
+ ctx: makeAuthCtx(),
772
+ now: REFERENCE_CLOCK,
773
+ mutationPolicyOverride: 'read-only',
774
+ })
775
+
776
+ expect(recheck.ok).toBe(false)
777
+ if (!recheck.ok) {
778
+ expect(recheck.status).toBe(403)
779
+ expect(recheck.code).toBe('read_only_agent')
780
+ }
781
+ expect(setStatus).not.toHaveBeenCalled()
782
+ expect(store.get('pa_1')!.status).toBe('pending')
783
+ })
784
+
785
+ // Scenario 12 -------------------------------------------------------------
786
+ it('scenario-12 prompt-override escalation refusal: overrides are additive-only, widen attempt is refused at confirm-time', async () => {
787
+ // The prompt-override merge layer (Step 5.3) is additive; it cannot grant
788
+ // the agent more mutation surface than its code declares. We prove the
789
+ // guarantee at the confirm layer by showing a read-only code declaration
790
+ // stays read-only regardless of the tenant override, and by showing
791
+ // `isMutationPolicyEscalation` would reject the escalation upstream.
792
+ const readOnlyAgent = makeAgent({
793
+ mutationPolicy: 'read-only',
794
+ allowedTools: ['catalog.update_product'],
795
+ })
796
+ const effective = resolveEffectiveMutationPolicy(
797
+ 'read-only',
798
+ 'confirm-required',
799
+ readOnlyAgent.id,
800
+ )
801
+ // Overrides never WIDEN — only narrow. Code-declared read-only sticks.
802
+ expect(effective).toBe('read-only')
803
+
804
+ const seed = makeSeed()
805
+ const { store, setStatus } = makeRepoStub({ seeds: [seed] })
806
+
807
+ const recheck = await runPendingActionRechecks({
808
+ action: store.get('pa_1')!,
809
+ agent: readOnlyAgent,
810
+ tool: makeTool(),
811
+ ctx: makeAuthCtx(),
812
+ now: REFERENCE_CLOCK,
813
+ // Even when the DB carries the "escalated" override, the resolver
814
+ // clamps it back to read-only, and the re-check returns 403.
815
+ mutationPolicyOverride: 'confirm-required',
816
+ })
817
+ expect(recheck.ok).toBe(false)
818
+ if (!recheck.ok) {
819
+ expect(recheck.status).toBe(403)
820
+ expect(recheck.code).toBe('read_only_agent')
821
+ }
822
+ expect(setStatus).not.toHaveBeenCalled()
823
+ expect(store.get('pa_1')!.status).toBe('pending')
824
+ })
825
+
826
+ // Scenario 13 -------------------------------------------------------------
827
+ it('scenario-13 reconnect: GET path re-hydrates the row by id between propose and confirm, then confirm proceeds normally', async () => {
828
+ const seed = makeSeed()
829
+ const { repo, store } = makeRepoStub({ seeds: [seed] })
830
+ const emit = jest.fn().mockResolvedValue(undefined)
831
+
832
+ // Simulate the client (mutation-preview-card) polling /actions/:id.
833
+ const reconnectRead = await repo.getById('pa_1', {
834
+ tenantId: 'tenant-a',
835
+ organizationId: 'org-a',
836
+ })
837
+ expect(reconnectRead).toBeTruthy()
838
+ expect(reconnectRead!.status).toBe('pending')
839
+
840
+ // Operator presses Confirm on the rehydrated card.
841
+ const executed = await executePendingActionConfirm({
842
+ action: reconnectRead!,
843
+ agent: makeAgent(),
844
+ tool: makeTool(),
845
+ ctx: makeExecCtx(),
846
+ repo,
847
+ emitEvent: emit,
848
+ now: REFERENCE_CLOCK,
849
+ })
850
+ expect(executed.ok).toBe(true)
851
+ expect(store.get('pa_1')!.status).toBe('confirmed')
852
+
853
+ // After confirm, a second poll yields the terminal row. The polling hook
854
+ // would stop scheduling further refreshes at this point.
855
+ const terminalRead = await repo.getById('pa_1', {
856
+ tenantId: 'tenant-a',
857
+ organizationId: 'org-a',
858
+ })
859
+ expect(terminalRead!.status).toBe('confirmed')
860
+ expect(terminalRead!.executionResult).toMatchObject({ recordId: 'p-1' })
861
+ })
862
+
863
+ // Scenario 14 -------------------------------------------------------------
864
+ it('scenario-14 illegal state transitions: direct pending→executing throws AiPendingActionStateError; executing→cancelled throws', async () => {
865
+ const seed = makeSeed()
866
+ const { repo } = makeRepoStub({ seeds: [seed] })
867
+
868
+ await expect(
869
+ repo.setStatus(
870
+ 'pa_1',
871
+ 'executing',
872
+ { tenantId: 'tenant-a', organizationId: 'org-a' },
873
+ { now: REFERENCE_CLOCK },
874
+ ),
875
+ ).rejects.toBeInstanceOf(AiPendingActionStateError)
876
+
877
+ // Walk to executing via the legal path (pending → confirmed → executing).
878
+ await repo.setStatus(
879
+ 'pa_1',
880
+ 'confirmed',
881
+ { tenantId: 'tenant-a', organizationId: 'org-a' },
882
+ { now: REFERENCE_CLOCK },
883
+ )
884
+ await repo.setStatus(
885
+ 'pa_1',
886
+ 'executing',
887
+ { tenantId: 'tenant-a', organizationId: 'org-a' },
888
+ { now: REFERENCE_CLOCK },
889
+ )
890
+
891
+ // Illegal: executing → cancelled is not in the allow-list.
892
+ await expect(
893
+ repo.setStatus(
894
+ 'pa_1',
895
+ 'cancelled',
896
+ { tenantId: 'tenant-a', organizationId: 'org-a' },
897
+ { now: REFERENCE_CLOCK },
898
+ ),
899
+ ).rejects.toBeInstanceOf(AiPendingActionStateError)
900
+ })
901
+
902
+ // Scenario 15 -------------------------------------------------------------
903
+ it('scenario-15 attachment cross-tenant: attachmentIds from another tenant → 403 attachment_cross_tenant, row stays pending', async () => {
904
+ const seed = makeSeed({ attachmentIds: ['att-foreign'] })
905
+ const { store, setStatus } = makeRepoStub({ seeds: [seed] })
906
+
907
+ // `findWithDecryption` is mocked at module scope to return an attachment
908
+ // row whose `tenantId` belongs to a different tenant. The recheck's
909
+ // cross-tenant guard inspects that field and short-circuits with 403.
910
+ const em = {} as unknown as import('@mikro-orm/postgresql').EntityManager
911
+
912
+ const recheck = await runPendingActionRechecks({
913
+ action: store.get('pa_1')!,
914
+ agent: makeAgent(),
915
+ tool: makeTool(),
916
+ ctx: { ...makeAuthCtx(), em, container: makeContainer() },
917
+ now: REFERENCE_CLOCK,
918
+ })
919
+
920
+ expect(recheck.ok).toBe(false)
921
+ if (!recheck.ok) {
922
+ expect(recheck.status).toBe(403)
923
+ expect(recheck.code).toBe('attachment_cross_tenant')
924
+ }
925
+ expect(setStatus).not.toHaveBeenCalled()
926
+ expect(store.get('pa_1')!.status).toBe('pending')
927
+ })
928
+
929
+ // Additional event-shape assertion ---------------------------------------
930
+ it('typed event helper: confirm / cancel / expired payloads carry resolvedByUserId per contract', async () => {
931
+ const seedA = makeSeed({ id: 'pa_a' })
932
+ const seedB = makeSeed({
933
+ id: 'pa_b',
934
+ expiresAt: new Date(REFERENCE_CLOCK.getTime() - 1000),
935
+ })
936
+ const seedC = makeSeed({ id: 'pa_c' })
937
+ const { repo, store } = makeRepoStub({ seeds: [seedA, seedB, seedC] })
938
+
939
+ const confirmEmit = jest.fn().mockResolvedValue(undefined)
940
+ await executePendingActionConfirm({
941
+ action: store.get('pa_a')!,
942
+ agent: makeAgent(),
943
+ tool: makeTool(),
944
+ ctx: makeExecCtx(),
945
+ repo,
946
+ emitEvent: confirmEmit,
947
+ now: REFERENCE_CLOCK,
948
+ })
949
+ const confirmPayload = confirmEmit.mock.calls[0][1] as AiActionConfirmedPayload
950
+ expect(confirmPayload.resolvedByUserId).toBe('user-a')
951
+
952
+ const expiredEmit = jest.fn().mockResolvedValue(undefined)
953
+ await executePendingActionCancel({
954
+ action: store.get('pa_b')!,
955
+ ctx: makeCancelCtx(),
956
+ repo,
957
+ emitEvent: expiredEmit,
958
+ now: REFERENCE_CLOCK,
959
+ })
960
+ const expiredPayload = expiredEmit.mock.calls[0][1] as AiActionExpiredPayload
961
+ expect(expiredPayload.resolvedByUserId).toBeNull()
962
+
963
+ const cancelEmit = jest.fn().mockResolvedValue(undefined)
964
+ await executePendingActionCancel({
965
+ action: store.get('pa_c')!,
966
+ ctx: makeCancelCtx(),
967
+ reason: 'user wants to stop',
968
+ repo,
969
+ emitEvent: cancelEmit,
970
+ now: REFERENCE_CLOCK,
971
+ })
972
+ const cancelPayload = cancelEmit.mock.calls[0][1] as AiActionCancelledPayload
973
+ expect(cancelPayload.resolvedByUserId).toBe('user-a')
974
+ expect(cancelPayload.reason).toBe('user wants to stop')
975
+ })
976
+ })
977
+
978
+ const EXPECTED_TOOL_HANDLER_CONTEXT_KEYS: ReadonlyArray<keyof McpToolContext> = [
979
+ 'tenantId',
980
+ 'organizationId',
981
+ 'userId',
982
+ 'container',
983
+ 'userFeatures',
984
+ 'isSuperAdmin',
985
+ ]
986
+
987
+ describe('Pending-action executor tool-handler context shape', () => {
988
+ it('tool handler receives the full McpToolContext surface expected by downstream tools', async () => {
989
+ const seed = makeSeed()
990
+ const { repo, store } = makeRepoStub({ seeds: [seed] })
991
+ const emit = jest.fn().mockResolvedValue(undefined)
992
+ const received: McpToolContext[] = []
993
+ const tool = makeTool({
994
+ handler: async (_input, context) => {
995
+ received.push(context)
996
+ return { recordId: 'p-1' }
997
+ },
998
+ })
999
+
1000
+ await executePendingActionConfirm({
1001
+ action: store.get('pa_1')!,
1002
+ agent: makeAgent(),
1003
+ tool,
1004
+ ctx: makeExecCtx(),
1005
+ repo,
1006
+ emitEvent: emit,
1007
+ now: REFERENCE_CLOCK,
1008
+ })
1009
+
1010
+ expect(received).toHaveLength(1)
1011
+ for (const key of EXPECTED_TOOL_HANDLER_CONTEXT_KEYS) {
1012
+ expect(received[0]).toHaveProperty(key as string)
1013
+ }
1014
+ })
1015
+ })