@open-mercato/ai-assistant 0.9.1-develop.7519.1.38f51f8dbb → 0.9.1-develop.7532.1.8eac7e198f

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.
@@ -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": "AAAA,SAAS,oBAAoB;AAC7B,SAAS,kBAAkB;AAY3B,SAAS,sCAAsC;AAC/C,SAAS,iCAAiC;AAO1C,MAAM,SAAS,aAAa,cAAc;AAUnC,MAAM,mCAAmC,MAAM;AAAA,EACpD,YACkB,MAMhB,SACA;AACA,UAAM,OAAO;AARG;AAShB,SAAK,OAAO;AAAA,EACd;AACF;AA4BA,MAAM,qCAAqC;AAE3C,MAAM,mCACJ;AAEF,SAAS,kBAAkB,KAAqC;AAC9D,MAAI,CAAC,IAAI,UAAU;AACjB,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,SAAO,IAAI;AACb;AAEA,SAAS,UAAU,WAA2C;AAC5D,MAAI,CAAC,WAAW;AACd,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,MAAI,KAA2B;AAC/B,MAAI;AACF,SAAK,UAAU,QAAuB,IAAI;AAAA,EAC5C,QAAQ;AACN,SAAK;AAAA,EACP;AACA,MAAI,CAAC,IAAI;AACP,UAAM,IAAI;AAAA,MACR;AAAA,MACA;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,mBAAmB,KAA6C;AACvE,SAAO;AAAA,IACL,UAAU,IAAI;AAAA,IACd,gBAAgB,IAAI;AAAA,IACpB,QAAQ,IAAI;AAAA,IACZ,WAAW,IAAI;AAAA,IACf,cAAc,IAAI;AAAA,IAClB,cAAc,IAAI;AAAA,EACpB;AACF;AAEA,SAAS,cAAc,OAAwB;AAC7C,QAAM,OAAO,oBAAI,QAAgB;AACjC,SAAO,KAAK,UAAU,OAAO,CAAC,MAAM,QAAQ;AAC1C,QAAI,OAAO,OAAO,QAAQ,UAAU;AAClC,UAAI,KAAK,IAAI,GAAa,EAAG,QAAO;AACpC,WAAK,IAAI,GAAa;AACtB,YAAM,UAAU,OAAO,QAAQ,GAA8B;AAC7D,cAAQ,KAAK,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,MAAO,IAAI,IAAI,KAAK,IAAI,IAAI,IAAI,CAAE;AACvD,aAAO,QAAQ,OAAgC,CAAC,KAAK,CAAC,GAAG,CAAC,MAAM;AAC9D,YAAI,CAAC,IAAI;AACT,eAAO;AAAA,MACT,GAAG,CAAC,CAAC;AAAA,IACP;AACA,WAAO;AAAA,EACT,CAAC;AACH;AAYO,SAAS,8BAA8B,OAOnC;AACT,QAAM,YAAY,cAAc;AAAA,IAC9B,QAAQ,MAAM;AAAA,IACd,KAAK,MAAM,kBAAkB;AAAA,IAC7B,OAAO,MAAM;AAAA,IACb,cAAc,MAAM,kBAAkB;AAAA,IACtC,MAAM,MAAM;AAAA,IACZ,OAAO,MAAM,mBAAmB,CAAC;AAAA,EACnC,CAAC;AACD,SAAO,WAAW,QAAQ,EAAE,OAAO,SAAS,EAAE,OAAO,KAAK;AAC5D;AAEA,SAAS,iBACP,QACA,OACA,SAC4B;AAC5B,QAAM,OAAmC,CAAC;AAC1C,QAAM,OAAO,oBAAI,IAAY;AAAA,IAC3B,GAAG,OAAO,KAAK,UAAU,CAAC,CAAC;AAAA,IAC3B,GAAG,OAAO,KAAK,SAAS,CAAC,CAAC;AAAA,EAC5B,CAAC;AACD,aAAW,SAAS,MAAM;AACxB,UAAM,cAAc,SAAS,OAAO,KAAK,IAAI;AAC7C,UAAM,aAAa,QAAQ,MAAM,KAAK,IAAI;AAC1C,QAAI,CAAC,OAAO,GAAG,aAAa,UAAU,KAAK,cAAc,WAAW,MAAM,cAAc,UAAU,GAAG;AACnG,YAAM,aAAa,SAAS,cAAc,KAAK;AAC/C,YAAM,gBAAgB,SAAS,SAAS,KAAK;AAC7C,YAAM,eAAe,SAAS,QAAQ,KAAK;AAC3C,WAAK,KAAK;AAAA,QACR;AAAA,QACA,GAAI,eAAe,SAAY,EAAE,WAAW,IAAI,CAAC;AAAA,QACjD,QAAQ;AAAA,QACR,OAAO;AAAA,QACP,GAAI,kBAAkB,SAAY,EAAE,cAAc,IAAI,CAAC;AAAA,QACvD,GAAI,iBAAiB,SAAY,EAAE,aAAa,IAAI,CAAC;AAAA,MACvD,CAAC;AAAA,IACH;AAAA,EACF;AACA,SAAO;AACT;AAEA,SAAS,qBACP,MACyB;AACzB,QAAM,MAAM,MAAM;AAClB,MAAI,OAAO,OAAO,QAAQ,YAAY,CAAC,MAAM,QAAQ,GAAG,GAAG;AACzD,WAAO;AAAA,EACT;AAIA,QAAM,WAAW,oBAAI,IAAI;AAAA,IACvB;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,IACA;AAAA,EACF,CAAC;AACD,QAAM,UAAmC,CAAC;AAC1C,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,QAAQ,CAAC,CAAC,GAAG;AACrD,QAAI,SAAS,IAAI,GAAG,EAAG;AACvB,YAAQ,GAAG,IAAI;AAAA,EACjB;AACA,SAAO;AACT;AAEA,SAAS,gBACP,MACA,UACyB;AACzB,QAAM,UAAU,MAAM;AACtB,MAAI,MAAM,QAAQ,OAAO,GAAG;AAC1B,UAAM,QAAQ,QAAQ,KAAK,CAAC,UAAU;AACpC,UAAI,CAAC,SAAS,OAAO,UAAU,SAAU,QAAO;AAChD,YAAM,YAAY;AAClB,aAAO,UAAU,aAAa,YAAY,UAAU,OAAO;AAAA,IAC7D,CAAC;AACD,QAAI,SAAS,OAAO,UAAU,UAAU;AACtC,YAAM,QAAS,MAAkC;AACjD,UAAI,SAAS,OAAO,UAAU,YAAY,CAAC,MAAM,QAAQ,KAAK,GAAG;AAC/D,eAAO;AAAA,MACT;AACA,YAAM,WAAW,oBAAI,IAAI,CAAC,YAAY,IAAI,CAAC;AAC3C,YAAM,UAAmC,CAAC;AAC1C,iBAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,KAAgC,GAAG;AAC3E,YAAI,SAAS,IAAI,GAAG,EAAG;AACvB,gBAAQ,GAAG,IAAI;AAAA,MACjB;AACA,aAAO;AAAA,IACT;AAAA,EACF;AACA,SAAO,CAAC;AACV;AAEA,SAAS,uBAAuB,MAAyC;AACvE,QAAM,MAAM,MAAM;AAClB,MAAI,CAAC,MAAM,QAAQ,GAAG,EAAG,QAAO,CAAC;AACjC,SAAO,IAAI,OAAO,CAAC,UAA2B,OAAO,UAAU,YAAY,MAAM,SAAS,CAAC;AAC7F;AAEA,eAAe,sBACb,MACA,OACA,KAOC;AACD,QAAM,WAAW,KAAK;AACtB,MAAI,CAAC,UAAU;AACb,WAAO,KAAK,6GAA6G,EAAE,UAAU,KAAK,KAAK,CAAC;AAChJ,WAAO;AAAA,MACL,WAAW,CAAC;AAAA,MACZ,kBAAkB;AAAA,MAClB,gBAAgB;AAAA,MAChB,eAAe;AAAA,MACf,oBAAoB;AAAA,IACtB;AAAA,EACF;AACA,QAAM,iBAAiB,mBAAmB,GAAG;AAC7C,QAAM,SAA8C,MAAM;AAAA,IACxD,MAAM;AAAA,IACN;AAAA,EACF;AACA,MAAI,CAAC,QAAQ;AACX,WAAO;AAAA,MACL,WAAW,CAAC;AAAA,MACZ,kBAAkB;AAAA,MAClB,gBAAgB;AAAA,MAChB,eAAe;AAAA,MACf,oBAAoB;AAAA,IACtB;AAAA,EACF;AACA,QAAM,QAAQ,OAAO,SAAS,qBAAqB,MAAM,YAAY;AACrE,QAAM,YAAY,iBAAiB,OAAO,QAAQ,OAAO,OAAO,OAAO;AACvE,SAAO;AAAA,IACL;AAAA,IACA,kBAAkB,OAAO;AAAA,IACzB,gBAAgB,OAAO;AAAA,IACvB,eAAe,OAAO;AAAA,IACtB,oBAAoB;AAAA,EACtB;AACF;AAEA,eAAe,kBACb,MACA,OACA,KAKC;AACD,QAAM,WAAW,KAAK;AACtB,MAAI,CAAC,UAAU;AACb,WAAO,KAAK,mHAAmH,EAAE,UAAU,KAAK,KAAK,CAAC;AACtJ,WAAO;AAAA,MACL,SAAS;AAAA,MACT,kBAAkB;AAAA,MAClB,oBAAoB;AAAA,IACtB;AAAA,EACF;AACA,QAAM,iBAAiB,mBAAmB,GAAG;AAC7C,QAAM,OAAiC,MAAM;AAAA,IAC3C,MAAM;AAAA,IACN;AAAA,EACF;AACA,MAAI,CAAC,MAAM,QAAQ,IAAI,KAAK,KAAK,WAAW,GAAG;AAC7C,WAAO;AAAA,MACL,SAAS;AAAA,MACT,kBAAkB;AAAA,MAClB,oBAAoB;AAAA,IACtB;AAAA,EACF;AACA,QAAM,QAAqC,KAAK,IAAI,CAAC,QAAQ;AAC3D,UAAM,QAAQ,IAAI,SAAS,gBAAgB,MAAM,cAAc,IAAI,QAAQ;AAC3E,WAAO;AAAA,MACL,UAAU,IAAI;AAAA,MACd,YAAY,IAAI;AAAA,MAChB,OAAO,IAAI;AAAA,MACX,WAAW,iBAAiB,IAAI,QAAQ,OAAO,IAAI,OAAO;AAAA,MAC1D,eAAe,IAAI,iBAAiB;AAAA,IACtC;AAAA,EACF,CAAC;AACD,QAAM,CAAC,WAAW,IAAI;AACtB,SAAO;AAAA,IACL,SAAS;AAAA,IACT,kBAAkB,cAAc,YAAY,aAAa;AAAA,IACzD,oBAAoB;AAAA,EACtB;AACF;AAaA,eAAsB,gBACpB,OACA,KACgC;AAChC,QAAM,EAAE,OAAO,KAAK,IAAI;AACxB,MAAI,KAAK,eAAe,MAAM;AAC5B,UAAM,IAAI;AAAA,MACR;AAAA,MACA,SAAS,KAAK,IAAI;AAAA,IACpB;AAAA,EACF;AACA,QAAM,kBAAkB;AAAA,IACtB,MAAM;AAAA,IACN,MAAM,0BAA0B;AAAA,IAChC,MAAM;AAAA,EACR;AACA,MAAI,oBAAoB,aAAa;AACnC,UAAM,IAAI;AAAA,MACR;AAAA,MACA,UAAU,MAAM,EAAE,4DAA4D,KAAK,IAAI;AAAA,IACzF;AAAA,EACF;AAEA,QAAM,WAAW,kBAAkB,GAAG;AACtC,QAAM,KAAK,UAAU,IAAI,SAAS;AAClC,QAAM,OAAO,IAAI,0BAA0B,EAAE;AAE7C,QAAM,SAAS,KAAK,WAAW;AAC/B,MAAI,YAAwC,CAAC;AAC7C,MAAI,UAA8C;AAClD,MAAI,mBAAkC;AACtC,MAAI,iBAAgC;AACpC,MAAI,gBAA+B;AACnC,MAAI,qBAAoC;AAExC,MAAI,QAAQ;AACV,UAAM,QAAQ,MAAM,kBAAkB,MAAM,OAAO,GAAG;AACtD,cAAU,MAAM;AAChB,uBAAmB,MAAM;AACzB,yBAAqB,MAAM;AAAA,EAC7B,OAAO;AACL,UAAM,SAAS,MAAM,sBAAsB,MAAM,OAAO,GAAG;AAC3D,gBAAY,OAAO;AACnB,uBAAmB,OAAO;AAC1B,qBAAiB,OAAO;AACxB,oBAAgB,OAAO;AACvB,yBAAqB,OAAO;AAAA,EAC9B;AAEA,QAAM,kBAAkB,MAAM,gBAAgB,CAAC;AAC/C,QAAM,iBAAiB,MAAM,kBAAkB;AAC/C,QAAM,iBAAiB,8BAA8B;AAAA,IACnD;AAAA,IACA,gBAAgB,IAAI,kBAAkB;AAAA,IACtC,SAAS,MAAM;AAAA,IACf;AAAA,IACA,UAAU,KAAK;AAAA,IACf;AAAA,EACF,CAAC;AAED,QAAM,gBAAgB,MAAM,KAAK;AAAA,IAC/B;AAAA,MACE,SAAS,MAAM;AAAA,MACf,UAAU,KAAK;AAAA,MACf;AAAA,MACA,iBAAiB,IAAI;AAAA,MACrB;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA;AAAA,MACA,eAAe,uBAAuB,eAAe;AAAA,MACrD,KAAK,MAAM;AAAA,IACb;AAAA,IACA;AAAA,MACE;AAAA,MACA,gBAAgB,IAAI,kBAAkB;AAAA,MACtC,QAAQ,IAAI;AAAA,IACd;AAAA,EACF;AAEA,QAAM,SAAmB;AAAA,IACvB,aAAa;AAAA,IACb,OAAO;AAAA,MACL,iBAAiB,cAAc;AAAA,MAC/B,WAAW,cAAc,UAAU,YAAY;AAAA,MAC/C,GAAI,UAAU,EAAE,QAAQ,IAAI,EAAE,UAAU;AAAA,MACxC,GAAI,qBAAqB,EAAE,mBAAmB,IAAI,CAAC;AAAA,IACrD;AAAA,EACF;AAEA,SAAO,EAAE,QAAQ,cAAc;AACjC;AAEO,MAAM,kCAAkC;",
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.7532.1.8eac7e198f",
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.7532.1.8eac7e198f",
104
+ "@open-mercato/ui": "0.9.1-develop.7532.1.8eac7e198f",
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.7532.1.8eac7e198f",
111
+ "@open-mercato/shared": "0.9.1-develop.7532.1.8eac7e198f",
112
+ "@open-mercato/ui": "0.9.1-develop.7532.1.8eac7e198f",
113
113
  "@types/react": "^19.2.18",
114
114
  "@types/react-dom": "^19.2.5",
115
115
  "react": "19.2.8",
@@ -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' },
@@ -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
  })