diff --git a/packages/modules/core.ai/src/server/apply.ts b/packages/modules/core.ai/src/server/apply.ts index 6a565abe..fbd25d4c 100644 --- a/packages/modules/core.ai/src/server/apply.ts +++ b/packages/modules/core.ai/src/server/apply.ts @@ -169,10 +169,15 @@ function ensureSingleOperation(proposal: AiProposal): AiProposalOperation { return operation; } -function applyReplaceSelection( +type ReplaceSelectionOperation = Extract< + AiProposalOperation, + { op: "replace_selection" } +>; + +function findReplaceSelectionMatch( body: string, - operation: Extract, -): string { + operation: ReplaceSelectionOperation, +): number { const original = operation.originalText; const index = body.indexOf(original); @@ -196,6 +201,16 @@ function applyReplaceSelection( ); } + return index; +} + +function applyReplaceSelection( + body: string, + operation: ReplaceSelectionOperation, +): string { + const original = operation.originalText; + const index = findReplaceSelectionMatch(body, operation); + return ( body.slice(0, index) + operation.replacementText + @@ -326,6 +341,14 @@ export async function applyAiProposal( operation.op === "replace_selection" && textAppearsExactlyOnce(existing.body, operation.originalText); + if ( + !baseDraftRevisionMatches && + operation.op === "replace_selection" && + !canApplyReplaceSelectionAgainstLiveRevision + ) { + findReplaceSelectionMatch(existing.body, operation); + } + if ( !baseDraftRevisionMatches && !canApplyReplaceSelectionAgainstLiveRevision diff --git a/packages/modules/core.ai/src/server/routes.test.ts b/packages/modules/core.ai/src/server/routes.test.ts index ec11094d..c72d9b51 100644 --- a/packages/modules/core.ai/src/server/routes.test.ts +++ b/packages/modules/core.ai/src/server/routes.test.ts @@ -545,6 +545,69 @@ describe("mountAiRoutes — proposals/:id/apply", () => { assert.equal(setup.updateCalls.length, 1); }); + test("returns source-not-found conflict when stale replace proposal cannot reanchor", async () => { + const proposal: AiProposal = { + proposalId: "p_reanchor_missing", + kind: "replace_selection", + project: "demo", + environment: "draft", + documentId: "doc_1", + baseDraftRevision: 4, + type: "post", + locale: "en", + summary: "Rewrite.", + operations: [ + { + op: "replace_selection", + selectionId: "sel_anchor", + originalText: "Welcome to the site.", + replacementText: "Hi there!", + }, + ], + validation: { status: "valid" }, + expiresAt: "2026-05-01T00:05:00.000Z", + provider: { + providerId: "echo", + model: "echo-1", + promptTemplateId: "copy_improvement.v1", + }, + }; + const setup = createTestSetup({ + document: buildDocument({ + draftRevision: 6, + body: "The intro has already changed.", + }), + }); + + const response = await setup.app.fetch( + "POST", + `https://test.local/api/v1/ai/proposals/${proposal.proposalId}/apply`, + { + method: "POST", + headers: TARGET_HEADERS, + body: JSON.stringify({ + proposal, + schemaHash: "hash_1", + draftRevision: 6, + }), + }, + ); + + assert.equal(response.status, 409); + const body = (await response.json()) as { + code: string; + message: string; + details?: Record; + }; + assert.equal(body.code, "AI_PROPOSAL_CONFLICT"); + assert.equal( + body.message, + "Original selection text was not found in the current draft body.", + ); + assert.equal(body.details?.selectionId, "sel_anchor"); + assert.equal(setup.updateCalls.length, 0); + }); + test("expired proposal returns 410 and emits expired audit", async () => { const orchestrator = createAiOrchestrator({ provider: createEchoAiProvider({