@open-mercato/ai-assistant 0.10.1-develop.7619.1.97f9a4bda8 → 0.10.1-develop.7622.1.0584ba2a04

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.
@@ -56,10 +56,14 @@ class AiPendingActionRepository {
56
56
  }
57
57
  );
58
58
  if (existing && existing.status === "pending") {
59
- return existing;
59
+ return { row: existing, reused: true };
60
+ }
61
+ if (existing && (existing.status === "confirmed" || existing.status === "executing") && now.getTime() < existing.expiresAt.getTime()) {
62
+ return { row: existing, reused: true };
60
63
  }
61
- if (existing && (existing.status === "failed" || existing.status === "cancelled" || existing.status === "expired")) {
62
- await tx.remove(existing).flush();
64
+ if (existing && (existing.status === "failed" || existing.status === "cancelled" || existing.status === "expired" || existing.status === "confirmed" || existing.status === "executing")) {
65
+ existing.idempotencyKey = `${existing.idempotencyKey}:superseded:${existing.id}`;
66
+ await tx.persist(existing).flush();
63
67
  }
64
68
  const row = tx.create(AiPendingAction, {
65
69
  tenantId: ctx.tenantId,
@@ -87,7 +91,7 @@ class AiPendingActionRepository {
87
91
  resolvedByUserId: null
88
92
  });
89
93
  await tx.persist(row).flush();
90
- return row;
94
+ return { row, reused: false };
91
95
  });
92
96
  }
93
97
  async getById(id, ctx) {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../../src/modules/ai_assistant/data/repositories/AiPendingActionRepository.ts"],
4
- "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n findOneWithDecryption,\n findWithDecryption,\n} from '@open-mercato/shared/lib/encryption/find'\nimport { AiPendingAction } from '../entities'\nimport {\n AI_PENDING_ACTION_ALLOWED_TRANSITIONS,\n AiPendingActionStateError,\n resolveAiPendingActionTtlSeconds,\n type AiPendingActionExecutionResult,\n type AiPendingActionFailedRecord,\n type AiPendingActionFieldDiff,\n type AiPendingActionQueueMode,\n type AiPendingActionRecordDiff,\n type AiPendingActionStatus,\n} from '../../lib/pending-action-types'\n\nexport interface AiPendingActionContext {\n tenantId: string\n organizationId?: string | null\n userId?: string | null\n}\n\nexport interface AiPendingActionCreateInput {\n agentId: string\n toolName: string\n idempotencyKey: string\n createdByUserId: string\n normalizedInput: Record<string, unknown>\n conversationId?: string | null\n targetEntityType?: string | null\n targetRecordId?: string | null\n fieldDiff?: AiPendingActionFieldDiff[]\n records?: AiPendingActionRecordDiff[] | null\n sideEffectsSummary?: string | null\n recordVersion?: string | null\n attachmentIds?: string[]\n queueMode?: AiPendingActionQueueMode\n /** Optional explicit TTL in seconds; overrides the env/default TTL. */\n ttlSeconds?: number\n /** Optional explicit `now` for deterministic tests. */\n now?: Date\n}\n\nexport interface AiPendingActionSetStatusExtra {\n resolvedByUserId?: string | null\n executionResult?: AiPendingActionExecutionResult | null\n failedRecords?: AiPendingActionFailedRecord[] | null\n /** Optional explicit `now` for deterministic tests. */\n now?: Date\n}\n\n/**\n * Persistent store for the Phase 3 WS-C mutation approval gate (Step 5.5).\n *\n * Responsibilities:\n * - Create new pending rows with a TTL-derived `expiresAt`, honoring\n * idempotency within the window (same `idempotencyKey` returns the same\n * row as long as it is still `pending`; any terminal state mints a new row).\n * - Tenant-scoped lookups for the confirm/cancel/reconnect routes and the\n * in-app UI's \"open actions\" list.\n * - State-machine enforcement: `setStatus` rejects illegal transitions via\n * {@link AiPendingActionStateError}. The runtime callers translate this\n * to a 409 Conflict response.\n * - `listExpired` for the cleanup worker (Step 5.12).\n *\n * Every read goes through `findOneWithDecryption` / `findWithDecryption`\n * because `normalized_input`, `field_diff` and `records` are declared in the\n * module's `encryption.ts` and are encrypted at rest when tenant data\n * encryption is enabled.\n */\nexport class AiPendingActionRepository {\n constructor(private readonly em: EntityManager) {}\n\n async create(\n input: AiPendingActionCreateInput,\n ctx: AiPendingActionContext,\n ): Promise<AiPendingAction> {\n if (!ctx?.tenantId) {\n throw new Error('AiPendingActionRepository.create requires tenantId')\n }\n if (!input?.agentId) {\n throw new Error('AiPendingActionRepository.create requires agentId')\n }\n if (!input?.toolName) {\n throw new Error('AiPendingActionRepository.create requires toolName')\n }\n if (!input?.idempotencyKey) {\n throw new Error(\n 'AiPendingActionRepository.create requires idempotencyKey',\n )\n }\n if (!input?.createdByUserId) {\n throw new Error(\n 'AiPendingActionRepository.create requires createdByUserId',\n )\n }\n\n const now = input.now ?? new Date()\n const ttlSeconds = Math.max(\n 1,\n Math.floor(\n typeof input.ttlSeconds === 'number' && Number.isFinite(input.ttlSeconds)\n ? input.ttlSeconds\n : resolveAiPendingActionTtlSeconds(),\n ),\n )\n const expiresAt = new Date(now.getTime() + ttlSeconds * 1000)\n\n return this.em.transactional(async (tx) => {\n const existing = await findOneWithDecryption<AiPendingAction>(\n tx as unknown as EntityManager,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n idempotencyKey: input.idempotencyKey,\n } as any,\n { orderBy: { createdAt: 'desc' } as any },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n if (existing && existing.status === 'pending') {\n return existing\n }\n // Terminal stale row would collide on the unique\n // `(tenantId, organizationId, idempotencyKey)` constraint when we\n // try to insert a fresh one with the same hash \u2014 and that exact\n // collision happens whenever the operator clicks \"Fix with AI\"\n // and the model retries the SAME tool with the SAME args. Remove\n // the stale row first so a retry can always proceed; success rows\n // stay (they represent a real, applied change), and failed /\n // cancelled / expired rows are cleared because they're blocking\n // exactly the recovery flow they were created to enable.\n if (\n existing &&\n (existing.status === 'failed' ||\n existing.status === 'cancelled' ||\n existing.status === 'expired')\n ) {\n await tx.remove(existing).flush()\n }\n const row = tx.create(AiPendingAction, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId: input.agentId,\n toolName: input.toolName,\n conversationId: input.conversationId ?? null,\n targetEntityType: input.targetEntityType ?? null,\n targetRecordId: input.targetRecordId ?? null,\n normalizedInput: input.normalizedInput ?? {},\n fieldDiff: Array.isArray(input.fieldDiff) ? input.fieldDiff : [],\n records: normalizeRecords(input.records),\n failedRecords: null,\n sideEffectsSummary: input.sideEffectsSummary ?? null,\n recordVersion: input.recordVersion ?? null,\n attachmentIds: Array.isArray(input.attachmentIds)\n ? input.attachmentIds\n : [],\n idempotencyKey: input.idempotencyKey,\n createdByUserId: input.createdByUserId,\n status: 'pending' as AiPendingActionStatus,\n queueMode: (input.queueMode ?? 'inline') as AiPendingActionQueueMode,\n executionResult: null,\n createdAt: now,\n expiresAt,\n resolvedAt: null,\n resolvedByUserId: null,\n } as unknown as AiPendingAction)\n await tx.persist(row).flush()\n return row\n })\n }\n\n async getById(\n id: string,\n ctx: AiPendingActionContext,\n ): Promise<AiPendingAction | null> {\n if (!id || !ctx?.tenantId) return null\n const row = await findOneWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n id,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n } as any,\n {},\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return row ?? null\n }\n\n async listPendingForAgent(\n agentId: string,\n ctx: AiPendingActionContext,\n limit: number = 50,\n ): Promise<AiPendingAction[]> {\n if (!agentId || !ctx?.tenantId) return []\n const capped = Math.max(1, Math.min(Math.floor(limit), 200))\n const rows = await findWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId,\n status: 'pending',\n } as any,\n {\n orderBy: { createdAt: 'desc' } as any,\n limit: capped,\n },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return rows\n }\n\n async setStatus(\n id: string,\n nextStatus: AiPendingActionStatus,\n ctx: AiPendingActionContext,\n extra?: AiPendingActionSetStatusExtra,\n ): Promise<AiPendingAction> {\n if (!ctx?.tenantId) {\n throw new Error('AiPendingActionRepository.setStatus requires tenantId')\n }\n if (!id) {\n throw new Error('AiPendingActionRepository.setStatus requires id')\n }\n return this.em.transactional(async (tx) => {\n const existing = await findOneWithDecryption<AiPendingAction>(\n tx as unknown as EntityManager,\n AiPendingAction,\n {\n id,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n } as any,\n {},\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n if (!existing) {\n throw new Error(`AiPendingAction not found: ${id}`)\n }\n if (existing.status === nextStatus) {\n return existing\n }\n const allowed = AI_PENDING_ACTION_ALLOWED_TRANSITIONS[existing.status] ?? []\n if (!allowed.includes(nextStatus)) {\n throw new AiPendingActionStateError(existing.status, nextStatus)\n }\n const now = extra?.now ?? new Date()\n existing.status = nextStatus\n if (\n nextStatus === 'confirmed' ||\n nextStatus === 'cancelled' ||\n nextStatus === 'expired' ||\n nextStatus === 'failed'\n ) {\n existing.resolvedAt = existing.resolvedAt ?? now\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'resolvedByUserId')) {\n existing.resolvedByUserId = extra.resolvedByUserId ?? null\n } else if (nextStatus === 'expired') {\n existing.resolvedByUserId = null\n }\n }\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'executionResult')) {\n existing.executionResult = extra.executionResult ?? null\n }\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'failedRecords')) {\n existing.failedRecords = normalizeFailedRecords(extra.failedRecords)\n }\n await tx.persist(existing).flush()\n return existing\n })\n }\n\n async listExpired(\n ctx: AiPendingActionContext,\n now: Date,\n limit: number = 100,\n ): Promise<AiPendingAction[]> {\n if (!ctx?.tenantId) return []\n const capped = Math.max(1, Math.min(Math.floor(limit), 500))\n const rows = await findWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n status: 'pending',\n expiresAt: { $lt: now } as any,\n } as any,\n {\n orderBy: { expiresAt: 'asc' } as any,\n limit: capped,\n },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return rows\n }\n}\n\nfunction normalizeRecords(\n records: AiPendingActionRecordDiff[] | null | undefined,\n): AiPendingActionRecordDiff[] | null {\n if (!Array.isArray(records) || records.length === 0) return null\n return records\n}\n\nfunction normalizeFailedRecords(\n failed: AiPendingActionFailedRecord[] | null | undefined,\n): AiPendingActionFailedRecord[] | null {\n if (!Array.isArray(failed) || failed.length === 0) return null\n return failed\n}\n\nexport default AiPendingActionRepository\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import type { EntityManager } from '@mikro-orm/postgresql'\nimport {\n findOneWithDecryption,\n findWithDecryption,\n} from '@open-mercato/shared/lib/encryption/find'\nimport { AiPendingAction } from '../entities'\nimport {\n AI_PENDING_ACTION_ALLOWED_TRANSITIONS,\n AiPendingActionStateError,\n resolveAiPendingActionTtlSeconds,\n type AiPendingActionExecutionResult,\n type AiPendingActionFailedRecord,\n type AiPendingActionFieldDiff,\n type AiPendingActionQueueMode,\n type AiPendingActionRecordDiff,\n type AiPendingActionStatus,\n} from '../../lib/pending-action-types'\n\nexport interface AiPendingActionContext {\n tenantId: string\n organizationId?: string | null\n userId?: string | null\n}\n\nexport interface AiPendingActionCreateInput {\n agentId: string\n toolName: string\n idempotencyKey: string\n createdByUserId: string\n normalizedInput: Record<string, unknown>\n conversationId?: string | null\n targetEntityType?: string | null\n targetRecordId?: string | null\n fieldDiff?: AiPendingActionFieldDiff[]\n records?: AiPendingActionRecordDiff[] | null\n sideEffectsSummary?: string | null\n recordVersion?: string | null\n attachmentIds?: string[]\n queueMode?: AiPendingActionQueueMode\n /** Optional explicit TTL in seconds; overrides the env/default TTL. */\n ttlSeconds?: number\n /** Optional explicit `now` for deterministic tests. */\n now?: Date\n}\n\nexport interface AiPendingActionCreateResult {\n row: AiPendingAction\n /**\n * True when `row` is an existing row returned as-is (a still-`pending`\n * row, or a `confirmed`/`executing` row whose mutation already ran) \u2014\n * i.e. no new row was inserted.\n */\n reused: boolean\n}\n\nexport interface AiPendingActionSetStatusExtra {\n resolvedByUserId?: string | null\n executionResult?: AiPendingActionExecutionResult | null\n failedRecords?: AiPendingActionFailedRecord[] | null\n /** Optional explicit `now` for deterministic tests. */\n now?: Date\n}\n\n/**\n * Persistent store for the Phase 3 WS-C mutation approval gate (Step 5.5).\n *\n * Responsibilities:\n * - Create new pending rows with a TTL-derived `expiresAt`, honoring\n * idempotency within the window (same `idempotencyKey` returns the same\n * row as long as it is still `pending`; any terminal state mints a new row).\n * - Tenant-scoped lookups for the confirm/cancel/reconnect routes and the\n * in-app UI's \"open actions\" list.\n * - State-machine enforcement: `setStatus` rejects illegal transitions via\n * {@link AiPendingActionStateError}. The runtime callers translate this\n * to a 409 Conflict response.\n * - `listExpired` for the cleanup worker (Step 5.12).\n *\n * Every read goes through `findOneWithDecryption` / `findWithDecryption`\n * because `normalized_input`, `field_diff` and `records` are declared in the\n * module's `encryption.ts` and are encrypted at rest when tenant data\n * encryption is enabled.\n */\nexport class AiPendingActionRepository {\n constructor(private readonly em: EntityManager) {}\n\n async create(\n input: AiPendingActionCreateInput,\n ctx: AiPendingActionContext,\n ): Promise<AiPendingActionCreateResult> {\n if (!ctx?.tenantId) {\n throw new Error('AiPendingActionRepository.create requires tenantId')\n }\n if (!input?.agentId) {\n throw new Error('AiPendingActionRepository.create requires agentId')\n }\n if (!input?.toolName) {\n throw new Error('AiPendingActionRepository.create requires toolName')\n }\n if (!input?.idempotencyKey) {\n throw new Error(\n 'AiPendingActionRepository.create requires idempotencyKey',\n )\n }\n if (!input?.createdByUserId) {\n throw new Error(\n 'AiPendingActionRepository.create requires createdByUserId',\n )\n }\n\n const now = input.now ?? new Date()\n const ttlSeconds = Math.max(\n 1,\n Math.floor(\n typeof input.ttlSeconds === 'number' && Number.isFinite(input.ttlSeconds)\n ? input.ttlSeconds\n : resolveAiPendingActionTtlSeconds(),\n ),\n )\n const expiresAt = new Date(now.getTime() + ttlSeconds * 1000)\n\n return this.em.transactional(async (tx) => {\n const existing = await findOneWithDecryption<AiPendingAction>(\n tx as unknown as EntityManager,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n idempotencyKey: input.idempotencyKey,\n } as any,\n { orderBy: { createdAt: 'desc' } as any },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n if (existing && existing.status === 'pending') {\n return { row: existing, reused: true }\n }\n // A `confirmed`/`executing` row represents a mutation that already\n // ran (or is running). Inserting a fresh row with the same key would\n // collide with the unique `(tenantId, organizationId,\n // idempotencyKey)` index \u2014 this happens whenever the model repeats\n // the same tool call with the same arguments after it already\n // succeeded. Return the existing row instead of inserting; the\n // caller (`prepareMutation`) uses `reused` to tell the model the\n // operation already ran rather than re-queuing it.\n //\n // This only holds within the row's own dedupe/TTL window\n // (`now < expiresAt`, per the spec's \"within the TTL\" dedupe\n // promise). Outside it, treat the row as stale: a legitimate repeat\n // of the same arguments later on would otherwise be refused forever,\n // and a process that died mid-execution would leave every retry\n // stuck reading a dead `executing` row.\n if (\n existing &&\n (existing.status === 'confirmed' || existing.status === 'executing') &&\n now.getTime() < existing.expiresAt.getTime()\n ) {\n return { row: existing, reused: true }\n }\n // A terminal (`failed`/`cancelled`/`expired`) row, or a stale\n // `confirmed`/`executing` row past its dedupe window, would also\n // collide on the same unique index when we insert a fresh one with\n // the same key \u2014 which happens whenever the operator clicks \"Fix\n // with AI\" and the model retries the SAME tool with the SAME args,\n // or simply repeats the same arguments later. Re-key the stale row\n // (instead of deleting it) so a retry can always proceed: the stale\n // row's own card keeps resolving to its terminal/prior state via a\n // GET by id, rather than 404ing.\n if (\n existing &&\n (existing.status === 'failed' ||\n existing.status === 'cancelled' ||\n existing.status === 'expired' ||\n existing.status === 'confirmed' ||\n existing.status === 'executing')\n ) {\n existing.idempotencyKey = `${existing.idempotencyKey}:superseded:${existing.id}`\n await tx.persist(existing).flush()\n }\n const row = tx.create(AiPendingAction, {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId: input.agentId,\n toolName: input.toolName,\n conversationId: input.conversationId ?? null,\n targetEntityType: input.targetEntityType ?? null,\n targetRecordId: input.targetRecordId ?? null,\n normalizedInput: input.normalizedInput ?? {},\n fieldDiff: Array.isArray(input.fieldDiff) ? input.fieldDiff : [],\n records: normalizeRecords(input.records),\n failedRecords: null,\n sideEffectsSummary: input.sideEffectsSummary ?? null,\n recordVersion: input.recordVersion ?? null,\n attachmentIds: Array.isArray(input.attachmentIds)\n ? input.attachmentIds\n : [],\n idempotencyKey: input.idempotencyKey,\n createdByUserId: input.createdByUserId,\n status: 'pending' as AiPendingActionStatus,\n queueMode: (input.queueMode ?? 'inline') as AiPendingActionQueueMode,\n executionResult: null,\n createdAt: now,\n expiresAt,\n resolvedAt: null,\n resolvedByUserId: null,\n } as unknown as AiPendingAction)\n await tx.persist(row).flush()\n return { row, reused: false }\n })\n }\n\n async getById(\n id: string,\n ctx: AiPendingActionContext,\n ): Promise<AiPendingAction | null> {\n if (!id || !ctx?.tenantId) return null\n const row = await findOneWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n id,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n } as any,\n {},\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return row ?? null\n }\n\n async listPendingForAgent(\n agentId: string,\n ctx: AiPendingActionContext,\n limit: number = 50,\n ): Promise<AiPendingAction[]> {\n if (!agentId || !ctx?.tenantId) return []\n const capped = Math.max(1, Math.min(Math.floor(limit), 200))\n const rows = await findWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n agentId,\n status: 'pending',\n } as any,\n {\n orderBy: { createdAt: 'desc' } as any,\n limit: capped,\n },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return rows\n }\n\n async setStatus(\n id: string,\n nextStatus: AiPendingActionStatus,\n ctx: AiPendingActionContext,\n extra?: AiPendingActionSetStatusExtra,\n ): Promise<AiPendingAction> {\n if (!ctx?.tenantId) {\n throw new Error('AiPendingActionRepository.setStatus requires tenantId')\n }\n if (!id) {\n throw new Error('AiPendingActionRepository.setStatus requires id')\n }\n return this.em.transactional(async (tx) => {\n const existing = await findOneWithDecryption<AiPendingAction>(\n tx as unknown as EntityManager,\n AiPendingAction,\n {\n id,\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n } as any,\n {},\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n if (!existing) {\n throw new Error(`AiPendingAction not found: ${id}`)\n }\n if (existing.status === nextStatus) {\n return existing\n }\n const allowed = AI_PENDING_ACTION_ALLOWED_TRANSITIONS[existing.status] ?? []\n if (!allowed.includes(nextStatus)) {\n throw new AiPendingActionStateError(existing.status, nextStatus)\n }\n const now = extra?.now ?? new Date()\n existing.status = nextStatus\n if (\n nextStatus === 'confirmed' ||\n nextStatus === 'cancelled' ||\n nextStatus === 'expired' ||\n nextStatus === 'failed'\n ) {\n existing.resolvedAt = existing.resolvedAt ?? now\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'resolvedByUserId')) {\n existing.resolvedByUserId = extra.resolvedByUserId ?? null\n } else if (nextStatus === 'expired') {\n existing.resolvedByUserId = null\n }\n }\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'executionResult')) {\n existing.executionResult = extra.executionResult ?? null\n }\n if (extra && Object.prototype.hasOwnProperty.call(extra, 'failedRecords')) {\n existing.failedRecords = normalizeFailedRecords(extra.failedRecords)\n }\n await tx.persist(existing).flush()\n return existing\n })\n }\n\n async listExpired(\n ctx: AiPendingActionContext,\n now: Date,\n limit: number = 100,\n ): Promise<AiPendingAction[]> {\n if (!ctx?.tenantId) return []\n const capped = Math.max(1, Math.min(Math.floor(limit), 500))\n const rows = await findWithDecryption<AiPendingAction>(\n this.em,\n AiPendingAction,\n {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId ?? null,\n status: 'pending',\n expiresAt: { $lt: now } as any,\n } as any,\n {\n orderBy: { expiresAt: 'asc' } as any,\n limit: capped,\n },\n {\n tenantId: ctx.tenantId ?? null,\n organizationId: ctx.organizationId ?? null,\n },\n )\n return rows\n }\n}\n\nfunction normalizeRecords(\n records: AiPendingActionRecordDiff[] | null | undefined,\n): AiPendingActionRecordDiff[] | null {\n if (!Array.isArray(records) || records.length === 0) return null\n return records\n}\n\nfunction normalizeFailedRecords(\n failed: AiPendingActionFailedRecord[] | null | undefined,\n): AiPendingActionFailedRecord[] | null {\n if (!Array.isArray(failed) || failed.length === 0) return null\n return failed\n}\n\nexport default AiPendingActionRepository\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -64,7 +64,18 @@ function buildToolHandlerContext(ctx, container, tool) {
64
64
  ...tool ? { tool } : {}
65
65
  };
66
66
  }
67
- function formatPendingActionToolResult(agent, tool, pendingActionId, expiresAt) {
67
+ function formatPendingActionToolResult(agent, tool, pendingActionId, expiresAt, reused, status, executionResult) {
68
+ if (reused && (status === "confirmed" || status === "executing")) {
69
+ const resultStatus = status === "executing" ? "executing" : "already-executed";
70
+ return formatToolResult({
71
+ status: resultStatus,
72
+ agentId: agent.id,
73
+ toolName: tool.name,
74
+ pendingActionId,
75
+ executionResult: executionResult ?? null,
76
+ message: resultStatus === "executing" ? `Mutation "${tool.name}" is already executing from a prior confirmed call. Do NOT submit it again; wait for the result.` : `Mutation "${tool.name}" already ran for this input (idempotency key reused). Do NOT submit it again \u2014 see executionResult for the prior outcome.`
77
+ });
78
+ }
68
79
  return formatToolResult({
69
80
  status: "pending-confirmation",
70
81
  agentId: agent.id,
@@ -102,7 +113,7 @@ function adaptToolToAiSdk(tool, ctx, mutation, container, effectiveMutationPolic
102
113
  if (mutation && requiresApprovalNow) {
103
114
  try {
104
115
  const toolCallArgs = args && typeof args === "object" && !Array.isArray(args) ? { ...args } : {};
105
- const { uiPart, pendingAction } = await prepareMutation(
116
+ const { uiPart, pendingAction, reused } = await prepareMutation(
106
117
  {
107
118
  agent: mutation.agent,
108
119
  tool: mutation.tool,
@@ -124,7 +135,10 @@ function adaptToolToAiSdk(tool, ctx, mutation, container, effectiveMutationPolic
124
135
  mutation.agent,
125
136
  mutation.tool,
126
137
  pendingAction.id,
127
- pendingAction.expiresAt
138
+ pendingAction.expiresAt,
139
+ reused,
140
+ pendingAction.status,
141
+ pendingAction.executionResult ?? null
128
142
  );
129
143
  } catch (error) {
130
144
  const message = error instanceof Error ? error.message : String(error);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
3
3
  "sources": ["../../../../src/modules/ai_assistant/lib/agent-tools.ts"],
4
- "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { dynamicTool, type Tool } from 'ai'\nimport type { AwilixContainer } from 'awilix'\nimport type { ZodType } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'\nimport type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'\nimport type { AiToolDefinition, McpToolContext } from './types'\nimport { loadAgentRegistry } from './agent-registry'\nimport {\n checkAgentPolicy,\n resolveEffectiveMutationPolicy,\n type AgentPolicyDenyCode,\n} from './agent-policy'\nimport { toolRegistry } from './tool-registry'\nimport { toSafeZodSchema } from './schema-utils'\nimport { prepareMutation } from './prepare-mutation'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Error thrown by `resolveAiAgentTools` (and downstream `runAiAgentText`) when\n * the agent-level policy check denies a request. Carries the structured deny\n * code so HTTP dispatchers can map it to a stable status and JSON body.\n */\nexport class AgentPolicyError extends Error {\n readonly code: AgentPolicyDenyCode\n\n constructor(code: AgentPolicyDenyCode, message: string) {\n super(message)\n this.name = 'AgentPolicyError'\n this.code = code\n }\n}\n\nexport interface ResolveAiAgentToolsInput {\n agentId: string\n authContext: AiChatRequestContext\n pageContext?: Record<string, unknown>\n attachmentIds?: string[]\n /**\n * Execution mode the caller intends to run the agent in. Defaults to\n * `'chat'` to preserve the existing chat dispatcher contract. Object-mode\n * callers (see `runAiAgentObject`) MUST pass `'object'` so the policy gate\n * can reject chat-only agents early with `execution_mode_not_supported`.\n */\n requestedExecutionMode?: 'chat' | 'object'\n /**\n * Optional tenant-scoped mutation-policy DOWNGRADE. When supplied, the\n * effective policy evaluated by `checkAgentPolicy` is the most restrictive\n * of `{ agent.mutationPolicy, mutationPolicyOverride }`. Escalation is\n * rejected at the route layer; this helper trusts callers to pass only\n * values produced by the override repository.\n */\n mutationPolicyOverride?: AiAgentMutationPolicy | null\n /**\n * DI container used by the `prepareMutation` tool-call wrapper (Step 5.6).\n * When present AND the agent's effective mutation policy is non-read-only,\n * `isMutation: true` tools are intercepted: the runtime creates an\n * `AiPendingAction` row and enqueues a `mutation-preview-card` UI part in\n * the returned {@link ResolvedAgentTools.uiPartQueue} instead of running the\n * tool's handler. When absent, mutation tools degrade-gracefully to the\n * pre-5.6 pass-through adapter \u2014 existing read-only agents are unaffected.\n */\n container?: AwilixContainer\n /**\n * Optional chat-turn correlation id used when hashing the\n * `AiPendingAction.idempotencyKey` so retries of the same mutation collapse\n * to a single row. The chat dispatcher supplies the OpenCode / AI SDK turn\n * id here; when omitted the hash falls back to `null` which still preserves\n * per-tenant/org uniqueness within the TTL window.\n */\n conversationId?: string | null\n}\n\n/**\n * Queue of UI parts the mutation-preview wrapper accumulates during a turn.\n * The chat/object dispatcher flushes these on the next emission boundary\n * (spec \u00A79 allows either direct streaming or this queue pattern \u2014 we ship the\n * queue in Step 5.6 and the chat dispatcher will drain it in Step 5.10 when\n * the `mutation-preview-card` component registers). BC: callers that ignore\n * the field are unaffected.\n */\nexport interface AiUiPartQueue {\n /** Pushed by the mutation wrapper; drained by the dispatcher in order. */\n enqueue: (part: AiUiPart) => void\n drain: () => AiUiPart[]\n size: () => number\n}\n\nfunction createAiUiPartQueue(): AiUiPartQueue {\n const buffer: AiUiPart[] = []\n return {\n enqueue: (part) => {\n buffer.push(part)\n },\n drain: () => {\n const snapshot = buffer.slice()\n buffer.length = 0\n return snapshot\n },\n size: () => buffer.length,\n }\n}\n\nexport interface ResolvedAgentTools {\n agent: AiAgentDefinition\n tools: Record<string, Tool<unknown, unknown>>\n /**\n * Per-request UI-part queue the chat dispatcher drains between streamText\n * chunks (Step 5.10 contract). Always present; empty when no mutation-tool\n * calls fire during the turn.\n */\n uiPartQueue: AiUiPartQueue\n}\n\nfunction toPolicyAuthContext(ctx: AiChatRequestContext): {\n userFeatures: string[]\n isSuperAdmin: boolean\n} {\n return {\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n}\n\n/**\n * Sanitize a dotted tool name (e.g. `search.hybrid_search`) into a format\n * accepted by all major LLM providers. OpenAI requires tool names to match\n * `^[a-zA-Z0-9_-]+$`; dots are replaced with double underscores (`__`).\n * Anthropic and Google accept both formats, so this is safe across providers.\n */\nexport function sanitizeToolNameForModel(name: string): string {\n return name.replace(/\\./g, '__')\n}\n\nexport function desanitizeToolNameForDisplay(name: string): string {\n return name.replace(/__/g, '.')\n}\n\nfunction formatToolResult(result: unknown): string {\n if (result === null || result === undefined) return 'No result returned'\n if (typeof result === 'string') return result\n if (typeof result === 'number' || typeof result === 'boolean') return String(result)\n try {\n return JSON.stringify(result, null, 2)\n } catch {\n return String(result)\n }\n}\n\nfunction buildToolHandlerContext(\n ctx: AiChatRequestContext,\n container?: AwilixContainer,\n tool?: AiToolDefinition,\n): McpToolContext {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n container: (container ?? undefined) as unknown as McpToolContext['container'],\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n ...(tool ? { tool } : {}),\n }\n}\n\ninterface MutationInterceptorOptions {\n agent: AiAgentDefinition\n tool: AiToolDefinition\n container: AwilixContainer\n ctx: AiChatRequestContext\n mutationPolicyOverride: AiAgentMutationPolicy | null\n conversationId: string | null\n uiPartQueue: AiUiPartQueue\n}\n\nfunction formatPendingActionToolResult(\n agent: AiAgentDefinition,\n tool: AiToolDefinition,\n pendingActionId: string,\n expiresAt: Date,\n): string {\n return formatToolResult({\n status: 'pending-confirmation',\n agentId: agent.id,\n toolName: tool.name,\n pendingActionId,\n expiresAt: expiresAt.toISOString(),\n message: `Awaiting user confirmation for mutation \"${tool.name}\". The action will NOT run until the user approves it.`,\n })\n}\n\n/**\n * Per-call gating decision for the destructive-confirm-required policy.\n * Honors a static boolean OR a predicate evaluated against the actual\n * tool-call args, so a multi-operation tool (e.g.\n * `customers.manage_deal_comment` with `operation: 'create' | 'update'\n * | 'delete'`) can gate only its destructive branches without being\n * split into multiple tools.\n */\nfunction resolveIsDestructive(\n isDestructive: boolean | ((input: unknown) => boolean) | undefined,\n args: unknown,\n): boolean {\n if (typeof isDestructive === 'function') {\n try {\n return isDestructive(args) === true\n } catch {\n // If the predicate throws (e.g. unexpected input shape), default to\n // gating \u2014 fail-safe for destructive policy.\n return true\n }\n }\n return isDestructive === true\n}\n\nfunction adaptToolToAiSdk(\n tool: AiToolDefinition,\n ctx: AiChatRequestContext,\n mutation: MutationInterceptorOptions | null,\n container?: AwilixContainer,\n effectiveMutationPolicy?: 'read-only' | 'confirm-required' | 'destructive-confirm-required',\n): Tool<unknown, unknown> {\n const safeSchema = toSafeZodSchema(tool.inputSchema as ZodType)\n const handlerContext = buildToolHandlerContext(ctx, container, tool)\n return dynamicTool({\n description: tool.description,\n inputSchema: safeSchema,\n execute: async (args: unknown) => {\n // Per-call gating decision. Under `destructive-confirm-required`,\n // tools with a predicate `isDestructive(args)` may flip per call \u2014\n // e.g. comment delete gates while comment create runs direct.\n const requiresApprovalNow = (() => {\n if (!mutation) return false\n if (effectiveMutationPolicy === 'confirm-required') return true\n if (effectiveMutationPolicy === 'destructive-confirm-required') {\n return resolveIsDestructive(mutation.tool.isDestructive, args)\n }\n return false\n })()\n if (mutation && requiresApprovalNow) {\n try {\n const toolCallArgs =\n args && typeof args === 'object' && !Array.isArray(args)\n ? { ...(args as Record<string, unknown>) }\n : {}\n const { uiPart, pendingAction } = await prepareMutation(\n {\n agent: mutation.agent,\n tool: mutation.tool,\n toolCallArgs,\n conversationId: mutation.conversationId,\n mutationPolicyOverride: mutation.mutationPolicyOverride,\n },\n {\n tenantId: mutation.ctx.tenantId,\n organizationId: mutation.ctx.organizationId,\n userId: mutation.ctx.userId,\n features: mutation.ctx.features,\n isSuperAdmin: mutation.ctx.isSuperAdmin,\n container: mutation.container,\n },\n )\n mutation.uiPartQueue.enqueue(uiPart)\n return formatPendingActionToolResult(\n mutation.agent,\n mutation.tool,\n pendingAction.id,\n pendingAction.expiresAt,\n )\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(\n `Tool \"${tool.name}\" could not be prepared for confirmation: ${message}`,\n )\n }\n }\n try {\n // Create a fresh container per tool call so the EM is never stale.\n const freshContainer = await createRequestContainer()\n const freshContext: McpToolContext = {\n ...handlerContext,\n container: freshContainer as unknown as McpToolContext['container'],\n }\n const { executeTool } = await import('./tool-executor')\n const execResult = await executeTool(tool.name, args, freshContext)\n if (!execResult.success) {\n throw new Error(execResult.error || 'Tool execution failed')\n }\n return formatToolResult(execResult.result)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(`Tool \"${tool.name}\" failed: ${message}`)\n }\n },\n })\n}\n\n/**\n * Resolves the agent's whitelisted tools into an AI SDK `tools` map, enforcing\n * the same policy gate as the HTTP dispatcher. Throws {@link AgentPolicyError}\n * on agent-level deny; tool-level denies are skipped with a logger warning\n * because the agent author whitelisted a tool the caller is not currently\n * permitted to execute (deterministic non-failure \u2014 the remaining tools still\n * reach the model).\n */\nexport async function resolveAiAgentTools(\n input: ResolveAiAgentToolsInput,\n): Promise<ResolvedAgentTools> {\n await loadAgentRegistry()\n // The agent registry alone is not enough: the policy gate below resolves\n // every allowlisted name against the TOOL registry, and rejects what it\n // cannot find as `tool_unknown`. Loading it here \u2014 rather than in the half\n // dozen entry points that happened to remember \u2014 is what makes an agent's\n // allowlist mean the same thing on every path.\n try {\n const { ensureModuleToolsLoaded } = await import('./tool-loader')\n await ensureModuleToolsLoaded()\n } catch (error) {\n // A registry that failed to load leaves the agent toolless, which the\n // caller already handles; it must not take the whole turn down.\n logger.error('Could not load module tools; agent will resolve to its known tools only', {\n agentId: input.agentId,\n err: error,\n })\n }\n\n const policyAuth = toPolicyAuthContext(input.authContext)\n const mutationPolicyOverride = input.mutationPolicyOverride ?? null\n const agentDecision = checkAgentPolicy({\n agentId: input.agentId,\n authContext: policyAuth,\n requestedExecutionMode: input.requestedExecutionMode ?? 'chat',\n mutationPolicyOverride,\n })\n if (!agentDecision.ok) {\n throw new AgentPolicyError(agentDecision.code, agentDecision.message)\n }\n\n const { agent } = agentDecision\n const tools: Record<string, Tool<unknown, unknown>> = {}\n const uiPartQueue = createAiUiPartQueue()\n const effectiveMutationPolicy = resolveEffectiveMutationPolicy(\n agent.mutationPolicy,\n mutationPolicyOverride,\n agent.id,\n )\n const canInterceptMutations =\n effectiveMutationPolicy !== 'read-only' && typeof input.container !== 'undefined'\n\n for (const toolName of agent.allowedTools) {\n const toolDecision = checkAgentPolicy({\n agentId: input.agentId,\n authContext: policyAuth,\n toolName,\n mutationPolicyOverride,\n })\n if (!toolDecision.ok) {\n logger.warn('Skipping tool for agent', { toolName, agentId: agent.id, reason: toolDecision.message })\n continue\n }\n\n const record = (toolDecision.tool ?? toolRegistry.getTool(toolName)) as\n | AiToolDefinition\n | undefined\n if (!record) {\n logger.warn('Tool vanished from registry between policy checks; skipping', { toolName })\n continue\n }\n\n try {\n // For any mutation tool whose policy isn't read-only, prepare the\n // interceptor options once. The actual gate-vs-run-direct decision\n // happens at call time inside `adaptToolToAiSdk` because under\n // `destructive-confirm-required` `isDestructive` may be a predicate\n // evaluated against the per-call args (e.g. a multi-operation\n // tool with `operation: 'create' | 'update' | 'delete'` gates only\n // the destructive branch).\n const mutationOptions: MutationInterceptorOptions | null =\n record.isMutation === true && canInterceptMutations && input.container\n ? {\n agent,\n tool: record,\n container: input.container,\n ctx: input.authContext,\n mutationPolicyOverride,\n conversationId: input.conversationId ?? null,\n uiPartQueue,\n }\n : null\n // Sanitize tool name for model API compatibility: OpenAI requires\n // names matching ^[a-zA-Z0-9_-]+$ (no dots). Replace dots with\n // double underscores so `search.hybrid_search` becomes\n // `search__hybrid_search`. The AI SDK uses the record key as the\n // tool name sent to the model; the original dotted name stays on\n // the `tool` object for logging and prepareMutation hashing.\n const modelSafeToolName = sanitizeToolNameForModel(toolName)\n tools[modelSafeToolName] = adaptToolToAiSdk(\n record,\n input.authContext,\n mutationOptions,\n input.container,\n effectiveMutationPolicy,\n )\n } catch (error) {\n logger.error('Failed to adapt tool for agent', { toolName, agentId: agent.id, err: error })\n }\n }\n\n return { agent, tools, uiPartQueue }\n}\n"],
5
- "mappings": "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",
4
+ "sourcesContent": ["import { createLogger } from '@open-mercato/shared/lib/logger'\nimport { dynamicTool, type Tool } from 'ai'\nimport type { AwilixContainer } from 'awilix'\nimport type { ZodType } from 'zod'\nimport { createRequestContainer } from '@open-mercato/shared/lib/di/container'\nimport type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'\nimport type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'\nimport type { AiToolDefinition, McpToolContext } from './types'\nimport type {\n AiPendingActionExecutionResult,\n AiPendingActionStatus,\n} from './pending-action-types'\nimport { loadAgentRegistry } from './agent-registry'\nimport {\n checkAgentPolicy,\n resolveEffectiveMutationPolicy,\n type AgentPolicyDenyCode,\n} from './agent-policy'\nimport { toolRegistry } from './tool-registry'\nimport { toSafeZodSchema } from './schema-utils'\nimport { prepareMutation } from './prepare-mutation'\n\nconst logger = createLogger('ai_assistant')\n\n/**\n * Error thrown by `resolveAiAgentTools` (and downstream `runAiAgentText`) when\n * the agent-level policy check denies a request. Carries the structured deny\n * code so HTTP dispatchers can map it to a stable status and JSON body.\n */\nexport class AgentPolicyError extends Error {\n readonly code: AgentPolicyDenyCode\n\n constructor(code: AgentPolicyDenyCode, message: string) {\n super(message)\n this.name = 'AgentPolicyError'\n this.code = code\n }\n}\n\nexport interface ResolveAiAgentToolsInput {\n agentId: string\n authContext: AiChatRequestContext\n pageContext?: Record<string, unknown>\n attachmentIds?: string[]\n /**\n * Execution mode the caller intends to run the agent in. Defaults to\n * `'chat'` to preserve the existing chat dispatcher contract. Object-mode\n * callers (see `runAiAgentObject`) MUST pass `'object'` so the policy gate\n * can reject chat-only agents early with `execution_mode_not_supported`.\n */\n requestedExecutionMode?: 'chat' | 'object'\n /**\n * Optional tenant-scoped mutation-policy DOWNGRADE. When supplied, the\n * effective policy evaluated by `checkAgentPolicy` is the most restrictive\n * of `{ agent.mutationPolicy, mutationPolicyOverride }`. Escalation is\n * rejected at the route layer; this helper trusts callers to pass only\n * values produced by the override repository.\n */\n mutationPolicyOverride?: AiAgentMutationPolicy | null\n /**\n * DI container used by the `prepareMutation` tool-call wrapper (Step 5.6).\n * When present AND the agent's effective mutation policy is non-read-only,\n * `isMutation: true` tools are intercepted: the runtime creates an\n * `AiPendingAction` row and enqueues a `mutation-preview-card` UI part in\n * the returned {@link ResolvedAgentTools.uiPartQueue} instead of running the\n * tool's handler. When absent, mutation tools degrade-gracefully to the\n * pre-5.6 pass-through adapter \u2014 existing read-only agents are unaffected.\n */\n container?: AwilixContainer\n /**\n * Optional chat-turn correlation id used when hashing the\n * `AiPendingAction.idempotencyKey` so retries of the same mutation collapse\n * to a single row. The chat dispatcher supplies the OpenCode / AI SDK turn\n * id here; when omitted the hash falls back to `null` which still preserves\n * per-tenant/org uniqueness within the TTL window.\n */\n conversationId?: string | null\n}\n\n/**\n * Queue of UI parts the mutation-preview wrapper accumulates during a turn.\n * The chat/object dispatcher flushes these on the next emission boundary\n * (spec \u00A79 allows either direct streaming or this queue pattern \u2014 we ship the\n * queue in Step 5.6 and the chat dispatcher will drain it in Step 5.10 when\n * the `mutation-preview-card` component registers). BC: callers that ignore\n * the field are unaffected.\n */\nexport interface AiUiPartQueue {\n /** Pushed by the mutation wrapper; drained by the dispatcher in order. */\n enqueue: (part: AiUiPart) => void\n drain: () => AiUiPart[]\n size: () => number\n}\n\nfunction createAiUiPartQueue(): AiUiPartQueue {\n const buffer: AiUiPart[] = []\n return {\n enqueue: (part) => {\n buffer.push(part)\n },\n drain: () => {\n const snapshot = buffer.slice()\n buffer.length = 0\n return snapshot\n },\n size: () => buffer.length,\n }\n}\n\nexport interface ResolvedAgentTools {\n agent: AiAgentDefinition\n tools: Record<string, Tool<unknown, unknown>>\n /**\n * Per-request UI-part queue the chat dispatcher drains between streamText\n * chunks (Step 5.10 contract). Always present; empty when no mutation-tool\n * calls fire during the turn.\n */\n uiPartQueue: AiUiPartQueue\n}\n\nfunction toPolicyAuthContext(ctx: AiChatRequestContext): {\n userFeatures: string[]\n isSuperAdmin: boolean\n} {\n return {\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n }\n}\n\n/**\n * Sanitize a dotted tool name (e.g. `search.hybrid_search`) into a format\n * accepted by all major LLM providers. OpenAI requires tool names to match\n * `^[a-zA-Z0-9_-]+$`; dots are replaced with double underscores (`__`).\n * Anthropic and Google accept both formats, so this is safe across providers.\n */\nexport function sanitizeToolNameForModel(name: string): string {\n return name.replace(/\\./g, '__')\n}\n\nexport function desanitizeToolNameForDisplay(name: string): string {\n return name.replace(/__/g, '.')\n}\n\nfunction formatToolResult(result: unknown): string {\n if (result === null || result === undefined) return 'No result returned'\n if (typeof result === 'string') return result\n if (typeof result === 'number' || typeof result === 'boolean') return String(result)\n try {\n return JSON.stringify(result, null, 2)\n } catch {\n return String(result)\n }\n}\n\nfunction buildToolHandlerContext(\n ctx: AiChatRequestContext,\n container?: AwilixContainer,\n tool?: AiToolDefinition,\n): McpToolContext {\n return {\n tenantId: ctx.tenantId,\n organizationId: ctx.organizationId,\n userId: ctx.userId,\n container: (container ?? undefined) as unknown as McpToolContext['container'],\n userFeatures: ctx.features,\n isSuperAdmin: ctx.isSuperAdmin,\n ...(tool ? { tool } : {}),\n }\n}\n\ninterface MutationInterceptorOptions {\n agent: AiAgentDefinition\n tool: AiToolDefinition\n container: AwilixContainer\n ctx: AiChatRequestContext\n mutationPolicyOverride: AiAgentMutationPolicy | null\n conversationId: string | null\n uiPartQueue: AiUiPartQueue\n}\n\nfunction formatPendingActionToolResult(\n agent: AiAgentDefinition,\n tool: AiToolDefinition,\n pendingActionId: string,\n expiresAt: Date,\n reused: boolean,\n status: AiPendingActionStatus,\n executionResult: AiPendingActionExecutionResult | null,\n): string {\n // A reused `confirmed`/`executing` row means this exact mutation already\n // ran (or is running) under the same idempotency key \u2014 tell the model so\n // it does not resubmit, instead of always claiming a fresh approval is\n // pending (issue #7550).\n if (reused && (status === 'confirmed' || status === 'executing')) {\n const resultStatus = status === 'executing' ? 'executing' : 'already-executed'\n return formatToolResult({\n status: resultStatus,\n agentId: agent.id,\n toolName: tool.name,\n pendingActionId,\n executionResult: executionResult ?? null,\n message:\n resultStatus === 'executing'\n ? `Mutation \"${tool.name}\" is already executing from a prior confirmed call. Do NOT submit it again; wait for the result.`\n : `Mutation \"${tool.name}\" already ran for this input (idempotency key reused). Do NOT submit it again \u2014 see executionResult for the prior outcome.`,\n })\n }\n return formatToolResult({\n status: 'pending-confirmation',\n agentId: agent.id,\n toolName: tool.name,\n pendingActionId,\n expiresAt: expiresAt.toISOString(),\n message: `Awaiting user confirmation for mutation \"${tool.name}\". The action will NOT run until the user approves it.`,\n })\n}\n\n/**\n * Per-call gating decision for the destructive-confirm-required policy.\n * Honors a static boolean OR a predicate evaluated against the actual\n * tool-call args, so a multi-operation tool (e.g.\n * `customers.manage_deal_comment` with `operation: 'create' | 'update'\n * | 'delete'`) can gate only its destructive branches without being\n * split into multiple tools.\n */\nfunction resolveIsDestructive(\n isDestructive: boolean | ((input: unknown) => boolean) | undefined,\n args: unknown,\n): boolean {\n if (typeof isDestructive === 'function') {\n try {\n return isDestructive(args) === true\n } catch {\n // If the predicate throws (e.g. unexpected input shape), default to\n // gating \u2014 fail-safe for destructive policy.\n return true\n }\n }\n return isDestructive === true\n}\n\nfunction adaptToolToAiSdk(\n tool: AiToolDefinition,\n ctx: AiChatRequestContext,\n mutation: MutationInterceptorOptions | null,\n container?: AwilixContainer,\n effectiveMutationPolicy?: 'read-only' | 'confirm-required' | 'destructive-confirm-required',\n): Tool<unknown, unknown> {\n const safeSchema = toSafeZodSchema(tool.inputSchema as ZodType)\n const handlerContext = buildToolHandlerContext(ctx, container, tool)\n return dynamicTool({\n description: tool.description,\n inputSchema: safeSchema,\n execute: async (args: unknown) => {\n // Per-call gating decision. Under `destructive-confirm-required`,\n // tools with a predicate `isDestructive(args)` may flip per call \u2014\n // e.g. comment delete gates while comment create runs direct.\n const requiresApprovalNow = (() => {\n if (!mutation) return false\n if (effectiveMutationPolicy === 'confirm-required') return true\n if (effectiveMutationPolicy === 'destructive-confirm-required') {\n return resolveIsDestructive(mutation.tool.isDestructive, args)\n }\n return false\n })()\n if (mutation && requiresApprovalNow) {\n try {\n const toolCallArgs =\n args && typeof args === 'object' && !Array.isArray(args)\n ? { ...(args as Record<string, unknown>) }\n : {}\n const { uiPart, pendingAction, reused } = await prepareMutation(\n {\n agent: mutation.agent,\n tool: mutation.tool,\n toolCallArgs,\n conversationId: mutation.conversationId,\n mutationPolicyOverride: mutation.mutationPolicyOverride,\n },\n {\n tenantId: mutation.ctx.tenantId,\n organizationId: mutation.ctx.organizationId,\n userId: mutation.ctx.userId,\n features: mutation.ctx.features,\n isSuperAdmin: mutation.ctx.isSuperAdmin,\n container: mutation.container,\n },\n )\n mutation.uiPartQueue.enqueue(uiPart)\n return formatPendingActionToolResult(\n mutation.agent,\n mutation.tool,\n pendingAction.id,\n pendingAction.expiresAt,\n reused,\n pendingAction.status,\n pendingAction.executionResult ?? null,\n )\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(\n `Tool \"${tool.name}\" could not be prepared for confirmation: ${message}`,\n )\n }\n }\n try {\n // Create a fresh container per tool call so the EM is never stale.\n const freshContainer = await createRequestContainer()\n const freshContext: McpToolContext = {\n ...handlerContext,\n container: freshContainer as unknown as McpToolContext['container'],\n }\n const { executeTool } = await import('./tool-executor')\n const execResult = await executeTool(tool.name, args, freshContext)\n if (!execResult.success) {\n throw new Error(execResult.error || 'Tool execution failed')\n }\n return formatToolResult(execResult.result)\n } catch (error) {\n const message = error instanceof Error ? error.message : String(error)\n throw new Error(`Tool \"${tool.name}\" failed: ${message}`)\n }\n },\n })\n}\n\n/**\n * Resolves the agent's whitelisted tools into an AI SDK `tools` map, enforcing\n * the same policy gate as the HTTP dispatcher. Throws {@link AgentPolicyError}\n * on agent-level deny; tool-level denies are skipped with a logger warning\n * because the agent author whitelisted a tool the caller is not currently\n * permitted to execute (deterministic non-failure \u2014 the remaining tools still\n * reach the model).\n */\nexport async function resolveAiAgentTools(\n input: ResolveAiAgentToolsInput,\n): Promise<ResolvedAgentTools> {\n await loadAgentRegistry()\n // The agent registry alone is not enough: the policy gate below resolves\n // every allowlisted name against the TOOL registry, and rejects what it\n // cannot find as `tool_unknown`. Loading it here \u2014 rather than in the half\n // dozen entry points that happened to remember \u2014 is what makes an agent's\n // allowlist mean the same thing on every path.\n try {\n const { ensureModuleToolsLoaded } = await import('./tool-loader')\n await ensureModuleToolsLoaded()\n } catch (error) {\n // A registry that failed to load leaves the agent toolless, which the\n // caller already handles; it must not take the whole turn down.\n logger.error('Could not load module tools; agent will resolve to its known tools only', {\n agentId: input.agentId,\n err: error,\n })\n }\n\n const policyAuth = toPolicyAuthContext(input.authContext)\n const mutationPolicyOverride = input.mutationPolicyOverride ?? null\n const agentDecision = checkAgentPolicy({\n agentId: input.agentId,\n authContext: policyAuth,\n requestedExecutionMode: input.requestedExecutionMode ?? 'chat',\n mutationPolicyOverride,\n })\n if (!agentDecision.ok) {\n throw new AgentPolicyError(agentDecision.code, agentDecision.message)\n }\n\n const { agent } = agentDecision\n const tools: Record<string, Tool<unknown, unknown>> = {}\n const uiPartQueue = createAiUiPartQueue()\n const effectiveMutationPolicy = resolveEffectiveMutationPolicy(\n agent.mutationPolicy,\n mutationPolicyOverride,\n agent.id,\n )\n const canInterceptMutations =\n effectiveMutationPolicy !== 'read-only' && typeof input.container !== 'undefined'\n\n for (const toolName of agent.allowedTools) {\n const toolDecision = checkAgentPolicy({\n agentId: input.agentId,\n authContext: policyAuth,\n toolName,\n mutationPolicyOverride,\n })\n if (!toolDecision.ok) {\n logger.warn('Skipping tool for agent', { toolName, agentId: agent.id, reason: toolDecision.message })\n continue\n }\n\n const record = (toolDecision.tool ?? toolRegistry.getTool(toolName)) as\n | AiToolDefinition\n | undefined\n if (!record) {\n logger.warn('Tool vanished from registry between policy checks; skipping', { toolName })\n continue\n }\n\n try {\n // For any mutation tool whose policy isn't read-only, prepare the\n // interceptor options once. The actual gate-vs-run-direct decision\n // happens at call time inside `adaptToolToAiSdk` because under\n // `destructive-confirm-required` `isDestructive` may be a predicate\n // evaluated against the per-call args (e.g. a multi-operation\n // tool with `operation: 'create' | 'update' | 'delete'` gates only\n // the destructive branch).\n const mutationOptions: MutationInterceptorOptions | null =\n record.isMutation === true && canInterceptMutations && input.container\n ? {\n agent,\n tool: record,\n container: input.container,\n ctx: input.authContext,\n mutationPolicyOverride,\n conversationId: input.conversationId ?? null,\n uiPartQueue,\n }\n : null\n // Sanitize tool name for model API compatibility: OpenAI requires\n // names matching ^[a-zA-Z0-9_-]+$ (no dots). Replace dots with\n // double underscores so `search.hybrid_search` becomes\n // `search__hybrid_search`. The AI SDK uses the record key as the\n // tool name sent to the model; the original dotted name stays on\n // the `tool` object for logging and prepareMutation hashing.\n const modelSafeToolName = sanitizeToolNameForModel(toolName)\n tools[modelSafeToolName] = adaptToolToAiSdk(\n record,\n input.authContext,\n mutationOptions,\n input.container,\n effectiveMutationPolicy,\n )\n } catch (error) {\n logger.error('Failed to adapt tool for agent', { toolName, agentId: agent.id, err: error })\n }\n }\n\n return { agent, tools, uiPartQueue }\n}\n"],
5
+ "mappings": "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",
6
6
  "names": []
7
7
  }
@@ -296,7 +296,7 @@ async function prepareMutation(input, ctx) {
296
296
  toolName: tool.name,
297
297
  normalizedInput
298
298
  });
299
- const pendingAction = await repo.create(
299
+ const { row: pendingAction, reused } = await repo.create(
300
300
  {
301
301
  agentId: agent.id,
302
302
  toolName: tool.name,
@@ -328,7 +328,7 @@ async function prepareMutation(input, ctx) {
328
328
  ...sideEffectsSummary ? { sideEffectsSummary } : {}
329
329
  }
330
330
  };
331
- return { uiPart, pendingAction };
331
+ return { uiPart, pendingAction, reused };
332
332
  }
333
333
  const MUTATION_PREVIEW_CARD_COMPONENT = MUTATION_PREVIEW_CARD_COMPONENT_ID;
334
334
  export {
@@ -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 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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",
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 * True when `pendingAction` is an existing row the repository returned\n * as-is (still `pending`, or `confirmed`/`executing` because the\n * mutation already ran) instead of a freshly inserted row.\n */\n reused: boolean\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 { row: pendingAction, reused } = 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, reused }\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.10.1-develop.7619.1.97f9a4bda8",
3
+ "version": "0.10.1-develop.7622.1.0584ba2a04",
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.10.1-develop.7619.1.97f9a4bda8",
104
- "@open-mercato/ui": "0.10.1-develop.7619.1.97f9a4bda8",
103
+ "@open-mercato/shared": "0.10.1-develop.7622.1.0584ba2a04",
104
+ "@open-mercato/ui": "0.10.1-develop.7622.1.0584ba2a04",
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.10.1-develop.7619.1.97f9a4bda8",
111
- "@open-mercato/shared": "0.10.1-develop.7619.1.97f9a4bda8",
112
- "@open-mercato/ui": "0.10.1-develop.7619.1.97f9a4bda8",
110
+ "@open-mercato/cli": "0.10.1-develop.7622.1.0584ba2a04",
111
+ "@open-mercato/shared": "0.10.1-develop.7622.1.0584ba2a04",
112
+ "@open-mercato/ui": "0.10.1-develop.7622.1.0584ba2a04",
113
113
  "@types/react": "^19.2.18",
114
114
  "@types/react-dom": "^19.2.5",
115
115
  "react": "19.2.8",
@@ -43,6 +43,16 @@ export interface AiPendingActionCreateInput {
43
43
  now?: Date
44
44
  }
45
45
 
46
+ export interface AiPendingActionCreateResult {
47
+ row: AiPendingAction
48
+ /**
49
+ * True when `row` is an existing row returned as-is (a still-`pending`
50
+ * row, or a `confirmed`/`executing` row whose mutation already ran) —
51
+ * i.e. no new row was inserted.
52
+ */
53
+ reused: boolean
54
+ }
55
+
46
56
  export interface AiPendingActionSetStatusExtra {
47
57
  resolvedByUserId?: string | null
48
58
  executionResult?: AiPendingActionExecutionResult | null
@@ -76,7 +86,7 @@ export class AiPendingActionRepository {
76
86
  async create(
77
87
  input: AiPendingActionCreateInput,
78
88
  ctx: AiPendingActionContext,
79
- ): Promise<AiPendingAction> {
89
+ ): Promise<AiPendingActionCreateResult> {
80
90
  if (!ctx?.tenantId) {
81
91
  throw new Error('AiPendingActionRepository.create requires tenantId')
82
92
  }
@@ -124,24 +134,49 @@ export class AiPendingActionRepository {
124
134
  },
125
135
  )
126
136
  if (existing && existing.status === 'pending') {
127
- return existing
137
+ return { row: existing, reused: true }
138
+ }
139
+ // A `confirmed`/`executing` row represents a mutation that already
140
+ // ran (or is running). Inserting a fresh row with the same key would
141
+ // collide with the unique `(tenantId, organizationId,
142
+ // idempotencyKey)` index — this happens whenever the model repeats
143
+ // the same tool call with the same arguments after it already
144
+ // succeeded. Return the existing row instead of inserting; the
145
+ // caller (`prepareMutation`) uses `reused` to tell the model the
146
+ // operation already ran rather than re-queuing it.
147
+ //
148
+ // This only holds within the row's own dedupe/TTL window
149
+ // (`now < expiresAt`, per the spec's "within the TTL" dedupe
150
+ // promise). Outside it, treat the row as stale: a legitimate repeat
151
+ // of the same arguments later on would otherwise be refused forever,
152
+ // and a process that died mid-execution would leave every retry
153
+ // stuck reading a dead `executing` row.
154
+ if (
155
+ existing &&
156
+ (existing.status === 'confirmed' || existing.status === 'executing') &&
157
+ now.getTime() < existing.expiresAt.getTime()
158
+ ) {
159
+ return { row: existing, reused: true }
128
160
  }
129
- // Terminal stale row would collide on the unique
130
- // `(tenantId, organizationId, idempotencyKey)` constraint when we
131
- // try to insert a fresh one with the same hash — and that exact
132
- // collision happens whenever the operator clicks "Fix with AI"
133
- // and the model retries the SAME tool with the SAME args. Remove
134
- // the stale row first so a retry can always proceed; success rows
135
- // stay (they represent a real, applied change), and failed /
136
- // cancelled / expired rows are cleared because they're blocking
137
- // exactly the recovery flow they were created to enable.
161
+ // A terminal (`failed`/`cancelled`/`expired`) row, or a stale
162
+ // `confirmed`/`executing` row past its dedupe window, would also
163
+ // collide on the same unique index when we insert a fresh one with
164
+ // the same key — which happens whenever the operator clicks "Fix
165
+ // with AI" and the model retries the SAME tool with the SAME args,
166
+ // or simply repeats the same arguments later. Re-key the stale row
167
+ // (instead of deleting it) so a retry can always proceed: the stale
168
+ // row's own card keeps resolving to its terminal/prior state via a
169
+ // GET by id, rather than 404ing.
138
170
  if (
139
171
  existing &&
140
172
  (existing.status === 'failed' ||
141
173
  existing.status === 'cancelled' ||
142
- existing.status === 'expired')
174
+ existing.status === 'expired' ||
175
+ existing.status === 'confirmed' ||
176
+ existing.status === 'executing')
143
177
  ) {
144
- await tx.remove(existing).flush()
178
+ existing.idempotencyKey = `${existing.idempotencyKey}:superseded:${existing.id}`
179
+ await tx.persist(existing).flush()
145
180
  }
146
181
  const row = tx.create(AiPendingAction, {
147
182
  tenantId: ctx.tenantId,
@@ -171,7 +206,7 @@ export class AiPendingActionRepository {
171
206
  resolvedByUserId: null,
172
207
  } as unknown as AiPendingAction)
173
208
  await tx.persist(row).flush()
174
- return row
209
+ return { row, reused: false }
175
210
  })
176
211
  }
177
212
 
@@ -178,11 +178,12 @@ describe('AiPendingActionRepository', () => {
178
178
  const now = new Date('2026-04-18T12:00:00.000Z')
179
179
  const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
180
180
 
181
- const row = await repo.create(
181
+ const { row, reused } = await repo.create(
182
182
  baseInput({ now, ttlSeconds: 900 }),
183
183
  ctx,
184
184
  )
185
185
 
186
+ expect(reused).toBe(false)
186
187
  expect(row.status).toBe('pending')
187
188
  expect(row.tenantId).toBe(tenantAlpha)
188
189
  expect(row.attachmentIds).toEqual([])
@@ -198,8 +199,8 @@ describe('AiPendingActionRepository', () => {
198
199
  const repo = new AiPendingActionRepository(em)
199
200
  const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
200
201
 
201
- const first = await repo.create(baseInput({ idempotencyKey: 'idem-42' }), ctx)
202
- const second = await repo.create(
202
+ const { row: first } = await repo.create(baseInput({ idempotencyKey: 'idem-42' }), ctx)
203
+ const { row: second, reused } = await repo.create(
203
204
  baseInput({
204
205
  idempotencyKey: 'idem-42',
205
206
  normalizedInput: { productId: 'p-1', patch: { name: 'different-call' } },
@@ -207,29 +208,159 @@ describe('AiPendingActionRepository', () => {
207
208
  ctx,
208
209
  )
209
210
 
211
+ expect(reused).toBe(true)
210
212
  expect(second.id).toBe(first.id)
211
213
  // the repo MUST NOT mutate the existing row from the second call
212
214
  expect(second.normalizedInput).toEqual(first.normalizedInput)
213
215
  expect(em.__store).toHaveLength(1)
214
216
  })
215
217
 
216
- it('after a terminal status, same idempotencyKey mints a NEW row (new id)', async () => {
218
+ it('after a terminal status, same idempotencyKey mints a NEW row (new id) and re-keys the stale one', async () => {
217
219
  const em = mockEm()
218
220
  const repo = new AiPendingActionRepository(em)
219
221
  const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
220
222
 
221
- const first = await repo.create(baseInput({ idempotencyKey: 'idem-9' }), ctx)
223
+ const { row: first } = await repo.create(baseInput({ idempotencyKey: 'idem-9' }), ctx)
222
224
  await repo.setStatus(first.id, 'cancelled', ctx, { resolvedByUserId: 'u-1' })
223
225
 
224
- const second = await repo.create(baseInput({ idempotencyKey: 'idem-9' }), ctx)
226
+ const { row: second, reused } = await repo.create(baseInput({ idempotencyKey: 'idem-9' }), ctx)
227
+ expect(reused).toBe(false)
225
228
  expect(second.id).not.toBe(first.id)
226
229
  expect(second.status).toBe('pending')
227
- // The repo removes stale terminal rows (cancelled/failed/expired) before
228
- // minting a fresh pending row so the unique-key constraint stays satisfied
229
- // and the "Fix with AI" retry flow works. Store ends up with just the
230
- // new pending row.
230
+ expect(second.idempotencyKey).toBe('idem-9')
231
+ // The repo re-keys stale terminal rows (cancelled/failed/expired) instead
232
+ // of deleting them — so the unique-key constraint stays satisfied for the
233
+ // "Fix with AI" retry flow, AND the stale row's own card keeps resolving
234
+ // to its terminal state via GET-by-id instead of 404ing (issue #7550).
235
+ expect(em.__store).toHaveLength(2)
236
+ const stale = em.__store.find((candidate) => candidate.id === first.id)
237
+ expect(stale).toBeDefined()
238
+ expect(stale!.status).toBe('cancelled')
239
+ expect(stale!.idempotencyKey).toBe(`idem-9:superseded:${first.id}`)
240
+ const fresh = em.__store.find((candidate) => candidate.id === second.id)
241
+ expect(fresh!.idempotencyKey).toBe('idem-9')
242
+ })
243
+
244
+ it('a confirmed row is returned as-is on retry — no second row, no unique-index collision', async () => {
245
+ const em = mockEm()
246
+ const repo = new AiPendingActionRepository(em)
247
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
248
+
249
+ const { row: first } = await repo.create(baseInput({ idempotencyKey: 'idem-confirmed' }), ctx)
250
+ await repo.setStatus(first.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
251
+
252
+ const { row: second, reused } = await repo.create(
253
+ baseInput({ idempotencyKey: 'idem-confirmed' }),
254
+ ctx,
255
+ )
256
+
257
+ expect(reused).toBe(true)
258
+ expect(second.id).toBe(first.id)
259
+ expect(second.status).toBe('confirmed')
260
+ expect(em.__store).toHaveLength(1)
261
+ })
262
+
263
+ it('an executing row is returned as-is on retry — no second row, no unique-index collision', async () => {
264
+ const em = mockEm()
265
+ const repo = new AiPendingActionRepository(em)
266
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
267
+
268
+ const { row: first } = await repo.create(baseInput({ idempotencyKey: 'idem-executing' }), ctx)
269
+ await repo.setStatus(first.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
270
+ await repo.setStatus(first.id, 'executing', ctx)
271
+
272
+ const { row: second, reused } = await repo.create(
273
+ baseInput({ idempotencyKey: 'idem-executing' }),
274
+ ctx,
275
+ )
276
+
277
+ expect(reused).toBe(true)
278
+ expect(second.id).toBe(first.id)
279
+ expect(second.status).toBe('executing')
280
+ expect(em.__store).toHaveLength(1)
281
+ })
282
+
283
+ it('a confirmed row is still reused just before its dedupe window elapses (now < expiresAt)', async () => {
284
+ const em = mockEm()
285
+ const repo = new AiPendingActionRepository(em)
286
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
287
+ const createdAt = new Date('2026-04-18T12:00:00.000Z')
288
+
289
+ const { row: first } = await repo.create(
290
+ baseInput({ idempotencyKey: 'idem-window', now: createdAt, ttlSeconds: 900 }),
291
+ ctx,
292
+ )
293
+ await repo.setStatus(first.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
294
+
295
+ const justBeforeExpiry = new Date(first.expiresAt.getTime() - 1)
296
+ const { row: second, reused } = await repo.create(
297
+ baseInput({ idempotencyKey: 'idem-window', now: justBeforeExpiry }),
298
+ ctx,
299
+ )
300
+
301
+ expect(reused).toBe(true)
302
+ expect(second.id).toBe(first.id)
303
+ expect(second.status).toBe('confirmed')
231
304
  expect(em.__store).toHaveLength(1)
232
- expect(em.__store[0].id).toBe(second.id)
305
+ })
306
+
307
+ it('a confirmed row past its dedupe window is re-keyed and a fresh pending row is minted', async () => {
308
+ const em = mockEm()
309
+ const repo = new AiPendingActionRepository(em)
310
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
311
+ const createdAt = new Date('2026-04-18T12:00:00.000Z')
312
+
313
+ const { row: first } = await repo.create(
314
+ baseInput({ idempotencyKey: 'idem-stale-confirmed', now: createdAt, ttlSeconds: 900 }),
315
+ ctx,
316
+ )
317
+ await repo.setStatus(first.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
318
+
319
+ const afterExpiry = new Date(first.expiresAt.getTime() + 1)
320
+ const { row: second, reused } = await repo.create(
321
+ baseInput({ idempotencyKey: 'idem-stale-confirmed', now: afterExpiry }),
322
+ ctx,
323
+ )
324
+
325
+ expect(reused).toBe(false)
326
+ expect(second.id).not.toBe(first.id)
327
+ expect(second.status).toBe('pending')
328
+ expect(second.idempotencyKey).toBe('idem-stale-confirmed')
329
+ expect(em.__store).toHaveLength(2)
330
+ const stale = em.__store.find((candidate) => candidate.id === first.id)
331
+ expect(stale!.status).toBe('confirmed')
332
+ expect(stale!.idempotencyKey).toBe(`idem-stale-confirmed:superseded:${first.id}`)
333
+ })
334
+
335
+ it('an executing row past its dedupe window is re-keyed and a fresh pending row is minted', async () => {
336
+ const em = mockEm()
337
+ const repo = new AiPendingActionRepository(em)
338
+ const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
339
+ const createdAt = new Date('2026-04-18T12:00:00.000Z')
340
+
341
+ const { row: first } = await repo.create(
342
+ baseInput({ idempotencyKey: 'idem-stale-executing', now: createdAt, ttlSeconds: 900 }),
343
+ ctx,
344
+ )
345
+ await repo.setStatus(first.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
346
+ await repo.setStatus(first.id, 'executing', ctx)
347
+
348
+ // a process dying mid-execution leaves this row stuck in `executing`
349
+ // forever — the dedupe window is what lets a later retry proceed
350
+ // instead of being told to "wait for the result" indefinitely.
351
+ const afterExpiry = new Date(first.expiresAt.getTime() + 1)
352
+ const { row: second, reused } = await repo.create(
353
+ baseInput({ idempotencyKey: 'idem-stale-executing', now: afterExpiry }),
354
+ ctx,
355
+ )
356
+
357
+ expect(reused).toBe(false)
358
+ expect(second.id).not.toBe(first.id)
359
+ expect(second.status).toBe('pending')
360
+ expect(em.__store).toHaveLength(2)
361
+ const stale = em.__store.find((candidate) => candidate.id === first.id)
362
+ expect(stale!.status).toBe('executing')
363
+ expect(stale!.idempotencyKey).toBe(`idem-stale-executing:superseded:${first.id}`)
233
364
  })
234
365
 
235
366
  it('setStatus rejects illegal transitions (e.g. confirmed → pending) with AiPendingActionStateError', async () => {
@@ -237,7 +368,7 @@ describe('AiPendingActionRepository', () => {
237
368
  const repo = new AiPendingActionRepository(em)
238
369
  const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
239
370
 
240
- const row = await repo.create(baseInput({ idempotencyKey: 'idem-illegal' }), ctx)
371
+ const { row } = await repo.create(baseInput({ idempotencyKey: 'idem-illegal' }), ctx)
241
372
  await repo.setStatus(row.id, 'confirmed', ctx, { resolvedByUserId: 'u-1' })
242
373
 
243
374
  await expect(
@@ -253,7 +384,7 @@ describe('AiPendingActionRepository', () => {
253
384
  const repo = new AiPendingActionRepository(em)
254
385
  const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
255
386
 
256
- const row = await repo.create(baseInput({ idempotencyKey: 'idem-expire' }), ctx)
387
+ const { row } = await repo.create(baseInput({ idempotencyKey: 'idem-expire' }), ctx)
257
388
  const expiredAt = new Date('2026-04-18T13:00:00.000Z')
258
389
  const expired = await repo.setStatus(row.id, 'expired', ctx, { now: expiredAt })
259
390
 
@@ -317,7 +448,7 @@ describe('AiPendingActionRepository', () => {
317
448
  const repo = new AiPendingActionRepository(em)
318
449
  const ctxAlpha = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
319
450
 
320
- const row = await repo.create(baseInput({ idempotencyKey: 'idem-iso' }), ctxAlpha)
451
+ const { row } = await repo.create(baseInput({ idempotencyKey: 'idem-iso' }), ctxAlpha)
321
452
  const sameTenant = await repo.getById(row.id, ctxAlpha)
322
453
  expect(sameTenant?.id).toBe(row.id)
323
454
 
@@ -336,7 +467,7 @@ describe('AiPendingActionRepository', () => {
336
467
  const repo = new AiPendingActionRepository(em)
337
468
  const ctx = { tenantId: tenantAlpha, organizationId: null, userId: 'u-1' }
338
469
 
339
- const first = await repo.create(
470
+ const { row: first } = await repo.create(
340
471
  baseInput({ idempotencyKey: 'p-1', agentId: 'catalog.assistant' }),
341
472
  ctx,
342
473
  )
@@ -35,6 +35,7 @@ import {
35
35
  prepareMutation,
36
36
  } from '../prepare-mutation'
37
37
  import { resolveAiAgentTools } from '../agent-tools'
38
+ import { AiPendingActionRepository } from '../../data/repositories/AiPendingActionRepository'
38
39
  import type { AiAgentDefinition } from '../ai-agent-definition'
39
40
  import type { AiToolDefinition } from '../types'
40
41
  import {
@@ -717,6 +718,103 @@ describe('resolveAiAgentTools mutation interception (Step 5.6)', () => {
717
718
  expect(String(outcome)).toMatch(/pending-confirmation/)
718
719
  })
719
720
 
721
+ it('a reused confirmed row reports already-executed with executionResult, not pending-confirmation (issue #7550)', async () => {
722
+ const em = mockEm()
723
+ const container = makeContainer(em)
724
+ const tool: AiToolDefinition = {
725
+ name: 'catalog.products.update',
726
+ description: 'update',
727
+ inputSchema: z.object({}).passthrough(),
728
+ handler: async () => ({ shouldNeverRun: true }),
729
+ isMutation: true,
730
+ loadBeforeRecord: async () => ({
731
+ recordId: 'p-1',
732
+ entityType: 'catalog.product',
733
+ recordVersion: null,
734
+ before: { name: 'old' },
735
+ }),
736
+ }
737
+ registerMcpTool(tool, { moduleId: 'catalog' })
738
+ seedAgentRegistryForTests([
739
+ makeAgent({
740
+ id: 'catalog.merch',
741
+ allowedTools: ['catalog.products.update'],
742
+ }),
743
+ ])
744
+
745
+ const resolved = await resolveAiAgentTools({
746
+ agentId: 'catalog.merch',
747
+ authContext: baseCtx,
748
+ container,
749
+ conversationId: 'conv-1',
750
+ })
751
+ const adapted = resolved.tools['catalog__products__update'] as unknown as {
752
+ execute: (args: unknown) => Promise<unknown>
753
+ }
754
+
755
+ const firstOutcome = await adapted.execute({ productId: 'p-1', patch: { name: 'new' } })
756
+ expect(String(firstOutcome)).toMatch(/pending-confirmation/)
757
+ expect(em.__store).toHaveLength(1)
758
+
759
+ const repo = new AiPendingActionRepository(em)
760
+ const pendingActionId = em.__store[0].id
761
+ await repo.setStatus(pendingActionId, 'confirmed', baseCtx, { resolvedByUserId: 'u-1' })
762
+ em.__store[0].executionResult = { recordId: 'p-1' }
763
+
764
+ const secondOutcome = await adapted.execute({ productId: 'p-1', patch: { name: 'new' } })
765
+ // No second row was inserted — the unique-index collision from #7550 is avoided.
766
+ expect(em.__store).toHaveLength(1)
767
+ expect(String(secondOutcome)).toMatch(/already-executed/)
768
+ expect(String(secondOutcome)).not.toMatch(/pending-confirmation/)
769
+ expect(String(secondOutcome)).toMatch(/"recordId": "p-1"/)
770
+ })
771
+
772
+ it('a reused executing row reports status=executing, not pending-confirmation (issue #7550)', async () => {
773
+ const em = mockEm()
774
+ const container = makeContainer(em)
775
+ const tool: AiToolDefinition = {
776
+ name: 'catalog.products.update',
777
+ description: 'update',
778
+ inputSchema: z.object({}).passthrough(),
779
+ handler: async () => ({ shouldNeverRun: true }),
780
+ isMutation: true,
781
+ loadBeforeRecord: async () => ({
782
+ recordId: 'p-1',
783
+ entityType: 'catalog.product',
784
+ recordVersion: null,
785
+ before: { name: 'old' },
786
+ }),
787
+ }
788
+ registerMcpTool(tool, { moduleId: 'catalog' })
789
+ seedAgentRegistryForTests([
790
+ makeAgent({
791
+ id: 'catalog.merch',
792
+ allowedTools: ['catalog.products.update'],
793
+ }),
794
+ ])
795
+
796
+ const resolved = await resolveAiAgentTools({
797
+ agentId: 'catalog.merch',
798
+ authContext: baseCtx,
799
+ container,
800
+ conversationId: 'conv-1',
801
+ })
802
+ const adapted = resolved.tools['catalog__products__update'] as unknown as {
803
+ execute: (args: unknown) => Promise<unknown>
804
+ }
805
+
806
+ await adapted.execute({ productId: 'p-1', patch: { name: 'new' } })
807
+ const repo = new AiPendingActionRepository(em)
808
+ const pendingActionId = em.__store[0].id
809
+ await repo.setStatus(pendingActionId, 'confirmed', baseCtx, { resolvedByUserId: 'u-1' })
810
+ await repo.setStatus(pendingActionId, 'executing', baseCtx)
811
+
812
+ const secondOutcome = await adapted.execute({ productId: 'p-1', patch: { name: 'new' } })
813
+ expect(em.__store).toHaveLength(1)
814
+ expect(String(secondOutcome)).toMatch(/"status": "executing"/)
815
+ expect(String(secondOutcome)).not.toMatch(/pending-confirmation/)
816
+ })
817
+
720
818
  it('does NOT intercept non-mutation tools even when the agent is mutation-capable', async () => {
721
819
  const em = mockEm()
722
820
  const container = makeContainer(em)
@@ -6,6 +6,10 @@ import { createRequestContainer } from '@open-mercato/shared/lib/di/container'
6
6
  import type { AiAgentDefinition, AiAgentMutationPolicy } from './ai-agent-definition'
7
7
  import type { AiChatRequestContext, AiUiPart } from './attachment-bridge-types'
8
8
  import type { AiToolDefinition, McpToolContext } from './types'
9
+ import type {
10
+ AiPendingActionExecutionResult,
11
+ AiPendingActionStatus,
12
+ } from './pending-action-types'
9
13
  import { loadAgentRegistry } from './agent-registry'
10
14
  import {
11
15
  checkAgentPolicy,
@@ -180,7 +184,28 @@ function formatPendingActionToolResult(
180
184
  tool: AiToolDefinition,
181
185
  pendingActionId: string,
182
186
  expiresAt: Date,
187
+ reused: boolean,
188
+ status: AiPendingActionStatus,
189
+ executionResult: AiPendingActionExecutionResult | null,
183
190
  ): string {
191
+ // A reused `confirmed`/`executing` row means this exact mutation already
192
+ // ran (or is running) under the same idempotency key — tell the model so
193
+ // it does not resubmit, instead of always claiming a fresh approval is
194
+ // pending (issue #7550).
195
+ if (reused && (status === 'confirmed' || status === 'executing')) {
196
+ const resultStatus = status === 'executing' ? 'executing' : 'already-executed'
197
+ return formatToolResult({
198
+ status: resultStatus,
199
+ agentId: agent.id,
200
+ toolName: tool.name,
201
+ pendingActionId,
202
+ executionResult: executionResult ?? null,
203
+ message:
204
+ resultStatus === 'executing'
205
+ ? `Mutation "${tool.name}" is already executing from a prior confirmed call. Do NOT submit it again; wait for the result.`
206
+ : `Mutation "${tool.name}" already ran for this input (idempotency key reused). Do NOT submit it again — see executionResult for the prior outcome.`,
207
+ })
208
+ }
184
209
  return formatToolResult({
185
210
  status: 'pending-confirmation',
186
211
  agentId: agent.id,
@@ -245,7 +270,7 @@ function adaptToolToAiSdk(
245
270
  args && typeof args === 'object' && !Array.isArray(args)
246
271
  ? { ...(args as Record<string, unknown>) }
247
272
  : {}
248
- const { uiPart, pendingAction } = await prepareMutation(
273
+ const { uiPart, pendingAction, reused } = await prepareMutation(
249
274
  {
250
275
  agent: mutation.agent,
251
276
  tool: mutation.tool,
@@ -268,6 +293,9 @@ function adaptToolToAiSdk(
268
293
  mutation.tool,
269
294
  pendingAction.id,
270
295
  pendingAction.expiresAt,
296
+ reused,
297
+ pendingAction.status,
298
+ pendingAction.executionResult ?? null,
271
299
  )
272
300
  } catch (error) {
273
301
  const message = error instanceof Error ? error.message : String(error)
@@ -68,6 +68,12 @@ export interface PrepareMutationContext extends AiChatRequestContext {
68
68
  export interface PrepareMutationResult {
69
69
  uiPart: AiUiPart
70
70
  pendingAction: AiPendingAction
71
+ /**
72
+ * True when `pendingAction` is an existing row the repository returned
73
+ * as-is (still `pending`, or `confirmed`/`executing` because the
74
+ * mutation already ran) instead of a freshly inserted row.
75
+ */
76
+ reused: boolean
71
77
  }
72
78
 
73
79
  const MUTATION_PREVIEW_CARD_COMPONENT_ID = 'mutation-preview-card'
@@ -443,7 +449,7 @@ export async function prepareMutation(
443
449
  normalizedInput,
444
450
  })
445
451
 
446
- const pendingAction = await repo.create(
452
+ const { row: pendingAction, reused } = await repo.create(
447
453
  {
448
454
  agentId: agent.id,
449
455
  toolName: tool.name,
@@ -477,7 +483,7 @@ export async function prepareMutation(
477
483
  },
478
484
  }
479
485
 
480
- return { uiPart, pendingAction }
486
+ return { uiPart, pendingAction, reused }
481
487
  }
482
488
 
483
489
  export const MUTATION_PREVIEW_CARD_COMPONENT = MUTATION_PREVIEW_CARD_COMPONENT_ID