@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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +361 -0
- package/README.md +5 -0
- package/dist/index.js +154 -0
- package/dist/index.js.map +2 -2
- package/dist/modules/ai_assistant/__integration__/TC-AI-002-agent-policy.spec.js +73 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-002-agent-policy.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-SETTINGS-005-settings-page.spec.js +484 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-AGENT-SETTINGS-005-settings-page.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-PLAYGROUND-004-playground.spec.js +251 -0
- package/dist/modules/ai_assistant/__integration__/TC-AI-PLAYGROUND-004-playground.spec.js.map +7 -0
- package/dist/modules/ai_assistant/__integration__/TC-INT-AI-TOOLS.spec.js +91 -0
- package/dist/modules/ai_assistant/__integration__/TC-INT-AI-TOOLS.spec.js.map +7 -0
- package/dist/modules/ai_assistant/ai-tools/attachments-pack.js +202 -0
- package/dist/modules/ai_assistant/ai-tools/attachments-pack.js.map +7 -0
- package/dist/modules/ai_assistant/ai-tools/meta-pack.js +121 -0
- package/dist/modules/ai_assistant/ai-tools/meta-pack.js.map +7 -0
- package/dist/modules/ai_assistant/ai-tools/search-pack.js +94 -0
- package/dist/modules/ai_assistant/ai-tools/search-pack.js.map +7 -0
- package/dist/modules/ai_assistant/ai-tools.js +14 -0
- package/dist/modules/ai_assistant/ai-tools.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/actions/[id]/cancel/route.js +175 -0
- package/dist/modules/ai_assistant/api/ai/actions/[id]/cancel/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/actions/[id]/confirm/route.js +174 -0
- package/dist/modules/ai_assistant/api/ai/actions/[id]/confirm/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/actions/[id]/route.js +101 -0
- package/dist/modules/ai_assistant/api/ai/actions/[id]/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/mutation-policy/route.js +311 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/mutation-policy/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/prompt-override/route.js +246 -0
- package/dist/modules/ai_assistant/api/ai/agents/[agentId]/prompt-override/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/agents/route.js +94 -0
- package/dist/modules/ai_assistant/api/ai/agents/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/chat/route.js +173 -0
- package/dist/modules/ai_assistant/api/ai/chat/route.js.map +7 -0
- package/dist/modules/ai_assistant/api/ai/run-object/route.js +167 -0
- package/dist/modules/ai_assistant/api/ai/run-object/route.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js +1111 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/page.meta.js +28 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/agents/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.meta.js +30 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/legacy/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js +4 -6
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js +1 -21
- package/dist/modules/ai_assistant/backend/config/ai-assistant/page.meta.js.map +2 -2
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js +462 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/page.js +10 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/page.js.map +7 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/page.meta.js +28 -0
- package/dist/modules/ai_assistant/backend/config/ai-assistant/playground/page.meta.js.map +7 -0
- package/dist/modules/ai_assistant/cli.js +78 -12
- package/dist/modules/ai_assistant/cli.js.map +2 -2
- package/dist/modules/ai_assistant/data/entities/AiAgentMutationPolicyOverride.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiAgentMutationPolicyOverride.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiAgentPromptOverride.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiAgentPromptOverride.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities/AiPendingAction.js +5 -0
- package/dist/modules/ai_assistant/data/entities/AiPendingAction.js.map +7 -0
- package/dist/modules/ai_assistant/data/entities.js +228 -0
- package/dist/modules/ai_assistant/data/entities.js.map +7 -0
- package/dist/modules/ai_assistant/data/repositories/AiAgentMutationPolicyOverrideRepository.js +95 -0
- package/dist/modules/ai_assistant/data/repositories/AiAgentMutationPolicyOverrideRepository.js.map +7 -0
- package/dist/modules/ai_assistant/data/repositories/AiAgentPromptOverrideRepository.js +95 -0
- package/dist/modules/ai_assistant/data/repositories/AiAgentPromptOverrideRepository.js.map +7 -0
- package/dist/modules/ai_assistant/data/repositories/AiPendingActionRepository.js +223 -0
- package/dist/modules/ai_assistant/data/repositories/AiPendingActionRepository.js.map +7 -0
- package/dist/modules/ai_assistant/events.js +33 -0
- package/dist/modules/ai_assistant/events.js.map +7 -0
- package/dist/modules/ai_assistant/i18n/de.json +252 -0
- package/dist/modules/ai_assistant/i18n/en.json +252 -0
- package/dist/modules/ai_assistant/i18n/es.json +252 -0
- package/dist/modules/ai_assistant/i18n/pl.json +252 -0
- package/dist/modules/ai_assistant/lib/agent-policy.js +168 -0
- package/dist/modules/ai_assistant/lib/agent-policy.js.map +7 -0
- package/dist/modules/ai_assistant/lib/agent-registry.js +195 -0
- package/dist/modules/ai_assistant/lib/agent-registry.js.map +7 -0
- package/dist/modules/ai_assistant/lib/agent-runtime.js +451 -0
- package/dist/modules/ai_assistant/lib/agent-runtime.js.map +7 -0
- package/dist/modules/ai_assistant/lib/agent-tools.js +223 -0
- package/dist/modules/ai_assistant/lib/agent-tools.js.map +7 -0
- package/dist/modules/ai_assistant/lib/agent-transport.js +25 -0
- package/dist/modules/ai_assistant/lib/agent-transport.js.map +7 -0
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js +11 -0
- package/dist/modules/ai_assistant/lib/ai-agent-definition.js.map +7 -0
- package/dist/modules/ai_assistant/lib/ai-agents-generated.d.js +1 -0
- package/dist/modules/ai_assistant/lib/ai-agents-generated.d.js.map +7 -0
- package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js +239 -0
- package/dist/modules/ai_assistant/lib/ai-api-operation-runner.js.map +7 -0
- package/dist/modules/ai_assistant/lib/ai-overrides.js +189 -0
- package/dist/modules/ai_assistant/lib/ai-overrides.js.map +7 -0
- package/dist/modules/ai_assistant/lib/ai-tool-definition.js +7 -0
- package/dist/modules/ai_assistant/lib/ai-tool-definition.js.map +7 -0
- package/dist/modules/ai_assistant/lib/ai-tools-generated.d.js +1 -0
- package/dist/modules/ai_assistant/lib/ai-tools-generated.d.js.map +7 -0
- package/dist/modules/ai_assistant/lib/api-backed-tool.js +48 -0
- package/dist/modules/ai_assistant/lib/api-backed-tool.js.map +7 -0
- package/dist/modules/ai_assistant/lib/attachment-bridge-types.js +1 -0
- package/dist/modules/ai_assistant/lib/attachment-bridge-types.js.map +7 -0
- package/dist/modules/ai_assistant/lib/attachment-parts.js +276 -0
- package/dist/modules/ai_assistant/lib/attachment-parts.js.map +7 -0
- package/dist/modules/ai_assistant/lib/model-factory.js +68 -0
- package/dist/modules/ai_assistant/lib/model-factory.js.map +7 -0
- package/dist/modules/ai_assistant/lib/pending-action-cancel.js +86 -0
- package/dist/modules/ai_assistant/lib/pending-action-cancel.js.map +7 -0
- package/dist/modules/ai_assistant/lib/pending-action-client.js +35 -0
- package/dist/modules/ai_assistant/lib/pending-action-client.js.map +7 -0
- package/dist/modules/ai_assistant/lib/pending-action-executor.js +243 -0
- package/dist/modules/ai_assistant/lib/pending-action-executor.js.map +7 -0
- package/dist/modules/ai_assistant/lib/pending-action-recheck.js +246 -0
- package/dist/modules/ai_assistant/lib/pending-action-recheck.js.map +7 -0
- package/dist/modules/ai_assistant/lib/pending-action-types.js +70 -0
- package/dist/modules/ai_assistant/lib/pending-action-types.js.map +7 -0
- package/dist/modules/ai_assistant/lib/prepare-mutation.js +315 -0
- package/dist/modules/ai_assistant/lib/prepare-mutation.js.map +7 -0
- package/dist/modules/ai_assistant/lib/prompt-composition-types.js +7 -0
- package/dist/modules/ai_assistant/lib/prompt-composition-types.js.map +7 -0
- package/dist/modules/ai_assistant/lib/prompt-override-merge.js +175 -0
- package/dist/modules/ai_assistant/lib/prompt-override-merge.js.map +7 -0
- package/dist/modules/ai_assistant/lib/schema-utils.js +5 -1
- package/dist/modules/ai_assistant/lib/schema-utils.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-executor.js +13 -2
- package/dist/modules/ai_assistant/lib/tool-executor.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-loader.js +86 -11
- package/dist/modules/ai_assistant/lib/tool-loader.js.map +2 -2
- package/dist/modules/ai_assistant/lib/tool-test-fixtures.js +120 -0
- package/dist/modules/ai_assistant/lib/tool-test-fixtures.js.map +7 -0
- package/dist/modules/ai_assistant/lib/tool-test-runner.js +418 -0
- package/dist/modules/ai_assistant/lib/tool-test-runner.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260419100521.js +17 -0
- package/dist/modules/ai_assistant/migrations/Migration20260419100521.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260419132948.js +16 -0
- package/dist/modules/ai_assistant/migrations/Migration20260419132948.js.map +7 -0
- package/dist/modules/ai_assistant/migrations/Migration20260419134235.js +17 -0
- package/dist/modules/ai_assistant/migrations/Migration20260419134235.js.map +7 -0
- package/dist/modules/ai_assistant/setup.js +36 -0
- package/dist/modules/ai_assistant/setup.js.map +2 -2
- package/dist/modules/ai_assistant/workers/ai-pending-action-cleanup.js +161 -0
- package/dist/modules/ai_assistant/workers/ai-pending-action-cleanup.js.map +7 -0
- package/generated/entities/ai_agent_mutation_policy_override/index.ts +9 -0
- package/generated/entities/ai_agent_prompt_override/index.ts +10 -0
- package/generated/entities/ai_pending_action/index.ts +24 -0
- package/generated/entities.ids.generated.ts +13 -0
- package/generated/entity-fields-registry.ts +57 -0
- package/jest.config.cjs +7 -0
- package/package.json +4 -4
- package/src/index.ts +215 -0
- package/src/modules/ai_assistant/__integration__/README.md +5 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-002-agent-policy.spec.ts +115 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-AGENT-SETTINGS-005-settings-page.spec.ts +574 -0
- package/src/modules/ai_assistant/__integration__/TC-AI-PLAYGROUND-004-playground.spec.ts +333 -0
- package/src/modules/ai_assistant/__integration__/TC-INT-AI-TOOLS.spec.ts +135 -0
- package/src/modules/ai_assistant/__tests__/events.test.ts +145 -0
- package/src/modules/ai_assistant/__tests__/integration/pending-action-contract.test.ts +1015 -0
- package/src/modules/ai_assistant/__tests__/integration/ws-c-attachment-bridge.test.ts +235 -0
- package/src/modules/ai_assistant/__tests__/integration/ws-c-policy-and-tools.test.ts +330 -0
- package/src/modules/ai_assistant/__tests__/integration/ws-c-tool-pack-coverage.test.ts +285 -0
- package/src/modules/ai_assistant/ai-tools/__tests__/attachments-pack.test.ts +322 -0
- package/src/modules/ai_assistant/ai-tools/__tests__/meta-pack.test.ts +218 -0
- package/src/modules/ai_assistant/ai-tools/__tests__/search-pack.test.ts +192 -0
- package/src/modules/ai_assistant/ai-tools/attachments-pack.ts +269 -0
- package/src/modules/ai_assistant/ai-tools/meta-pack.ts +140 -0
- package/src/modules/ai_assistant/ai-tools/search-pack.ts +122 -0
- package/src/modules/ai_assistant/ai-tools.ts +21 -0
- package/src/modules/ai_assistant/api/ai/actions/[id]/__tests__/route.test.ts +222 -0
- package/src/modules/ai_assistant/api/ai/actions/[id]/cancel/__tests__/route.test.ts +286 -0
- package/src/modules/ai_assistant/api/ai/actions/[id]/cancel/route.ts +237 -0
- package/src/modules/ai_assistant/api/ai/actions/[id]/confirm/__tests__/route.test.ts +339 -0
- package/src/modules/ai_assistant/api/ai/actions/[id]/confirm/route.ts +229 -0
- package/src/modules/ai_assistant/api/ai/actions/[id]/route.ts +142 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/mutation-policy/__tests__/route.test.ts +367 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/mutation-policy/route.ts +380 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/prompt-override/__tests__/route.test.ts +333 -0
- package/src/modules/ai_assistant/api/ai/agents/[agentId]/prompt-override/route.ts +307 -0
- package/src/modules/ai_assistant/api/ai/agents/route.ts +107 -0
- package/src/modules/ai_assistant/api/ai/chat/__tests__/route.test.ts +282 -0
- package/src/modules/ai_assistant/api/ai/chat/route.ts +207 -0
- package/src/modules/ai_assistant/api/ai/run-object/__tests__/route.test.ts +282 -0
- package/src/modules/ai_assistant/api/ai/run-object/route.ts +204 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/AiAgentSettingsPageClient.tsx +1419 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/page.meta.ts +26 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/agents/page.tsx +12 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.meta.ts +28 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/legacy/page.tsx +12 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/page.meta.ts +8 -23
- package/src/modules/ai_assistant/backend/config/ai-assistant/page.tsx +15 -10
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/AiPlaygroundPageClient.tsx +604 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/page.meta.ts +26 -0
- package/src/modules/ai_assistant/backend/config/ai-assistant/playground/page.tsx +12 -0
- package/src/modules/ai_assistant/cli.ts +99 -24
- package/src/modules/ai_assistant/data/__tests__/schema-unique-indexes.test.ts +69 -0
- package/src/modules/ai_assistant/data/entities/AiAgentMutationPolicyOverride.ts +7 -0
- package/src/modules/ai_assistant/data/entities/AiAgentPromptOverride.ts +7 -0
- package/src/modules/ai_assistant/data/entities/AiPendingAction.ts +7 -0
- package/src/modules/ai_assistant/data/entities.ts +270 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentMutationPolicyOverrideRepository.ts +129 -0
- package/src/modules/ai_assistant/data/repositories/AiAgentPromptOverrideRepository.ts +132 -0
- package/src/modules/ai_assistant/data/repositories/AiPendingActionRepository.ts +334 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentMutationPolicyOverrideRepository.test.ts +195 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiAgentPromptOverrideRepository.test.ts +197 -0
- package/src/modules/ai_assistant/data/repositories/__tests__/AiPendingActionRepository.test.ts +357 -0
- package/src/modules/ai_assistant/events.ts +112 -0
- package/src/modules/ai_assistant/i18n/de.json +252 -0
- package/src/modules/ai_assistant/i18n/en.json +252 -0
- package/src/modules/ai_assistant/i18n/es.json +252 -0
- package/src/modules/ai_assistant/i18n/pl.json +252 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-policy.mutation-override.test.ts +203 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-policy.test.ts +385 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-registry.test.ts +217 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-object.test.ts +329 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime-parity.test.ts +573 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-runtime.test.ts +291 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-tools.test.ts +172 -0
- package/src/modules/ai_assistant/lib/__tests__/agent-transport.test.ts +41 -0
- package/src/modules/ai_assistant/lib/__tests__/ai-agent-definition.test.ts +183 -0
- package/src/modules/ai_assistant/lib/__tests__/ai-api-operation-runner.test.ts +432 -0
- package/src/modules/ai_assistant/lib/__tests__/ai-overrides.test.ts +308 -0
- package/src/modules/ai_assistant/lib/__tests__/api-backed-tool.test.ts +302 -0
- package/src/modules/ai_assistant/lib/__tests__/attachment-bridge-and-prompt-types.test.ts +188 -0
- package/src/modules/ai_assistant/lib/__tests__/attachment-parts.test.ts +531 -0
- package/src/modules/ai_assistant/lib/__tests__/max-steps-budget.integration.test.ts +263 -0
- package/src/modules/ai_assistant/lib/__tests__/model-factory.integration.test.ts +183 -0
- package/src/modules/ai_assistant/lib/__tests__/model-factory.test.ts +168 -0
- package/src/modules/ai_assistant/lib/__tests__/pending-action-cancel.test.ts +235 -0
- package/src/modules/ai_assistant/lib/__tests__/pending-action-client.test.ts +148 -0
- package/src/modules/ai_assistant/lib/__tests__/pending-action-executor.test.ts +348 -0
- package/src/modules/ai_assistant/lib/__tests__/pending-action-recheck.test.ts +378 -0
- package/src/modules/ai_assistant/lib/__tests__/phase-0-additive-contract.test.ts +299 -0
- package/src/modules/ai_assistant/lib/__tests__/prepare-mutation.test.ts +610 -0
- package/src/modules/ai_assistant/lib/__tests__/prompt-override-merge.test.ts +136 -0
- package/src/modules/ai_assistant/lib/__tests__/tool-loader.test.ts +125 -0
- package/src/modules/ai_assistant/lib/agent-policy.ts +270 -0
- package/src/modules/ai_assistant/lib/agent-registry.ts +277 -0
- package/src/modules/ai_assistant/lib/agent-runtime.ts +751 -0
- package/src/modules/ai_assistant/lib/agent-tools.ts +396 -0
- package/src/modules/ai_assistant/lib/agent-transport.ts +51 -0
- package/src/modules/ai_assistant/lib/ai-agent-definition.ts +86 -0
- package/src/modules/ai_assistant/lib/ai-agents-generated.d.ts +18 -0
- package/src/modules/ai_assistant/lib/ai-api-operation-runner.ts +333 -0
- package/src/modules/ai_assistant/lib/ai-overrides.ts +389 -0
- package/src/modules/ai_assistant/lib/ai-tool-definition.ts +7 -0
- package/src/modules/ai_assistant/lib/ai-tools-generated.d.ts +7 -0
- package/src/modules/ai_assistant/lib/api-backed-tool.ts +85 -0
- package/src/modules/ai_assistant/lib/attachment-bridge-types.ts +24 -0
- package/src/modules/ai_assistant/lib/attachment-parts.ts +433 -0
- package/src/modules/ai_assistant/lib/model-factory.ts +212 -0
- package/src/modules/ai_assistant/lib/pending-action-cancel.ts +179 -0
- package/src/modules/ai_assistant/lib/pending-action-client.ts +126 -0
- package/src/modules/ai_assistant/lib/pending-action-executor.ts +424 -0
- package/src/modules/ai_assistant/lib/pending-action-recheck.ts +410 -0
- package/src/modules/ai_assistant/lib/pending-action-types.ts +194 -0
- package/src/modules/ai_assistant/lib/prepare-mutation.ts +448 -0
- package/src/modules/ai_assistant/lib/prompt-composition-types.ts +24 -0
- package/src/modules/ai_assistant/lib/prompt-override-merge.ts +253 -0
- package/src/modules/ai_assistant/lib/schema-utils.ts +14 -2
- package/src/modules/ai_assistant/lib/tool-executor.ts +25 -3
- package/src/modules/ai_assistant/lib/tool-loader.ts +159 -13
- package/src/modules/ai_assistant/lib/tool-test-fixtures.ts +160 -0
- package/src/modules/ai_assistant/lib/tool-test-runner.ts +596 -0
- package/src/modules/ai_assistant/lib/types.ts +105 -2
- package/src/modules/ai_assistant/migrations/.snapshot-open-mercato.json +871 -0
- package/src/modules/ai_assistant/migrations/Migration20260419100521.ts +17 -0
- package/src/modules/ai_assistant/migrations/Migration20260419132948.ts +16 -0
- package/src/modules/ai_assistant/migrations/Migration20260419134235.ts +17 -0
- package/src/modules/ai_assistant/setup.ts +53 -0
- package/src/modules/ai_assistant/workers/__tests__/ai-pending-action-cleanup.test.ts +333 -0
- 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
|
+
})
|