@open-mercato/ai-assistant 0.9.1-develop.7519.1.38f51f8dbb → 0.9.1-develop.7536.1.f9a5776a07

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.
@@ -229,6 +229,8 @@
229
229
  "ai_assistant.chat.mutation_cards.preview.handlerError": "Der Mutations-Handler meldete einen Fehler. Prüfen Sie die Details und schlagen Sie die Aktion ggf. erneut vor.",
230
230
  "ai_assistant.chat.mutation_cards.preview.reviewDetails": "Details ansehen",
231
231
  "ai_assistant.chat.mutation_cards.preview.title": "Vorgeschlagene Änderungen prüfen",
232
+ "ai_assistant.chat.mutation_cards.result.cancelledBody": "Sie haben diese vorgeschlagene Änderung abgebrochen. Es wurden keine Änderungen vorgenommen.",
233
+ "ai_assistant.chat.mutation_cards.result.cancelledTitle": "Vorschlag abgebrochen",
232
234
  "ai_assistant.chat.mutation_cards.result.failureBody": "Die Änderung konnte nicht angewendet werden.",
233
235
  "ai_assistant.chat.mutation_cards.result.failureTitle": "Aktion fehlgeschlagen",
234
236
  "ai_assistant.chat.mutation_cards.result.fixWithAi": "Mit KI korrigieren",
@@ -229,6 +229,8 @@
229
229
  "ai_assistant.chat.mutation_cards.preview.handlerError": "The mutation handler reported an error. Review the details and re-propose if needed.",
230
230
  "ai_assistant.chat.mutation_cards.preview.reviewDetails": "Review details",
231
231
  "ai_assistant.chat.mutation_cards.preview.title": "Review proposed changes",
232
+ "ai_assistant.chat.mutation_cards.result.cancelledBody": "You cancelled this proposed change. No changes were made.",
233
+ "ai_assistant.chat.mutation_cards.result.cancelledTitle": "Proposal cancelled",
232
234
  "ai_assistant.chat.mutation_cards.result.failureBody": "The mutation could not be applied.",
233
235
  "ai_assistant.chat.mutation_cards.result.failureTitle": "Action failed",
234
236
  "ai_assistant.chat.mutation_cards.result.fixWithAi": "Fix with AI",
@@ -229,6 +229,8 @@
229
229
  "ai_assistant.chat.mutation_cards.preview.handlerError": "El gestor de mutaciones informó un error. Revisa los detalles y vuelve a proponer la acción si es necesario.",
230
230
  "ai_assistant.chat.mutation_cards.preview.reviewDetails": "Ver detalles",
231
231
  "ai_assistant.chat.mutation_cards.preview.title": "Revisar los cambios propuestos",
232
+ "ai_assistant.chat.mutation_cards.result.cancelledBody": "Cancelaste este cambio propuesto. No se realizó ningún cambio.",
233
+ "ai_assistant.chat.mutation_cards.result.cancelledTitle": "Propuesta cancelada",
232
234
  "ai_assistant.chat.mutation_cards.result.failureBody": "No se pudo aplicar la mutación.",
233
235
  "ai_assistant.chat.mutation_cards.result.failureTitle": "La acción falló",
234
236
  "ai_assistant.chat.mutation_cards.result.fixWithAi": "Corregir con IA",
@@ -229,6 +229,8 @@
229
229
  "ai_assistant.chat.mutation_cards.preview.handlerError": "뮤테이션 핸들러가 오류를 보고했습니다. 세부 정보를 검토하고 필요하면 다시 제안하세요.",
230
230
  "ai_assistant.chat.mutation_cards.preview.reviewDetails": "세부 정보 검토",
231
231
  "ai_assistant.chat.mutation_cards.preview.title": "제안된 변경 사항 검토",
232
+ "ai_assistant.chat.mutation_cards.result.cancelledBody": "이 제안된 변경을 취소했습니다. 변경 사항이 적용되지 않았습니다.",
233
+ "ai_assistant.chat.mutation_cards.result.cancelledTitle": "제안이 취소됨",
232
234
  "ai_assistant.chat.mutation_cards.result.failureBody": "뮤테이션을 적용할 수 없습니다.",
233
235
  "ai_assistant.chat.mutation_cards.result.failureTitle": "작업 실패",
234
236
  "ai_assistant.chat.mutation_cards.result.fixWithAi": "AI로 수정",
@@ -229,6 +229,8 @@
229
229
  "ai_assistant.chat.mutation_cards.preview.handlerError": "Handler mutacji zgłosił błąd. Sprawdź szczegóły i w razie potrzeby zaproponuj akcję ponownie.",
230
230
  "ai_assistant.chat.mutation_cards.preview.reviewDetails": "Pokaż szczegóły",
231
231
  "ai_assistant.chat.mutation_cards.preview.title": "Sprawdź proponowane zmiany",
232
+ "ai_assistant.chat.mutation_cards.result.cancelledBody": "Anulowano tę proponowaną zmianę. Nie wprowadzono żadnych zmian.",
233
+ "ai_assistant.chat.mutation_cards.result.cancelledTitle": "Propozycja anulowana",
232
234
  "ai_assistant.chat.mutation_cards.result.failureBody": "Nie udało się zastosować zmiany.",
233
235
  "ai_assistant.chat.mutation_cards.result.failureTitle": "Akcja nie powiodła się",
234
236
  "ai_assistant.chat.mutation_cards.result.fixWithAi": "Popraw z AI",
@@ -54,7 +54,11 @@ async function executePendingActionCancel(input) {
54
54
  const executionResult = {
55
55
  error: {
56
56
  code: "cancelled_by_user",
57
- message: trimmedReason.length > 0 ? trimmedReason : "Cancelled by user"
57
+ // Left empty when no reason is supplied: this is surfaced as a
58
+ // neutral "proposal cancelled" message in the client, which
59
+ // supplies its own localized default copy (see MutationResultCard)
60
+ // instead of a hardcoded English string.
61
+ message: trimmedReason
58
62
  }
59
63
  };
60
64
  const cancelledRow = await repo.setStatus(action.id, "cancelled", scope, {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/pending-action-cancel.ts"],
4
- "sourcesContent": ["/**\n * Pending-action cancel executor (spec \u00A79.4, Step 5.9).\n *\n * Flips an `AiPendingAction` from `pending \u2192 cancelled` and emits the\n * typed `ai.action.cancelled` event via `emitAiAssistantEvent`. Unlike\n * {@link executePendingActionConfirm} the tool handler is NEVER invoked \u2014\n * cancellation is a pure state-machine transition plus an event emission.\n * Any other status short-circuits: already-`cancelled` is idempotent (no\n * second emit, same row returned); `confirmed` / `executing` / `failed`\n * are treated as invariant violations and bubble up as 409 via the route.\n *\n * If the row's `expiresAt` is in the past at the time of this call we\n * flip it to `expired` (not `cancelled`) \u2014 the Step 5.12 cleanup worker\n * races with this code path and we want a single canonical terminal\n * status for a row that reached its TTL.\n *\n * Idempotency: the caller MUST handle the already-`cancelled` branch\n * BEFORE invoking this helper so the event is emitted exactly once per\n * cancellation.\n */\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { AiPendingActionRepository } from '../data/repositories/AiPendingActionRepository'\nimport type { AiPendingAction } from '../data/entities'\nimport { emitAiAssistantEvent } from '../events'\nimport type {\n AiActionCancelledPayload,\n AiActionExpiredPayload,\n AiAssistantEventId,\n} from '../events'\nimport type { AiPendingActionExecutionResult } from './pending-action-types'\n\nconst logger = createLogger('ai_assistant')\n\nexport interface PendingActionCancelContext {\n tenantId: string\n organizationId: string | null\n userId: string\n container: import('awilix').AwilixContainer\n}\n\nexport type CancelEmitter = (\n eventId: Extract<AiAssistantEventId, 'ai.action.cancelled' | 'ai.action.expired'>,\n payload: AiActionCancelledPayload | AiActionExpiredPayload,\n) => Promise<void>\n\nexport interface PendingActionCancelInput {\n action: AiPendingAction\n ctx: PendingActionCancelContext\n /** Optional, caller-supplied cancellation reason (already trimmed by the route). */\n reason?: string | null\n repo?: AiPendingActionRepository\n /**\n * Injection seam for unit tests. When omitted, emission is routed via\n * the typed `emitAiAssistantEvent` helper (the normal production path).\n */\n emitEvent?: CancelEmitter\n now?: Date\n}\n\nexport type PendingActionCancelStatus = 'cancelled' | 'expired'\n\nexport interface PendingActionCancelResult {\n row: AiPendingAction\n status: PendingActionCancelStatus\n}\n\nconst CANCELLED_EVENT_ID = 'ai.action.cancelled' as const\nconst EXPIRED_EVENT_ID = 'ai.action.expired' as const\n\nconst defaultCancelEmitter: CancelEmitter = async (eventId, payload) => {\n await emitAiAssistantEvent(eventId, payload as unknown as Record<string, unknown>, {\n persistent: true,\n })\n}\n\nasync function emitEventSafe(\n emitter: CancelEmitter,\n eventId: Parameters<CancelEmitter>[0],\n payload: Parameters<CancelEmitter>[1],\n): Promise<void> {\n try {\n await emitter(eventId, payload)\n } catch (error) {\n logger.warn('Failed to emit pending-action event', { eventId, err: error })\n }\n}\n\n/**\n * Atomic `pending \u2192 cancelled` transition with TTL-race safety.\n *\n * - If `action.status === 'cancelled'`, returns the current row WITHOUT\n * emitting a second event. Callers should typically short-circuit on\n * this branch BEFORE invoking the helper (see the Step 5.9 route).\n * - If `action.expiresAt <= now`, the row is flipped to `expired` and the\n * `ai.action.expired` event is emitted via the typed\n * `emitAiAssistantEvent` helper (see `../events`). Returns\n * `{ status: 'expired' }` so the route can translate to a 409\n * `expired` envelope.\n * - Otherwise flips `pending \u2192 cancelled`, writes `resolvedAt` + the\n * optional cancellation reason onto `executionResult.error`, and emits\n * `ai.action.cancelled`.\n *\n * Any status other than `pending` / `cancelled` is treated as an\n * invariant violation \u2014 the route returns 409 `invalid_status` before\n * reaching this helper. If the caller invokes the helper on such a row\n * it will throw via the repo's state-machine guard.\n */\nexport async function executePendingActionCancel(\n input: PendingActionCancelInput,\n): Promise<PendingActionCancelResult> {\n const { action, ctx, now } = input\n const repo = input.repo ?? new AiPendingActionRepository(ctx.container.resolve('em'))\n const scope = {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n }\n const clock = now ?? new Date()\n const emitter: CancelEmitter = input.emitEvent ?? defaultCancelEmitter\n\n if (action.status === 'cancelled') {\n return { row: action, status: 'cancelled' }\n }\n\n const expiresAt =\n action.expiresAt instanceof Date ? action.expiresAt : new Date(action.expiresAt)\n if (expiresAt.getTime() <= clock.getTime()) {\n const expiredRow = await repo.setStatus(action.id, 'expired', scope, { now: clock })\n const resolvedAtIso =\n (expiredRow.resolvedAt ?? clock).toISOString?.() ?? new Date(clock).toISOString()\n const expiresAtIso =\n (expiresAt ?? clock).toISOString?.() ?? new Date(clock).toISOString()\n const expiredPayload: AiActionExpiredPayload = {\n pendingActionId: expiredRow.id,\n agentId: expiredRow.agentId,\n toolName: expiredRow.toolName,\n status: expiredRow.status,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n userId: ctx.userId,\n resolvedByUserId: null,\n resolvedAt: resolvedAtIso,\n expiresAt: expiresAtIso,\n expiredAt: resolvedAtIso,\n }\n await emitEventSafe(emitter, EXPIRED_EVENT_ID, expiredPayload)\n return { row: expiredRow, status: 'expired' }\n }\n\n const trimmedReason = typeof input.reason === 'string' ? input.reason.trim() : ''\n const executionResult: AiPendingActionExecutionResult = {\n error: {\n code: 'cancelled_by_user',\n message: trimmedReason.length > 0 ? trimmedReason : 'Cancelled by user',\n },\n }\n\n const cancelledRow = await repo.setStatus(action.id, 'cancelled', scope, {\n resolvedByUserId: ctx.userId,\n executionResult,\n now: clock,\n })\n const cancelledPayload: AiActionCancelledPayload = {\n pendingActionId: cancelledRow.id,\n agentId: cancelledRow.agentId,\n toolName: cancelledRow.toolName,\n status: cancelledRow.status,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n userId: ctx.userId,\n resolvedByUserId: ctx.userId,\n resolvedAt: (cancelledRow.resolvedAt ?? clock).toISOString?.() ?? new Date(clock).toISOString(),\n executionResult,\n ...(trimmedReason.length > 0 ? { reason: trimmedReason } : {}),\n }\n await emitEventSafe(emitter, CANCELLED_EVENT_ID, cancelledPayload)\n\n return { row: cancelledRow, status: 'cancelled' }\n}\n\nexport const PENDING_ACTION_CANCELLED_EVENT_ID = CANCELLED_EVENT_ID\nexport const PENDING_ACTION_EXPIRED_EVENT_ID = EXPIRED_EVENT_ID\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["/**\n * Pending-action cancel executor (spec \u00A79.4, Step 5.9).\n *\n * Flips an `AiPendingAction` from `pending \u2192 cancelled` and emits the\n * typed `ai.action.cancelled` event via `emitAiAssistantEvent`. Unlike\n * {@link executePendingActionConfirm} the tool handler is NEVER invoked \u2014\n * cancellation is a pure state-machine transition plus an event emission.\n * Any other status short-circuits: already-`cancelled` is idempotent (no\n * second emit, same row returned); `confirmed` / `executing` / `failed`\n * are treated as invariant violations and bubble up as 409 via the route.\n *\n * If the row's `expiresAt` is in the past at the time of this call we\n * flip it to `expired` (not `cancelled`) \u2014 the Step 5.12 cleanup worker\n * races with this code path and we want a single canonical terminal\n * status for a row that reached its TTL.\n *\n * Idempotency: the caller MUST handle the already-`cancelled` branch\n * BEFORE invoking this helper so the event is emitted exactly once per\n * cancellation.\n */\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport { AiPendingActionRepository } from '../data/repositories/AiPendingActionRepository'\nimport type { AiPendingAction } from '../data/entities'\nimport { emitAiAssistantEvent } from '../events'\nimport type {\n AiActionCancelledPayload,\n AiActionExpiredPayload,\n AiAssistantEventId,\n} from '../events'\nimport type { AiPendingActionExecutionResult } from './pending-action-types'\n\nconst logger = createLogger('ai_assistant')\n\nexport interface PendingActionCancelContext {\n tenantId: string\n organizationId: string | null\n userId: string\n container: import('awilix').AwilixContainer\n}\n\nexport type CancelEmitter = (\n eventId: Extract<AiAssistantEventId, 'ai.action.cancelled' | 'ai.action.expired'>,\n payload: AiActionCancelledPayload | AiActionExpiredPayload,\n) => Promise<void>\n\nexport interface PendingActionCancelInput {\n action: AiPendingAction\n ctx: PendingActionCancelContext\n /** Optional, caller-supplied cancellation reason (already trimmed by the route). */\n reason?: string | null\n repo?: AiPendingActionRepository\n /**\n * Injection seam for unit tests. When omitted, emission is routed via\n * the typed `emitAiAssistantEvent` helper (the normal production path).\n */\n emitEvent?: CancelEmitter\n now?: Date\n}\n\nexport type PendingActionCancelStatus = 'cancelled' | 'expired'\n\nexport interface PendingActionCancelResult {\n row: AiPendingAction\n status: PendingActionCancelStatus\n}\n\nconst CANCELLED_EVENT_ID = 'ai.action.cancelled' as const\nconst EXPIRED_EVENT_ID = 'ai.action.expired' as const\n\nconst defaultCancelEmitter: CancelEmitter = async (eventId, payload) => {\n await emitAiAssistantEvent(eventId, payload as unknown as Record<string, unknown>, {\n persistent: true,\n })\n}\n\nasync function emitEventSafe(\n emitter: CancelEmitter,\n eventId: Parameters<CancelEmitter>[0],\n payload: Parameters<CancelEmitter>[1],\n): Promise<void> {\n try {\n await emitter(eventId, payload)\n } catch (error) {\n logger.warn('Failed to emit pending-action event', { eventId, err: error })\n }\n}\n\n/**\n * Atomic `pending \u2192 cancelled` transition with TTL-race safety.\n *\n * - If `action.status === 'cancelled'`, returns the current row WITHOUT\n * emitting a second event. Callers should typically short-circuit on\n * this branch BEFORE invoking the helper (see the Step 5.9 route).\n * - If `action.expiresAt <= now`, the row is flipped to `expired` and the\n * `ai.action.expired` event is emitted via the typed\n * `emitAiAssistantEvent` helper (see `../events`). Returns\n * `{ status: 'expired' }` so the route can translate to a 409\n * `expired` envelope.\n * - Otherwise flips `pending \u2192 cancelled`, writes `resolvedAt` + the\n * optional cancellation reason onto `executionResult.error`, and emits\n * `ai.action.cancelled`.\n *\n * Any status other than `pending` / `cancelled` is treated as an\n * invariant violation \u2014 the route returns 409 `invalid_status` before\n * reaching this helper. If the caller invokes the helper on such a row\n * it will throw via the repo's state-machine guard.\n */\nexport async function executePendingActionCancel(\n input: PendingActionCancelInput,\n): Promise<PendingActionCancelResult> {\n const { action, ctx, now } = input\n const repo = input.repo ?? new AiPendingActionRepository(ctx.container.resolve('em'))\n const scope = {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n }\n const clock = now ?? new Date()\n const emitter: CancelEmitter = input.emitEvent ?? defaultCancelEmitter\n\n if (action.status === 'cancelled') {\n return { row: action, status: 'cancelled' }\n }\n\n const expiresAt =\n action.expiresAt instanceof Date ? action.expiresAt : new Date(action.expiresAt)\n if (expiresAt.getTime() <= clock.getTime()) {\n const expiredRow = await repo.setStatus(action.id, 'expired', scope, { now: clock })\n const resolvedAtIso =\n (expiredRow.resolvedAt ?? clock).toISOString?.() ?? new Date(clock).toISOString()\n const expiresAtIso =\n (expiresAt ?? clock).toISOString?.() ?? new Date(clock).toISOString()\n const expiredPayload: AiActionExpiredPayload = {\n pendingActionId: expiredRow.id,\n agentId: expiredRow.agentId,\n toolName: expiredRow.toolName,\n status: expiredRow.status,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n userId: ctx.userId,\n resolvedByUserId: null,\n resolvedAt: resolvedAtIso,\n expiresAt: expiresAtIso,\n expiredAt: resolvedAtIso,\n }\n await emitEventSafe(emitter, EXPIRED_EVENT_ID, expiredPayload)\n return { row: expiredRow, status: 'expired' }\n }\n\n const trimmedReason = typeof input.reason === 'string' ? input.reason.trim() : ''\n const executionResult: AiPendingActionExecutionResult = {\n error: {\n code: 'cancelled_by_user',\n // Left empty when no reason is supplied: this is surfaced as a\n // neutral \"proposal cancelled\" message in the client, which\n // supplies its own localized default copy (see MutationResultCard)\n // instead of a hardcoded English string.\n message: trimmedReason,\n },\n }\n\n const cancelledRow = await repo.setStatus(action.id, 'cancelled', scope, {\n resolvedByUserId: ctx.userId,\n executionResult,\n now: clock,\n })\n const cancelledPayload: AiActionCancelledPayload = {\n pendingActionId: cancelledRow.id,\n agentId: cancelledRow.agentId,\n toolName: cancelledRow.toolName,\n status: cancelledRow.status,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n userId: ctx.userId,\n resolvedByUserId: ctx.userId,\n resolvedAt: (cancelledRow.resolvedAt ?? clock).toISOString?.() ?? new Date(clock).toISOString(),\n executionResult,\n ...(trimmedReason.length > 0 ? { reason: trimmedReason } : {}),\n }\n await emitEventSafe(emitter, CANCELLED_EVENT_ID, cancelledPayload)\n\n return { row: cancelledRow, status: 'cancelled' }\n}\n\nexport const PENDING_ACTION_CANCELLED_EVENT_ID = CANCELLED_EVENT_ID\nexport const PENDING_ACTION_EXPIRED_EVENT_ID = EXPIRED_EVENT_ID\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -79,9 +79,9 @@ function computeMutationIdempotencyKey(input) {
79
79
  });
80
80
  return createHash("sha256").update(canonical).digest("hex");
81
81
  }
82
- function computeFieldDiff(before, after, display) {
82
+ function computeFieldDiff(before, after, display, options) {
83
83
  const diff = [];
84
- const keys = /* @__PURE__ */ new Set([
84
+ const keys = options?.keys ? new Set(options.keys) : /* @__PURE__ */ new Set([
85
85
  ...Object.keys(before ?? {}),
86
86
  ...Object.keys(after ?? {})
87
87
  ]);
@@ -147,6 +147,11 @@ function matchBatchPatch(args, recordId) {
147
147
  }
148
148
  return {};
149
149
  }
150
+ function fallbackPatchDiffKeys(patch, before) {
151
+ return Object.keys(patch ?? {}).filter(
152
+ (key) => Object.prototype.hasOwnProperty.call(before ?? {}, key)
153
+ );
154
+ }
150
155
  function normalizeAttachmentIds(args) {
151
156
  const raw = args?.attachmentIds;
152
157
  if (!Array.isArray(raw)) return [];
@@ -178,8 +183,14 @@ async function buildSingleRecordDiff(tool, input, ctx) {
178
183
  sideEffectsSummary: null
179
184
  };
180
185
  }
186
+ const hasExplicitAfter = before.after != null;
181
187
  const patch = before.after ?? extractPatchFromArgs(input.toolCallArgs);
182
- const fieldDiff = computeFieldDiff(before.before, patch, before.display);
188
+ const fieldDiff = computeFieldDiff(
189
+ before.before,
190
+ patch,
191
+ before.display,
192
+ hasExplicitAfter ? void 0 : { keys: fallbackPatchDiffKeys(patch, before.before) }
193
+ );
183
194
  return {
184
195
  fieldDiff,
185
196
  targetEntityType: before.entityType,
@@ -211,12 +222,18 @@ async function buildBatchRecords(tool, input, ctx) {
211
222
  };
212
223
  }
213
224
  const diffs = rows.map((row) => {
225
+ const hasExplicitAfter = row.after != null;
214
226
  const patch = row.after ?? matchBatchPatch(input.toolCallArgs, row.recordId);
215
227
  return {
216
228
  recordId: row.recordId,
217
229
  entityType: row.entityType,
218
230
  label: row.label,
219
- fieldDiff: computeFieldDiff(row.before, patch, row.display),
231
+ fieldDiff: computeFieldDiff(
232
+ row.before,
233
+ patch,
234
+ row.display,
235
+ hasExplicitAfter ? void 0 : { keys: fallbackPatchDiffKeys(patch, row.before) }
236
+ ),
220
237
  recordVersion: row.recordVersion ?? null
221
238
  };
222
239
  });
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/prepare-mutation.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { createHash } from 'node:crypto'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'\nimport type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'\nimport type {\n AiToolDefinition,\n AiToolFieldDiffDisplayHints,\n AiToolLoadBeforeRecord,\n AiToolLoadBeforeSingleRecord,\n McpToolContext,\n} from './types'\nimport { resolveEffectiveMutationPolicy } from './agent-policy'\nimport { AiPendingActionRepository } from '../data/repositories/AiPendingActionRepository'\nimport type { AiPendingAction } from '../data/entities'\nimport type {\n AiPendingActionFieldDiff,\n AiPendingActionRecordDiff,\n} from './pending-action-types'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Structured error raised by {@link prepareMutation}. Callers (today the\n * agent-runtime tool wrapper installed by `resolveAiAgentTools`) turn this\n * into a tool-call failure that the model surfaces back to the user without\n * leaking internals. The runtime NEVER reaches this helper when the agent\n * is declared read-only \u2014 the policy gate rejects the tool call upstream \u2014\n * but we keep the fail-closed check as a defensive guard.\n */\nexport class AiMutationPreparationError extends Error {\n constructor(\n public readonly code:\n | 'not_a_mutation_tool'\n | 'read_only_agent'\n | 'tenant_scope_missing'\n | 'container_missing'\n | 'em_missing',\n message: string,\n ) {\n super(message)\n this.name = 'AiMutationPreparationError'\n }\n}\n\nexport interface PrepareMutationInput {\n agent: AiAgentDefinition\n tool: AiToolDefinition\n toolCallArgs: Record<string, unknown>\n conversationId?: string | null\n /**\n * Optional downgrade the caller already resolved (mirror of\n * `resolveAiAgentTools({ mutationPolicyOverride })`). When omitted, the\n * agent's code-declared policy stands alone.\n */\n mutationPolicyOverride?: AiAgentMutationPolicy | null\n /**\n * Deterministic clock hook for tests. Defaults to `new Date()`.\n */\n now?: Date\n}\n\nexport interface PrepareMutationContext extends AiChatRequestContext {\n container: AwilixContainer\n}\n\nexport interface PrepareMutationResult {\n uiPart: AiUiPart\n pendingAction: AiPendingAction\n}\n\nconst MUTATION_PREVIEW_CARD_COMPONENT_ID = 'mutation-preview-card'\n\nconst NO_RESOLVER_SIDE_EFFECTS_MESSAGE =\n 'Tool did not declare a field-diff resolver; action will proceed without a preview.'\n\nfunction assertTenantScope(ctx: PrepareMutationContext): string {\n if (!ctx.tenantId) {\n throw new AiMutationPreparationError(\n 'tenant_scope_missing',\n 'prepareMutation requires a tenant-scoped request context.',\n )\n }\n return ctx.tenantId\n}\n\nfunction resolveEm(container: AwilixContainer): EntityManager {\n if (!container) {\n throw new AiMutationPreparationError(\n 'container_missing',\n 'prepareMutation requires an Awilix container to resolve the EntityManager.',\n )\n }\n let em: EntityManager | null = null\n try {\n em = container.resolve<EntityManager>('em')\n } catch {\n em = null\n }\n if (!em) {\n throw new AiMutationPreparationError(\n 'em_missing',\n 'prepareMutation could not resolve \"em\" from the container.',\n )\n }\n return em\n}\n\nfunction toolHandlerContext(ctx: PrepareMutationContext): McpToolContext {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n container: ctx.container,\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n}\n\nfunction safeStringify(value: unknown): string {\n const seen = new WeakSet<object>()\n return JSON.stringify(value, (_key, raw) => {\n if (raw && typeof raw === 'object') {\n if (seen.has(raw as object)) return '[Circular]'\n seen.add(raw as object)\n const entries = Object.entries(raw as Record<string, unknown>)\n entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n return entries.reduce<Record<string, unknown>>((acc, [k, v]) => {\n acc[k] = v\n return acc\n }, {})\n }\n return raw\n })\n}\n\n/**\n * Hashes `(tenantId, orgId, agentId, conversationId, toolName, normalizedInput)`\n * into a stable SHA-256 digest so that retries of the same tool call with the\n * same payload collapse to a single `AiPendingAction` row inside the TTL\n * window. The input is normalized through `safeStringify` to make object key\n * order irrelevant (spec \u00A78 rule `idempotencyKey prevents double-submission`).\n * Attachments are NOT included \u2014 the attachment set is captured separately on\n * the pending row so that re-uploading the same file set with a different\n * tool-call object never accidentally collides.\n */\nexport function computeMutationIdempotencyKey(input: {\n tenantId: string\n organizationId: string | null\n agentId: string\n conversationId: string | null\n toolName: string\n normalizedInput: Record<string, unknown>\n}): string {\n const canonical = safeStringify({\n tenant: input.tenantId,\n org: input.organizationId ?? null,\n agent: input.agentId,\n conversation: input.conversationId ?? null,\n tool: input.toolName,\n input: input.normalizedInput ?? {},\n })\n return createHash('sha256').update(canonical).digest('hex')\n}\n\nfunction computeFieldDiff(\n before: Record<string, unknown>,\n after: Record<string, unknown>,\n display?: AiToolFieldDiffDisplayHints,\n): AiPendingActionFieldDiff[] {\n const diff: AiPendingActionFieldDiff[] = []\n const keys = new Set<string>([\n ...Object.keys(before ?? {}),\n ...Object.keys(after ?? {}),\n ])\n for (const field of keys) {\n const beforeValue = before ? before[field] : undefined\n const afterValue = after ? after[field] : undefined\n if (!Object.is(beforeValue, afterValue) && safeStringify(beforeValue) !== safeStringify(afterValue)) {\n const fieldLabel = display?.fieldLabels?.[field]\n const beforeDisplay = display?.before?.[field]\n const afterDisplay = display?.after?.[field]\n diff.push({\n field,\n ...(fieldLabel !== undefined ? { fieldLabel } : {}),\n before: beforeValue,\n after: afterValue,\n ...(beforeDisplay !== undefined ? { beforeDisplay } : {}),\n ...(afterDisplay !== undefined ? { afterDisplay } : {}),\n })\n }\n }\n return diff\n}\n\nfunction extractPatchFromArgs(\n args: Record<string, unknown>,\n): Record<string, unknown> {\n const raw = args?.patch\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) {\n return raw as Record<string, unknown>\n }\n // Fall back: treat the whole args object (minus well-known envelope keys)\n // as the patch. This preserves compatibility with tools whose schema is\n // flat (`{ productId, name }`) rather than nested (`{ productId, patch }`).\n const envelope = new Set([\n 'id',\n 'recordId',\n 'records',\n 'attachmentIds',\n '_sessionToken',\n ])\n const reduced: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(args ?? {})) {\n if (envelope.has(key)) continue\n reduced[key] = value\n }\n return reduced\n}\n\nfunction matchBatchPatch(\n args: Record<string, unknown>,\n recordId: string,\n): Record<string, unknown> {\n const rawList = args?.records\n if (Array.isArray(rawList)) {\n const match = rawList.find((entry) => {\n if (!entry || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return candidate.recordId === recordId || candidate.id === recordId\n })\n if (match && typeof match === 'object') {\n const patch = (match as Record<string, unknown>).patch\n if (patch && typeof patch === 'object' && !Array.isArray(patch)) {\n return patch as Record<string, unknown>\n }\n const envelope = new Set(['recordId', 'id'])\n const reduced: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(match as Record<string, unknown>)) {\n if (envelope.has(key)) continue\n reduced[key] = value\n }\n return reduced\n }\n }\n return {}\n}\n\nfunction normalizeAttachmentIds(args: Record<string, unknown>): string[] {\n const raw = args?.attachmentIds\n if (!Array.isArray(raw)) return []\n return raw.filter((value): value is string => typeof value === 'string' && value.length > 0)\n}\n\nasync function buildSingleRecordDiff(\n tool: AiToolDefinition,\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<{\n fieldDiff: AiPendingActionFieldDiff[]\n targetEntityType: string | null\n targetRecordId: string | null\n recordVersion: string | null\n sideEffectsSummary: string | null\n}> {\n const resolver = tool.loadBeforeRecord\n if (!resolver) {\n logger.warn('prepareMutation: tool declared isMutation=true but no loadBeforeRecord resolver; shipping empty fieldDiff', { toolName: tool.name })\n return {\n fieldDiff: [],\n targetEntityType: null,\n targetRecordId: null,\n recordVersion: null,\n sideEffectsSummary: NO_RESOLVER_SIDE_EFFECTS_MESSAGE,\n }\n }\n const handlerContext = toolHandlerContext(ctx)\n const before: AiToolLoadBeforeSingleRecord | null = await resolver(\n input.toolCallArgs as never,\n handlerContext,\n )\n if (!before) {\n return {\n fieldDiff: [],\n targetEntityType: null,\n targetRecordId: null,\n recordVersion: null,\n sideEffectsSummary: null,\n }\n }\n const patch = before.after ?? extractPatchFromArgs(input.toolCallArgs)\n const fieldDiff = computeFieldDiff(before.before, patch, before.display)\n return {\n fieldDiff,\n targetEntityType: before.entityType,\n targetRecordId: before.recordId,\n recordVersion: before.recordVersion,\n sideEffectsSummary: null,\n }\n}\n\nasync function buildBatchRecords(\n tool: AiToolDefinition,\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<{\n records: AiPendingActionRecordDiff[] | null\n targetEntityType: string | null\n sideEffectsSummary: string | null\n}> {\n const resolver = tool.loadBeforeRecords\n if (!resolver) {\n logger.warn('prepareMutation: bulk tool declared isMutation=true but no loadBeforeRecords resolver; shipping empty records[]', { toolName: tool.name })\n return {\n records: null,\n targetEntityType: null,\n sideEffectsSummary: NO_RESOLVER_SIDE_EFFECTS_MESSAGE,\n }\n }\n const handlerContext = toolHandlerContext(ctx)\n const rows: AiToolLoadBeforeRecord[] = await resolver(\n input.toolCallArgs as never,\n handlerContext,\n )\n if (!Array.isArray(rows) || rows.length === 0) {\n return {\n records: null,\n targetEntityType: null,\n sideEffectsSummary: null,\n }\n }\n const diffs: AiPendingActionRecordDiff[] = rows.map((row) => {\n const patch = row.after ?? matchBatchPatch(input.toolCallArgs, row.recordId)\n return {\n recordId: row.recordId,\n entityType: row.entityType,\n label: row.label,\n fieldDiff: computeFieldDiff(row.before, patch, row.display),\n recordVersion: row.recordVersion ?? null,\n }\n })\n const [firstEntity] = rows\n return {\n records: diffs,\n targetEntityType: firstEntity ? firstEntity.entityType : null,\n sideEffectsSummary: null,\n }\n}\n\n/**\n * Intercepts a mutation tool call and turns it into an `AiPendingAction` +\n * `mutation-preview-card` UI part (spec Phase 3 WS-C \u00A79). The caller MUST\n * have already confirmed the agent's effective `mutationPolicy` is NOT\n * `read-only`; this helper repeats the check defensively because skipping it\n * would be a policy-bypass.\n *\n * The tool handler is NEVER invoked by this function \u2014 the write is\n * short-circuited and only runs from the Step 5.8 confirm route. See the\n * unit test `does not call the tool handler` for the guard.\n */\nexport async function prepareMutation(\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<PrepareMutationResult> {\n const { agent, tool } = input\n if (tool.isMutation !== true) {\n throw new AiMutationPreparationError(\n 'not_a_mutation_tool',\n `Tool \"${tool.name}\" is not a mutation tool; prepareMutation should not be invoked.`,\n )\n }\n const effectivePolicy = resolveEffectiveMutationPolicy(\n agent.mutationPolicy,\n input.mutationPolicyOverride ?? null,\n agent.id,\n )\n if (effectivePolicy === 'read-only') {\n throw new AiMutationPreparationError(\n 'read_only_agent',\n `Agent \"${agent.id}\" has effective mutationPolicy=read-only; mutation tool \"${tool.name}\" cannot be prepared.`,\n )\n }\n\n const tenantId = assertTenantScope(ctx)\n const em = resolveEm(ctx.container)\n const repo = new AiPendingActionRepository(em)\n\n const isBulk = tool.isBulk === true\n let fieldDiff: AiPendingActionFieldDiff[] = []\n let records: AiPendingActionRecordDiff[] | null = null\n let targetEntityType: string | null = null\n let targetRecordId: string | null = null\n let recordVersion: string | null = null\n let sideEffectsSummary: string | null = null\n\n if (isBulk) {\n const batch = await buildBatchRecords(tool, input, ctx)\n records = batch.records\n targetEntityType = batch.targetEntityType\n sideEffectsSummary = batch.sideEffectsSummary\n } else {\n const single = await buildSingleRecordDiff(tool, input, ctx)\n fieldDiff = single.fieldDiff\n targetEntityType = single.targetEntityType\n targetRecordId = single.targetRecordId\n recordVersion = single.recordVersion\n sideEffectsSummary = single.sideEffectsSummary\n }\n\n const normalizedInput = input.toolCallArgs ?? {}\n const conversationId = input.conversationId ?? null\n const idempotencyKey = computeMutationIdempotencyKey({\n tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId: agent.id,\n conversationId,\n toolName: tool.name,\n normalizedInput,\n })\n\n const pendingAction = await repo.create(\n {\n agentId: agent.id,\n toolName: tool.name,\n idempotencyKey,\n createdByUserId: ctx.userId,\n normalizedInput,\n conversationId,\n targetEntityType,\n targetRecordId,\n fieldDiff,\n records,\n sideEffectsSummary,\n recordVersion,\n attachmentIds: normalizeAttachmentIds(normalizedInput),\n now: input.now,\n },\n {\n tenantId,\n organizationId: ctx.organizationId ?? null,\n userId: ctx.userId,\n },\n )\n\n const uiPart: AiUiPart = {\n componentId: MUTATION_PREVIEW_CARD_COMPONENT_ID,\n props: {\n pendingActionId: pendingAction.id,\n expiresAt: pendingAction.expiresAt.toISOString(),\n ...(records ? { records } : { fieldDiff }),\n ...(sideEffectsSummary ? { sideEffectsSummary } : {}),\n },\n }\n\n return { uiPart, pendingAction }\n}\n\nexport const MUTATION_PREVIEW_CARD_COMPONENT = MUTATION_PREVIEW_CARD_COMPONENT_ID\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { createHash } from 'node:crypto'\nimport type { AwilixContainer } from 'awilix'\nimport type { EntityManager } from '@mikro-orm/postgresql'\nimport type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'\nimport type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'\nimport type {\n AiToolDefinition,\n AiToolFieldDiffDisplayHints,\n AiToolLoadBeforeRecord,\n AiToolLoadBeforeSingleRecord,\n McpToolContext,\n} from './types'\nimport { resolveEffectiveMutationPolicy } from './agent-policy'\nimport { AiPendingActionRepository } from '../data/repositories/AiPendingActionRepository'\nimport type { AiPendingAction } from '../data/entities'\nimport type {\n AiPendingActionFieldDiff,\n AiPendingActionRecordDiff,\n} from './pending-action-types'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Structured error raised by {@link prepareMutation}. Callers (today the\n * agent-runtime tool wrapper installed by `resolveAiAgentTools`) turn this\n * into a tool-call failure that the model surfaces back to the user without\n * leaking internals. The runtime NEVER reaches this helper when the agent\n * is declared read-only \u2014 the policy gate rejects the tool call upstream \u2014\n * but we keep the fail-closed check as a defensive guard.\n */\nexport class AiMutationPreparationError extends Error {\n constructor(\n public readonly code:\n | 'not_a_mutation_tool'\n | 'read_only_agent'\n | 'tenant_scope_missing'\n | 'container_missing'\n | 'em_missing',\n message: string,\n ) {\n super(message)\n this.name = 'AiMutationPreparationError'\n }\n}\n\nexport interface PrepareMutationInput {\n agent: AiAgentDefinition\n tool: AiToolDefinition\n toolCallArgs: Record<string, unknown>\n conversationId?: string | null\n /**\n * Optional downgrade the caller already resolved (mirror of\n * `resolveAiAgentTools({ mutationPolicyOverride })`). When omitted, the\n * agent's code-declared policy stands alone.\n */\n mutationPolicyOverride?: AiAgentMutationPolicy | null\n /**\n * Deterministic clock hook for tests. Defaults to `new Date()`.\n */\n now?: Date\n}\n\nexport interface PrepareMutationContext extends AiChatRequestContext {\n container: AwilixContainer\n}\n\nexport interface PrepareMutationResult {\n uiPart: AiUiPart\n pendingAction: AiPendingAction\n}\n\nconst MUTATION_PREVIEW_CARD_COMPONENT_ID = 'mutation-preview-card'\n\nconst NO_RESOLVER_SIDE_EFFECTS_MESSAGE =\n 'Tool did not declare a field-diff resolver; action will proceed without a preview.'\n\nfunction assertTenantScope(ctx: PrepareMutationContext): string {\n if (!ctx.tenantId) {\n throw new AiMutationPreparationError(\n 'tenant_scope_missing',\n 'prepareMutation requires a tenant-scoped request context.',\n )\n }\n return ctx.tenantId\n}\n\nfunction resolveEm(container: AwilixContainer): EntityManager {\n if (!container) {\n throw new AiMutationPreparationError(\n 'container_missing',\n 'prepareMutation requires an Awilix container to resolve the EntityManager.',\n )\n }\n let em: EntityManager | null = null\n try {\n em = container.resolve<EntityManager>('em')\n } catch {\n em = null\n }\n if (!em) {\n throw new AiMutationPreparationError(\n 'em_missing',\n 'prepareMutation could not resolve \"em\" from the container.',\n )\n }\n return em\n}\n\nfunction toolHandlerContext(ctx: PrepareMutationContext): McpToolContext {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n container: ctx.container,\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n}\n\nfunction safeStringify(value: unknown): string {\n const seen = new WeakSet<object>()\n return JSON.stringify(value, (_key, raw) => {\n if (raw && typeof raw === 'object') {\n if (seen.has(raw as object)) return '[Circular]'\n seen.add(raw as object)\n const entries = Object.entries(raw as Record<string, unknown>)\n entries.sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))\n return entries.reduce<Record<string, unknown>>((acc, [k, v]) => {\n acc[k] = v\n return acc\n }, {})\n }\n return raw\n })\n}\n\n/**\n * Hashes `(tenantId, orgId, agentId, conversationId, toolName, normalizedInput)`\n * into a stable SHA-256 digest so that retries of the same tool call with the\n * same payload collapse to a single `AiPendingAction` row inside the TTL\n * window. The input is normalized through `safeStringify` to make object key\n * order irrelevant (spec \u00A78 rule `idempotencyKey prevents double-submission`).\n * Attachments are NOT included \u2014 the attachment set is captured separately on\n * the pending row so that re-uploading the same file set with a different\n * tool-call object never accidentally collides.\n */\nexport function computeMutationIdempotencyKey(input: {\n tenantId: string\n organizationId: string | null\n agentId: string\n conversationId: string | null\n toolName: string\n normalizedInput: Record<string, unknown>\n}): string {\n const canonical = safeStringify({\n tenant: input.tenantId,\n org: input.organizationId ?? null,\n agent: input.agentId,\n conversation: input.conversationId ?? null,\n tool: input.toolName,\n input: input.normalizedInput ?? {},\n })\n return createHash('sha256').update(canonical).digest('hex')\n}\n\nfunction computeFieldDiff(\n before: Record<string, unknown>,\n after: Record<string, unknown>,\n display?: AiToolFieldDiffDisplayHints,\n options?: { keys?: Iterable<string> },\n): AiPendingActionFieldDiff[] {\n const diff: AiPendingActionFieldDiff[] = []\n const keys = options?.keys\n ? new Set<string>(options.keys)\n : new Set<string>([\n ...Object.keys(before ?? {}),\n ...Object.keys(after ?? {}),\n ])\n for (const field of keys) {\n const beforeValue = before ? before[field] : undefined\n const afterValue = after ? after[field] : undefined\n if (!Object.is(beforeValue, afterValue) && safeStringify(beforeValue) !== safeStringify(afterValue)) {\n const fieldLabel = display?.fieldLabels?.[field]\n const beforeDisplay = display?.before?.[field]\n const afterDisplay = display?.after?.[field]\n diff.push({\n field,\n ...(fieldLabel !== undefined ? { fieldLabel } : {}),\n before: beforeValue,\n after: afterValue,\n ...(beforeDisplay !== undefined ? { beforeDisplay } : {}),\n ...(afterDisplay !== undefined ? { afterDisplay } : {}),\n })\n }\n }\n return diff\n}\n\nfunction extractPatchFromArgs(\n args: Record<string, unknown>,\n): Record<string, unknown> {\n const raw = args?.patch\n if (raw && typeof raw === 'object' && !Array.isArray(raw)) {\n return raw as Record<string, unknown>\n }\n // Fall back: treat the whole args object (minus well-known envelope keys)\n // as the patch. This preserves compatibility with tools whose schema is\n // flat (`{ productId, name }`) rather than nested (`{ productId, patch }`).\n const envelope = new Set([\n 'id',\n 'recordId',\n 'records',\n 'attachmentIds',\n '_sessionToken',\n ])\n const reduced: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(args ?? {})) {\n if (envelope.has(key)) continue\n reduced[key] = value\n }\n return reduced\n}\n\nfunction matchBatchPatch(\n args: Record<string, unknown>,\n recordId: string,\n): Record<string, unknown> {\n const rawList = args?.records\n if (Array.isArray(rawList)) {\n const match = rawList.find((entry) => {\n if (!entry || typeof entry !== 'object') return false\n const candidate = entry as Record<string, unknown>\n return candidate.recordId === recordId || candidate.id === recordId\n })\n if (match && typeof match === 'object') {\n const patch = (match as Record<string, unknown>).patch\n if (patch && typeof patch === 'object' && !Array.isArray(patch)) {\n return patch as Record<string, unknown>\n }\n const envelope = new Set(['recordId', 'id'])\n const reduced: Record<string, unknown> = {}\n for (const [key, value] of Object.entries(match as Record<string, unknown>)) {\n if (envelope.has(key)) continue\n reduced[key] = value\n }\n return reduced\n }\n }\n return {}\n}\n\nfunction fallbackPatchDiffKeys(\n patch: Record<string, unknown>,\n before: Record<string, unknown>,\n): string[] {\n return Object.keys(patch ?? {}).filter((key) =>\n Object.prototype.hasOwnProperty.call(before ?? {}, key),\n )\n}\n\nfunction normalizeAttachmentIds(args: Record<string, unknown>): string[] {\n const raw = args?.attachmentIds\n if (!Array.isArray(raw)) return []\n return raw.filter((value): value is string => typeof value === 'string' && value.length > 0)\n}\n\nasync function buildSingleRecordDiff(\n tool: AiToolDefinition,\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<{\n fieldDiff: AiPendingActionFieldDiff[]\n targetEntityType: string | null\n targetRecordId: string | null\n recordVersion: string | null\n sideEffectsSummary: string | null\n}> {\n const resolver = tool.loadBeforeRecord\n if (!resolver) {\n logger.warn('prepareMutation: tool declared isMutation=true but no loadBeforeRecord resolver; shipping empty fieldDiff', { toolName: tool.name })\n return {\n fieldDiff: [],\n targetEntityType: null,\n targetRecordId: null,\n recordVersion: null,\n sideEffectsSummary: NO_RESOLVER_SIDE_EFFECTS_MESSAGE,\n }\n }\n const handlerContext = toolHandlerContext(ctx)\n const before: AiToolLoadBeforeSingleRecord | null = await resolver(\n input.toolCallArgs as never,\n handlerContext,\n )\n if (!before) {\n return {\n fieldDiff: [],\n targetEntityType: null,\n targetRecordId: null,\n recordVersion: null,\n sideEffectsSummary: null,\n }\n }\n const hasExplicitAfter = before.after != null\n const patch = before.after ?? extractPatchFromArgs(input.toolCallArgs)\n const fieldDiff = computeFieldDiff(\n before.before,\n patch,\n before.display,\n hasExplicitAfter ? undefined : { keys: fallbackPatchDiffKeys(patch, before.before) },\n )\n return {\n fieldDiff,\n targetEntityType: before.entityType,\n targetRecordId: before.recordId,\n recordVersion: before.recordVersion,\n sideEffectsSummary: null,\n }\n}\n\nasync function buildBatchRecords(\n tool: AiToolDefinition,\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<{\n records: AiPendingActionRecordDiff[] | null\n targetEntityType: string | null\n sideEffectsSummary: string | null\n}> {\n const resolver = tool.loadBeforeRecords\n if (!resolver) {\n logger.warn('prepareMutation: bulk tool declared isMutation=true but no loadBeforeRecords resolver; shipping empty records[]', { toolName: tool.name })\n return {\n records: null,\n targetEntityType: null,\n sideEffectsSummary: NO_RESOLVER_SIDE_EFFECTS_MESSAGE,\n }\n }\n const handlerContext = toolHandlerContext(ctx)\n const rows: AiToolLoadBeforeRecord[] = await resolver(\n input.toolCallArgs as never,\n handlerContext,\n )\n if (!Array.isArray(rows) || rows.length === 0) {\n return {\n records: null,\n targetEntityType: null,\n sideEffectsSummary: null,\n }\n }\n const diffs: AiPendingActionRecordDiff[] = rows.map((row) => {\n const hasExplicitAfter = row.after != null\n const patch = row.after ?? matchBatchPatch(input.toolCallArgs, row.recordId)\n return {\n recordId: row.recordId,\n entityType: row.entityType,\n label: row.label,\n fieldDiff: computeFieldDiff(\n row.before,\n patch,\n row.display,\n hasExplicitAfter ? undefined : { keys: fallbackPatchDiffKeys(patch, row.before) },\n ),\n recordVersion: row.recordVersion ?? null,\n }\n })\n const [firstEntity] = rows\n return {\n records: diffs,\n targetEntityType: firstEntity ? firstEntity.entityType : null,\n sideEffectsSummary: null,\n }\n}\n\n/**\n * Intercepts a mutation tool call and turns it into an `AiPendingAction` +\n * `mutation-preview-card` UI part (spec Phase 3 WS-C \u00A79). The caller MUST\n * have already confirmed the agent's effective `mutationPolicy` is NOT\n * `read-only`; this helper repeats the check defensively because skipping it\n * would be a policy-bypass.\n *\n * The tool handler is NEVER invoked by this function \u2014 the write is\n * short-circuited and only runs from the Step 5.8 confirm route. See the\n * unit test `does not call the tool handler` for the guard.\n */\nexport async function prepareMutation(\n input: PrepareMutationInput,\n ctx: PrepareMutationContext,\n): Promise<PrepareMutationResult> {\n const { agent, tool } = input\n if (tool.isMutation !== true) {\n throw new AiMutationPreparationError(\n 'not_a_mutation_tool',\n `Tool \"${tool.name}\" is not a mutation tool; prepareMutation should not be invoked.`,\n )\n }\n const effectivePolicy = resolveEffectiveMutationPolicy(\n agent.mutationPolicy,\n input.mutationPolicyOverride ?? null,\n agent.id,\n )\n if (effectivePolicy === 'read-only') {\n throw new AiMutationPreparationError(\n 'read_only_agent',\n `Agent \"${agent.id}\" has effective mutationPolicy=read-only; mutation tool \"${tool.name}\" cannot be prepared.`,\n )\n }\n\n const tenantId = assertTenantScope(ctx)\n const em = resolveEm(ctx.container)\n const repo = new AiPendingActionRepository(em)\n\n const isBulk = tool.isBulk === true\n let fieldDiff: AiPendingActionFieldDiff[] = []\n let records: AiPendingActionRecordDiff[] | null = null\n let targetEntityType: string | null = null\n let targetRecordId: string | null = null\n let recordVersion: string | null = null\n let sideEffectsSummary: string | null = null\n\n if (isBulk) {\n const batch = await buildBatchRecords(tool, input, ctx)\n records = batch.records\n targetEntityType = batch.targetEntityType\n sideEffectsSummary = batch.sideEffectsSummary\n } else {\n const single = await buildSingleRecordDiff(tool, input, ctx)\n fieldDiff = single.fieldDiff\n targetEntityType = single.targetEntityType\n targetRecordId = single.targetRecordId\n recordVersion = single.recordVersion\n sideEffectsSummary = single.sideEffectsSummary\n }\n\n const normalizedInput = input.toolCallArgs ?? {}\n const conversationId = input.conversationId ?? null\n const idempotencyKey = computeMutationIdempotencyKey({\n tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId: agent.id,\n conversationId,\n toolName: tool.name,\n normalizedInput,\n })\n\n const pendingAction = await repo.create(\n {\n agentId: agent.id,\n toolName: tool.name,\n idempotencyKey,\n createdByUserId: ctx.userId,\n normalizedInput,\n conversationId,\n targetEntityType,\n targetRecordId,\n fieldDiff,\n records,\n sideEffectsSummary,\n recordVersion,\n attachmentIds: normalizeAttachmentIds(normalizedInput),\n now: input.now,\n },\n {\n tenantId,\n organizationId: ctx.organizationId ?? null,\n userId: ctx.userId,\n },\n )\n\n const uiPart: AiUiPart = {\n componentId: MUTATION_PREVIEW_CARD_COMPONENT_ID,\n props: {\n pendingActionId: pendingAction.id,\n expiresAt: pendingAction.expiresAt.toISOString(),\n ...(records ? { records } : { fieldDiff }),\n ...(sideEffectsSummary ? { sideEffectsSummary } : {}),\n },\n }\n\n return { uiPart, pendingAction }\n}\n\nexport const MUTATION_PREVIEW_CARD_COMPONENT = MUTATION_PREVIEW_CARD_COMPONENT_ID\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ai-assistant",
3
- "version": "0.9.1-develop.7519.1.38f51f8dbb",
3
+ "version": "0.9.1-develop.7536.1.f9a5776a07",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "engines": {
@@ -100,16 +100,16 @@
100
100
  "zod-to-json-schema": "^3.25.2"
101
101
  },
102
102
  "peerDependencies": {
103
- "@open-mercato/shared": "0.9.1-develop.7519.1.38f51f8dbb",
104
- "@open-mercato/ui": "0.9.1-develop.7519.1.38f51f8dbb",
103
+ "@open-mercato/shared": "0.9.1-develop.7536.1.f9a5776a07",
104
+ "@open-mercato/ui": "0.9.1-develop.7536.1.f9a5776a07",
105
105
  "react": "^19.0.0",
106
106
  "react-dom": "^19.0.0",
107
107
  "zod": ">=3.23.0"
108
108
  },
109
109
  "devDependencies": {
110
- "@open-mercato/cli": "0.9.1-develop.7519.1.38f51f8dbb",
111
- "@open-mercato/shared": "0.9.1-develop.7519.1.38f51f8dbb",
112
- "@open-mercato/ui": "0.9.1-develop.7519.1.38f51f8dbb",
110
+ "@open-mercato/cli": "0.9.1-develop.7536.1.f9a5776a07",
111
+ "@open-mercato/shared": "0.9.1-develop.7536.1.f9a5776a07",
112
+ "@open-mercato/ui": "0.9.1-develop.7536.1.f9a5776a07",
113
113
  "@types/react": "^19.2.18",
114
114
  "@types/react-dom": "^19.2.5",
115
115
  "react": "19.2.8",
@@ -250,14 +250,14 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
250
250
  expect(repoGetByIdMock).not.toHaveBeenCalled()
251
251
  })
252
252
 
253
- it('whitespace-only reason becomes empty → default "Cancelled by user" message', async () => {
253
+ it('whitespace-only reason becomes empty (no hardcoded English copy)', async () => {
254
254
  repoGetByIdMock.mockResolvedValueOnce(makeRow())
255
255
 
256
256
  const response = await POST(buildRequest({ reason: ' \t\n ' }) as any, buildContext(PENDING_ACTION_ID))
257
257
  expect(response.status).toBe(200)
258
258
  const body = await response.json()
259
259
  expect(body.pendingAction.executionResult).toEqual({
260
- error: { code: 'cancelled_by_user', message: 'Cancelled by user' },
260
+ error: { code: 'cancelled_by_user', message: '' },
261
261
  })
262
262
  })
263
263
 
@@ -288,7 +288,7 @@ describe('POST /api/ai/actions/:id/cancel route (Step 5.9)', () => {
288
288
  const body = await response.json()
289
289
  expect(body.ok).toBe(true)
290
290
  expect(body.pendingAction.executionResult).toEqual({
291
- error: { code: 'cancelled_by_user', message: 'Cancelled by user' },
291
+ error: { code: 'cancelled_by_user', message: '' },
292
292
  })
293
293
  })
294
294
 
@@ -229,6 +229,8 @@
229
229
  "ai_assistant.chat.mutation_cards.preview.handlerError": "Der Mutations-Handler meldete einen Fehler. Prüfen Sie die Details und schlagen Sie die Aktion ggf. erneut vor.",
230
230
  "ai_assistant.chat.mutation_cards.preview.reviewDetails": "Details ansehen",
231
231
  "ai_assistant.chat.mutation_cards.preview.title": "Vorgeschlagene Änderungen prüfen",
232
+ "ai_assistant.chat.mutation_cards.result.cancelledBody": "Sie haben diese vorgeschlagene Änderung abgebrochen. Es wurden keine Änderungen vorgenommen.",
233
+ "ai_assistant.chat.mutation_cards.result.cancelledTitle": "Vorschlag abgebrochen",
232
234
  "ai_assistant.chat.mutation_cards.result.failureBody": "Die Änderung konnte nicht angewendet werden.",
233
235
  "ai_assistant.chat.mutation_cards.result.failureTitle": "Aktion fehlgeschlagen",
234
236
  "ai_assistant.chat.mutation_cards.result.fixWithAi": "Mit KI korrigieren",
@@ -229,6 +229,8 @@
229
229
  "ai_assistant.chat.mutation_cards.preview.handlerError": "The mutation handler reported an error. Review the details and re-propose if needed.",
230
230
  "ai_assistant.chat.mutation_cards.preview.reviewDetails": "Review details",
231
231
  "ai_assistant.chat.mutation_cards.preview.title": "Review proposed changes",
232
+ "ai_assistant.chat.mutation_cards.result.cancelledBody": "You cancelled this proposed change. No changes were made.",
233
+ "ai_assistant.chat.mutation_cards.result.cancelledTitle": "Proposal cancelled",
232
234
  "ai_assistant.chat.mutation_cards.result.failureBody": "The mutation could not be applied.",
233
235
  "ai_assistant.chat.mutation_cards.result.failureTitle": "Action failed",
234
236
  "ai_assistant.chat.mutation_cards.result.fixWithAi": "Fix with AI",
@@ -229,6 +229,8 @@
229
229
  "ai_assistant.chat.mutation_cards.preview.handlerError": "El gestor de mutaciones informó un error. Revisa los detalles y vuelve a proponer la acción si es necesario.",
230
230
  "ai_assistant.chat.mutation_cards.preview.reviewDetails": "Ver detalles",
231
231
  "ai_assistant.chat.mutation_cards.preview.title": "Revisar los cambios propuestos",
232
+ "ai_assistant.chat.mutation_cards.result.cancelledBody": "Cancelaste este cambio propuesto. No se realizó ningún cambio.",
233
+ "ai_assistant.chat.mutation_cards.result.cancelledTitle": "Propuesta cancelada",
232
234
  "ai_assistant.chat.mutation_cards.result.failureBody": "No se pudo aplicar la mutación.",
233
235
  "ai_assistant.chat.mutation_cards.result.failureTitle": "La acción falló",
234
236
  "ai_assistant.chat.mutation_cards.result.fixWithAi": "Corregir con IA",
@@ -229,6 +229,8 @@
229
229
  "ai_assistant.chat.mutation_cards.preview.handlerError": "뮤테이션 핸들러가 오류를 보고했습니다. 세부 정보를 검토하고 필요하면 다시 제안하세요.",
230
230
  "ai_assistant.chat.mutation_cards.preview.reviewDetails": "세부 정보 검토",
231
231
  "ai_assistant.chat.mutation_cards.preview.title": "제안된 변경 사항 검토",
232
+ "ai_assistant.chat.mutation_cards.result.cancelledBody": "이 제안된 변경을 취소했습니다. 변경 사항이 적용되지 않았습니다.",
233
+ "ai_assistant.chat.mutation_cards.result.cancelledTitle": "제안이 취소됨",
232
234
  "ai_assistant.chat.mutation_cards.result.failureBody": "뮤테이션을 적용할 수 없습니다.",
233
235
  "ai_assistant.chat.mutation_cards.result.failureTitle": "작업 실패",
234
236
  "ai_assistant.chat.mutation_cards.result.fixWithAi": "AI로 수정",
@@ -229,6 +229,8 @@
229
229
  "ai_assistant.chat.mutation_cards.preview.handlerError": "Handler mutacji zgłosił błąd. Sprawdź szczegóły i w razie potrzeby zaproponuj akcję ponownie.",
230
230
  "ai_assistant.chat.mutation_cards.preview.reviewDetails": "Pokaż szczegóły",
231
231
  "ai_assistant.chat.mutation_cards.preview.title": "Sprawdź proponowane zmiany",
232
+ "ai_assistant.chat.mutation_cards.result.cancelledBody": "Anulowano tę proponowaną zmianę. Nie wprowadzono żadnych zmian.",
233
+ "ai_assistant.chat.mutation_cards.result.cancelledTitle": "Propozycja anulowana",
232
234
  "ai_assistant.chat.mutation_cards.result.failureBody": "Nie udało się zastosować zmiany.",
233
235
  "ai_assistant.chat.mutation_cards.result.failureTitle": "Akcja nie powiodła się",
234
236
  "ai_assistant.chat.mutation_cards.result.fixWithAi": "Popraw z AI",
@@ -147,7 +147,7 @@ describe('executePendingActionCancel', () => {
147
147
  expect(typeof emittedPayload.resolvedAt).toBe('string')
148
148
  })
149
149
 
150
- it('defaults to "Cancelled by user" message when no reason is supplied', async () => {
150
+ it('stores an empty message when no reason is supplied (no hardcoded English copy)', async () => {
151
151
  const repo = makeRepoStub(makeAction())
152
152
  const emitEvent = jest.fn().mockResolvedValue(undefined)
153
153
  const clock = new Date('2026-04-18T10:05:00.000Z')
@@ -164,7 +164,7 @@ describe('executePendingActionCancel', () => {
164
164
  const [, , , extra] = repo.setStatus.mock.calls[0]
165
165
  expect(extra).toMatchObject({
166
166
  executionResult: {
167
- error: { code: 'cancelled_by_user', message: 'Cancelled by user' },
167
+ error: { code: 'cancelled_by_user', message: '' },
168
168
  },
169
169
  })
170
170
  const [, payload] = emitEvent.mock.calls[0] as [string, AiActionCancelledPayload]
@@ -285,6 +285,86 @@ describe('prepareMutation', () => {
285
285
  expect(pendingAction.organizationId).toBe('org-alpha')
286
286
  })
287
287
 
288
+ it('fallback patch from flat args: untouched snapshot fields and envelope keys do not surface as cleared (regression #7349)', async () => {
289
+ const em = mockEm()
290
+ const container = makeContainer(em)
291
+ const tool = makeTool({
292
+ name: 'catalog.update_product',
293
+ isMutation: true,
294
+ loadBeforeRecord: async () => ({
295
+ recordId: 'p-1',
296
+ entityType: 'catalog.product',
297
+ recordVersion: 'v-1',
298
+ before: {
299
+ title: 'Old title',
300
+ subtitle: null,
301
+ description: 'Old description',
302
+ sku: 'SKU-1',
303
+ handle: null,
304
+ isActive: true,
305
+ primaryCurrencyCode: null,
306
+ },
307
+ }),
308
+ })
309
+ const agent = makeAgent({ id: 'catalog.merch' })
310
+
311
+ const { uiPart } = await prepareMutation(
312
+ {
313
+ agent,
314
+ tool,
315
+ toolCallArgs: { productId: 'p-1', title: 'New title' },
316
+ },
317
+ { ...baseCtx, container },
318
+ )
319
+
320
+ expect(uiPart.props.fieldDiff).toEqual([
321
+ { field: 'title', before: 'Old title', after: 'New title' },
322
+ ])
323
+ })
324
+
325
+ it('batch fallback patch from flat args: untouched snapshot fields do not surface as cleared (regression #7349)', async () => {
326
+ const em = mockEm()
327
+ const container = makeContainer(em)
328
+ const tool = makeTool({
329
+ name: 'catalog.bulk_update_products',
330
+ isMutation: true,
331
+ isBulk: true,
332
+ loadBeforeRecords: async () => [
333
+ {
334
+ recordId: 'p-1',
335
+ entityType: 'catalog.product',
336
+ label: 'Widget',
337
+ recordVersion: 'v-1',
338
+ before: {
339
+ title: 'Old title',
340
+ subtitle: null,
341
+ description: 'Old description',
342
+ sku: 'SKU-1',
343
+ },
344
+ },
345
+ ],
346
+ })
347
+ const agent = makeAgent({ id: 'catalog.merch' })
348
+
349
+ const { pendingAction } = await prepareMutation(
350
+ {
351
+ agent,
352
+ tool,
353
+ toolCallArgs: {
354
+ records: [{ recordId: 'p-1', title: 'New title' }],
355
+ },
356
+ },
357
+ { ...baseCtx, container },
358
+ )
359
+
360
+ const records = pendingAction.records as Array<{
361
+ fieldDiff: Array<{ field: string; before: unknown; after: unknown }>
362
+ }>
363
+ expect(records[0].fieldDiff).toEqual([
364
+ { field: 'title', before: 'Old title', after: 'New title' },
365
+ ])
366
+ })
367
+
288
368
  it('uses resolver-provided after snapshots and display labels for operator previews', async () => {
289
369
  const em = mockEm()
290
370
  const container = makeContainer(em)
@@ -379,11 +459,9 @@ describe('prepareMutation', () => {
379
459
  expect(records).toHaveLength(2)
380
460
  expect(records[0].recordId).toBe('p-1')
381
461
  expect(records[0].label).toBe('Widget')
382
- // Patch only carries `name` — keys present in `before` but not in `after`
383
- // still surface as diff entries with after=undefined (they are "cleared").
384
- expect(records[0].fieldDiff).toEqual(
385
- expect.arrayContaining([{ field: 'name', before: 'old', after: 'new-1' }]),
386
- )
462
+ expect(records[0].fieldDiff).toEqual([
463
+ { field: 'name', before: 'old', after: 'new-1' },
464
+ ])
387
465
  expect(records[1].fieldDiff).toEqual(
388
466
  expect.arrayContaining([
389
467
  { field: 'name', before: 'gadget-old', after: 'new-2' },
@@ -151,7 +151,11 @@ export async function executePendingActionCancel(
151
151
  const executionResult: AiPendingActionExecutionResult = {
152
152
  error: {
153
153
  code: 'cancelled_by_user',
154
- message: trimmedReason.length > 0 ? trimmedReason : 'Cancelled by user',
154
+ // Left empty when no reason is supplied: this is surfaced as a
155
+ // neutral "proposal cancelled" message in the client, which
156
+ // supplies its own localized default copy (see MutationResultCard)
157
+ // instead of a hardcoded English string.
158
+ message: trimmedReason,
155
159
  },
156
160
  }
157
161
 
@@ -168,12 +168,15 @@ function computeFieldDiff(
168
168
  before: Record<string, unknown>,
169
169
  after: Record<string, unknown>,
170
170
  display?: AiToolFieldDiffDisplayHints,
171
+ options?: { keys?: Iterable<string> },
171
172
  ): AiPendingActionFieldDiff[] {
172
173
  const diff: AiPendingActionFieldDiff[] = []
173
- const keys = new Set<string>([
174
- ...Object.keys(before ?? {}),
175
- ...Object.keys(after ?? {}),
176
- ])
174
+ const keys = options?.keys
175
+ ? new Set<string>(options.keys)
176
+ : new Set<string>([
177
+ ...Object.keys(before ?? {}),
178
+ ...Object.keys(after ?? {}),
179
+ ])
177
180
  for (const field of keys) {
178
181
  const beforeValue = before ? before[field] : undefined
179
182
  const afterValue = after ? after[field] : undefined
@@ -247,6 +250,15 @@ function matchBatchPatch(
247
250
  return {}
248
251
  }
249
252
 
253
+ function fallbackPatchDiffKeys(
254
+ patch: Record<string, unknown>,
255
+ before: Record<string, unknown>,
256
+ ): string[] {
257
+ return Object.keys(patch ?? {}).filter((key) =>
258
+ Object.prototype.hasOwnProperty.call(before ?? {}, key),
259
+ )
260
+ }
261
+
250
262
  function normalizeAttachmentIds(args: Record<string, unknown>): string[] {
251
263
  const raw = args?.attachmentIds
252
264
  if (!Array.isArray(raw)) return []
@@ -289,8 +301,14 @@ async function buildSingleRecordDiff(
289
301
  sideEffectsSummary: null,
290
302
  }
291
303
  }
304
+ const hasExplicitAfter = before.after != null
292
305
  const patch = before.after ?? extractPatchFromArgs(input.toolCallArgs)
293
- const fieldDiff = computeFieldDiff(before.before, patch, before.display)
306
+ const fieldDiff = computeFieldDiff(
307
+ before.before,
308
+ patch,
309
+ before.display,
310
+ hasExplicitAfter ? undefined : { keys: fallbackPatchDiffKeys(patch, before.before) },
311
+ )
294
312
  return {
295
313
  fieldDiff,
296
314
  targetEntityType: before.entityType,
@@ -331,12 +349,18 @@ async function buildBatchRecords(
331
349
  }
332
350
  }
333
351
  const diffs: AiPendingActionRecordDiff[] = rows.map((row) => {
352
+ const hasExplicitAfter = row.after != null
334
353
  const patch = row.after ?? matchBatchPatch(input.toolCallArgs, row.recordId)
335
354
  return {
336
355
  recordId: row.recordId,
337
356
  entityType: row.entityType,
338
357
  label: row.label,
339
- fieldDiff: computeFieldDiff(row.before, patch, row.display),
358
+ fieldDiff: computeFieldDiff(
359
+ row.before,
360
+ patch,
361
+ row.display,
362
+ hasExplicitAfter ? undefined : { keys: fallbackPatchDiffKeys(patch, row.before) },
363
+ ),
340
364
  recordVersion: row.recordVersion ?? null,
341
365
  }
342
366
  })