From a60d27211ff1d82b6e96deeb7b135fa2e526a860 Mon Sep 17 00:00:00 2001 From: Arham Wani Date: Sat, 8 Aug 2026 23:55:40 +0530 Subject: [PATCH 1/2] fix(ai): prevent stale agent edit overwrites --- src/components/ai-edition/LeftPanel.tsx | 28 ++++---- src/i18n/locales/ar/editor.json | 1 + src/i18n/locales/en/editor.json | 1 + src/i18n/locales/es/editor.json | 1 + src/i18n/locales/fr/editor.json | 1 + src/i18n/locales/it/editor.json | 1 + src/i18n/locales/ja-JP/editor.json | 1 + src/i18n/locales/ko-KR/editor.json | 1 + src/i18n/locales/pt-BR/editor.json | 1 + src/i18n/locales/ru/editor.json | 1 + src/i18n/locales/tr/editor.json | 1 + src/i18n/locales/vi/editor.json | 1 + src/i18n/locales/zh-CN/editor.json | 1 + src/i18n/locales/zh-TW/editor.json | 1 + .../store/agentDocumentApply.test.ts | 67 +++++++++++++++++++ .../ai-edition/store/agentDocumentApply.ts | 26 +++++++ 16 files changed, 122 insertions(+), 12 deletions(-) create mode 100644 src/lib/ai-edition/store/agentDocumentApply.test.ts create mode 100644 src/lib/ai-edition/store/agentDocumentApply.ts diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 1942d1da2..2b266ff92 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -3,7 +3,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; import { createPortal } from "react-dom"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; -import { type AxcutAsset, ensureDocument } from "@/lib/ai-edition/schema"; +import type { AxcutAsset } from "@/lib/ai-edition/schema"; +import { applyAgentDocumentIfCurrent } from "@/lib/ai-edition/store/agentDocumentApply"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useAssetTranscriptions, @@ -869,15 +870,13 @@ function ChatStripPanel() { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }); - // Apply a document returned by the agent (tool batch or undo). setDocument - // pushes the previous doc to the local undo stack (Cmd+Z also works), then - // saveDocument persists it to disk. - const applyAgentDocument = useCallback(async (doc: unknown) => { - const parsed = ensureDocument(doc); - const store = useProjectStore.getState(); - store.setDocument(parsed); - await store.saveDocument(parsed); - }, []); + // Apply a document returned by the agent (tool batch or rewind). Agent turns + // supply their starting revision so a concurrent manual edit wins; an explicit + // rewind omits it because replacing the live document is the confirmed action. + const applyAgentDocument = useCallback( + (doc: unknown, expectedRevision?: number) => applyAgentDocumentIfCurrent(doc, expectedRevision), + [], + ); const send = async (overrideText?: string) => { const text = (overrideText ?? input).trim(); @@ -928,7 +927,9 @@ function ChatStripPanel() { thinkingRunSessionRef.current = sessionId; // Send the current document snapshot so the agent can run edit tools // against it (P1). Falls back to text-only chat when no doc is open. - const documentSnapshot = useProjectStore.getState().document ?? undefined; + const snapshot = useProjectStore.getState(); + const documentSnapshot = snapshot.document ?? undefined; + const documentRevision = snapshot.revision; const result = await nativeBridgeClient.aiEdition.chatRun( projectId, sessionId, @@ -939,7 +940,10 @@ function ChatStripPanel() { if (result.success && assistant) { if (result.document) { try { - await applyAgentDocument(result.document); + const applyResult = await applyAgentDocument(result.document, documentRevision); + if (applyResult === "conflict") { + toast.warning(t("chat.agentEditConflict")); + } } catch (err) { toast.error(t("chat.applyEditsFailed"), { description: err instanceof Error ? err.message : String(err), diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 80dedcf6d..76465ab09 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "تعذّر اختيار النموذج", "providerSettings": "إعدادات المزوّد…", "applyEditsFailed": "تعذّر تطبيق تعديلات الوكيل", + "agentEditConflict": "لم تُطبَّق تعديلات الوكيل لأن المشروع تغيّر أثناء عمله.", "chatFailed": "فشلت المحادثة", "rewindFailed": "فشلت إعادة الضبط", "rewoundSuccess": "تمت إعادة الضبط إلى بداية تلك الرسالة", diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 429803556..1ef754751 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "Could not select model", "providerSettings": "Provider settings…", "applyEditsFailed": "Could not apply the agent's edits", + "agentEditConflict": "Agent edits were not applied because the project changed while it was working.", "chatFailed": "Chat failed", "rewindFailed": "Rewind failed", "rewoundSuccess": "Rewound to the start of that message", diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index 0e052ad78..b3a2df130 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "No se pudo seleccionar el modelo", "providerSettings": "Configuración del proveedor…", "applyEditsFailed": "No se pudieron aplicar las ediciones del agente", + "agentEditConflict": "Las ediciones del agente no se aplicaron porque el proyecto cambió mientras trabajaba.", "chatFailed": "Error en el chat", "rewindFailed": "Error al rebobinar", "rewoundSuccess": "Rebobinado al inicio de ese mensaje", diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index 5df6ab06d..ae581ff08 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "Impossible de sélectionner le modèle", "providerSettings": "Réglages du fournisseur…", "applyEditsFailed": "Impossible d'appliquer les modifications de l'agent", + "agentEditConflict": "Les modifications de l'agent n'ont pas été appliquées, car le projet a changé pendant son travail.", "chatFailed": "Échec du chat", "rewindFailed": "Échec du retour en arrière", "rewoundSuccess": "Retour au début de ce message effectué", diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index c19fc3543..24c191c78 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "Impossibile selezionare il modello", "providerSettings": "Impostazioni provider…", "applyEditsFailed": "Impossibile applicare le modifiche dell'agente", + "agentEditConflict": "Le modifiche dell'agente non sono state applicate perché il progetto è cambiato durante l'elaborazione.", "chatFailed": "Chat non riuscita", "rewindFailed": "Riavvolgimento non riuscito", "rewoundSuccess": "Riavvolto all'inizio di quel messaggio", diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index 516640491..e9db0679c 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "モデルを選択できませんでした", "providerSettings": "プロバイダー設定…", "applyEditsFailed": "エージェントの編集を適用できませんでした", + "agentEditConflict": "エージェントの処理中にプロジェクトが変更されたため、編集は適用されませんでした。", "chatFailed": "チャットに失敗しました", "rewindFailed": "巻き戻しに失敗しました", "rewoundSuccess": "そのメッセージの先頭まで巻き戻しました", diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index f2ebcdc01..ab9ac788c 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "모델을 선택할 수 없습니다", "providerSettings": "제공업체 설정…", "applyEditsFailed": "에이전트의 편집을 적용할 수 없습니다", + "agentEditConflict": "에이전트가 작업하는 동안 프로젝트가 변경되어 편집 내용이 적용되지 않았습니다.", "chatFailed": "채팅 실패", "rewindFailed": "되감기 실패", "rewoundSuccess": "해당 메시지 시작 지점으로 되감았습니다", diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index c5ab7154a..71b46e144 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "Não foi possível selecionar o modelo", "providerSettings": "Configurações do provedor…", "applyEditsFailed": "Não foi possível aplicar as edições do agente", + "agentEditConflict": "As edições do agente não foram aplicadas porque o projeto mudou durante o processamento.", "chatFailed": "Falha no chat", "rewindFailed": "Falha ao rebobinar", "rewoundSuccess": "Rebobinado até o início dessa mensagem", diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index 881ea68d3..a98939cb4 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "Не удалось выбрать модель", "providerSettings": "Настройки провайдера…", "applyEditsFailed": "Не удалось применить правки агента", + "agentEditConflict": "Правки агента не применены, потому что проект изменился во время его работы.", "chatFailed": "Ошибка чата", "rewindFailed": "Ошибка отката", "rewoundSuccess": "Откат к началу этого сообщения выполнен", diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index dfe1832fd..21533c98b 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "Model seçilemedi", "providerSettings": "Sağlayıcı ayarları…", "applyEditsFailed": "Aracının düzenlemeleri uygulanamadı", + "agentEditConflict": "Aracı çalışırken proje değiştiği için düzenlemeleri uygulanmadı.", "chatFailed": "Sohbet başarısız oldu", "rewindFailed": "Geri sarma başarısız oldu", "rewoundSuccess": "O mesajın başına geri sarıldı", diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index ee17bc1b8..0b8fbbc64 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "Không thể chọn mô hình", "providerSettings": "Cài đặt nhà cung cấp…", "applyEditsFailed": "Không thể áp dụng chỉnh sửa của tác nhân", + "agentEditConflict": "Các chỉnh sửa của tác nhân không được áp dụng vì dự án đã thay đổi trong lúc xử lý.", "chatFailed": "Trò chuyện thất bại", "rewindFailed": "Tua lại thất bại", "rewoundSuccess": "Đã tua lại đến đầu tin nhắn đó", diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 41be4e22f..833671b6e 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "无法选择模型", "providerSettings": "提供方设置…", "applyEditsFailed": "无法应用代理的编辑", + "agentEditConflict": "代理工作期间项目已更改,因此未应用代理的编辑。", "chatFailed": "聊天失败", "rewindFailed": "回退失败", "rewoundSuccess": "已回退到该消息的开头", diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index de53c8676..8ff93ac13 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -237,6 +237,7 @@ "selectModelFailed": "無法選擇模型", "providerSettings": "提供者設定…", "applyEditsFailed": "無法套用代理的編輯", + "agentEditConflict": "代理執行期間專案已變更,因此未套用代理的編輯。", "chatFailed": "聊天失敗", "rewindFailed": "倒轉失敗", "rewoundSuccess": "已倒轉至該訊息的開頭", diff --git a/src/lib/ai-edition/store/agentDocumentApply.test.ts b/src/lib/ai-edition/store/agentDocumentApply.test.ts new file mode 100644 index 000000000..fd38ad0ee --- /dev/null +++ b/src/lib/ai-edition/store/agentDocumentApply.test.ts @@ -0,0 +1,67 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyDocument } from "../schema"; +import { applyAgentDocumentIfCurrent } from "./agentDocumentApply"; +import { useProjectStore } from "./projectStore"; + +const saveMock = vi.hoisted(() => vi.fn()); + +vi.mock("@/native/client", () => ({ + nativeBridgeClient: { + aiEdition: { save: saveMock }, + }, +})); + +describe("applyAgentDocumentIfCurrent", () => { + beforeEach(() => { + useProjectStore.getState().clear(); + saveMock.mockReset(); + }); + + it("applies an agent result when the document revision is unchanged", async () => { + const before = createEmptyDocument({ projectId: "project_1", title: "Before" }); + const agentResult = { + ...before, + project: { ...before.project, title: "Agent edit" }, + }; + useProjectStore.setState({ projectId: "project_1", document: before, revision: 4 }); + saveMock.mockImplementation(async (document) => ({ success: true, document })); + + await expect(applyAgentDocumentIfCurrent(agentResult, 4)).resolves.toBe("applied"); + + expect(saveMock).toHaveBeenCalledOnce(); + expect(useProjectStore.getState().document?.project.title).toBe("Agent edit"); + }); + + it("preserves a manual edit made after the agent snapshot", async () => { + const before = createEmptyDocument({ projectId: "project_1", title: "Before" }); + const agentResult = { + ...before, + project: { ...before.project, title: "Agent edit" }, + }; + useProjectStore.setState({ projectId: "project_1", document: before, revision: 4 }); + useProjectStore.getState().setDocument({ + ...before, + project: { ...before.project, title: "Manual edit" }, + }); + + await expect(applyAgentDocumentIfCurrent(agentResult, 4)).resolves.toBe("conflict"); + + expect(saveMock).not.toHaveBeenCalled(); + expect(useProjectStore.getState().document?.project.title).toBe("Manual edit"); + }); + + it("allows an explicit rewind to replace the current revision", async () => { + const current = createEmptyDocument({ projectId: "project_1", title: "Current" }); + const checkpoint = { + ...current, + project: { ...current.project, title: "Checkpoint" }, + }; + useProjectStore.setState({ projectId: "project_1", document: current, revision: 9 }); + saveMock.mockImplementation(async (document) => ({ success: true, document })); + + await expect(applyAgentDocumentIfCurrent(checkpoint)).resolves.toBe("applied"); + + expect(useProjectStore.getState().document?.project.title).toBe("Checkpoint"); + }); +}); diff --git a/src/lib/ai-edition/store/agentDocumentApply.ts b/src/lib/ai-edition/store/agentDocumentApply.ts new file mode 100644 index 000000000..c2a0a1e5e --- /dev/null +++ b/src/lib/ai-edition/store/agentDocumentApply.ts @@ -0,0 +1,26 @@ +import { ensureDocument } from "../schema"; +import { useProjectStore } from "./projectStore"; + +export type AgentDocumentApplyResult = "applied" | "conflict"; + +/** + * Apply a full document returned by the agent only if the live editor is still + * on the revision used to start that agent turn. + * + * `expectedRevision` is omitted for explicit rewind operations, where replacing + * the current document is the action the user just confirmed. + */ +export async function applyAgentDocumentIfCurrent( + document: unknown, + expectedRevision?: number, +): Promise { + const store = useProjectStore.getState(); + if (expectedRevision !== undefined && store.revision !== expectedRevision) { + return "conflict"; + } + + const parsed = ensureDocument(document); + store.setDocument(parsed); + await store.saveDocument(parsed); + return "applied"; +} From 25f7c6aaff244256b23272b3f6e2befea8f61b1b Mon Sep 17 00:00:00 2001 From: EtienneLescot Date: Thu, 20 Aug 2026 11:59:10 +0200 Subject: [PATCH 2/2] fix(ai): keep the agent's turn when the project moved under it Three things the conflict guard left open. **The turn was thrown away.** On conflict the document was dropped on the floor while the chat went on rendering the assistant's "done, I removed 14 silences" and its green tool-call chips, and the only feedback was a toast blaming "the project changed". The thing that usually moves `revision` mid-turn is not the user: `transcriptionStore` transcribes every asset that lands in a document in the background and finishes with a save. So importing a five-minute recording and asking for silences to be cut costs a minute of waiting and the tokens, for something the user never did. The document is kept now and the toast carries "Apply anyway", with no auto-dismiss, because it is the only way back to it. **A failed save left the edits on screen.** `setDocument` runs before `saveDocument` can throw, so a locked project file showed the agent's edits under a toast saying they had been rejected -- with `dirty` set, so the next unrelated save persisted them. The previous document is restored now, through `setState` so the rejected one does not enter the undo stack. **Rewind quietly did the opposite of what the conflict just promised.** The confirmation sits one click away from the conflict toast and replaces the live document with the checkpoint, manual edit and all. Its copy said only that the agent's turns would be rolled back. It now says the edits made since are replaced too. The send-and-apply pair moves into `runAgentTurn`, which is what makes the guard testable: the revision has to be read from the same store snapshot as the document and before the turn is awaited, and none of that was visible at the call site, where the two are twenty lines apart in a 2,000-line component. Moving the read below the `await` restored the bug in full with all three tests green. It now fails two. Co-Authored-By: Claude Opus 5 --- src/components/ai-edition/LeftPanel.tsx | 62 ++++++++------ src/i18n/locales/ar/editor.json | 3 +- src/i18n/locales/en/editor.json | 3 +- src/i18n/locales/es/editor.json | 3 +- src/i18n/locales/fr/editor.json | 3 +- src/i18n/locales/it/editor.json | 3 +- src/i18n/locales/ja-JP/editor.json | 3 +- src/i18n/locales/ko-KR/editor.json | 3 +- src/i18n/locales/pt-BR/editor.json | 3 +- src/i18n/locales/ru/editor.json | 3 +- src/i18n/locales/tr/editor.json | 3 +- src/i18n/locales/vi/editor.json | 3 +- src/i18n/locales/zh-CN/editor.json | 3 +- src/i18n/locales/zh-TW/editor.json | 3 +- .../store/agentDocumentApply.test.ts | 82 ++++++++++++++++++- .../ai-edition/store/agentDocumentApply.ts | 68 ++++++++++++++- 16 files changed, 207 insertions(+), 44 deletions(-) diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 2b266ff92..e21a98281 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -4,7 +4,10 @@ import { createPortal } from "react-dom"; import { toast } from "sonner"; import { useScopedT } from "@/contexts/I18nContext"; import type { AxcutAsset } from "@/lib/ai-edition/schema"; -import { applyAgentDocumentIfCurrent } from "@/lib/ai-edition/store/agentDocumentApply"; +import { + applyAgentDocumentIfCurrent, + runAgentTurn, +} from "@/lib/ai-edition/store/agentDocumentApply"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useAssetTranscriptions, @@ -870,14 +873,6 @@ function ChatStripPanel() { scrollRef.current?.scrollTo({ top: scrollRef.current.scrollHeight, behavior: "smooth" }); }); - // Apply a document returned by the agent (tool batch or rewind). Agent turns - // supply their starting revision so a concurrent manual edit wins; an explicit - // rewind omits it because replacing the live document is the confirmed action. - const applyAgentDocument = useCallback( - (doc: unknown, expectedRevision?: number) => applyAgentDocumentIfCurrent(doc, expectedRevision), - [], - ); - const send = async (overrideText?: string) => { const text = (overrideText ?? input).trim(); if (!projectId || !text || busy) return; @@ -927,26 +922,39 @@ function ChatStripPanel() { thinkingRunSessionRef.current = sessionId; // Send the current document snapshot so the agent can run edit tools // against it (P1). Falls back to text-only chat when no doc is open. - const snapshot = useProjectStore.getState(); - const documentSnapshot = snapshot.document ?? undefined; - const documentRevision = snapshot.revision; - const result = await nativeBridgeClient.aiEdition.chatRun( - projectId, - sessionId, - text, - documentSnapshot, + // `runAgentTurn` reads the document AND the revision it is at from one snapshot + // before the turn starts, so a manual edit landing while the agent works is + // detectable when its answer comes back. + const { result, applyDocument } = await runAgentTurn((documentSnapshot) => + nativeBridgeClient.aiEdition.chatRun(projectId, sessionId, text, documentSnapshot), ); const assistant = result.assistantMessage; if (result.success && assistant) { if (result.document) { - try { - const applyResult = await applyAgentDocument(result.document, documentRevision); - if (applyResult === "conflict") { - toast.warning(t("chat.agentEditConflict")); + const applyEdits = async (options?: { ignoreConflict?: boolean }) => { + try { + return await applyDocument(options); + } catch (err) { + toast.error(t("chat.applyEditsFailed"), { + description: err instanceof Error ? err.message : String(err), + }); + return "conflict" as const; } - } catch (err) { - toast.error(t("chat.applyEditsFailed"), { - description: err instanceof Error ? err.message : String(err), + }; + if ((await applyEdits()) === "conflict") { + // The turn is not lost, it is just not automatically applied: the document + // is still in hand and the assistant's reply is about to be rendered as if + // the edits had landed. The thing that usually moves `revision` here is a + // background transcription finishing, not the user -- so dropping the whole + // turn on the floor and blaming "the project changed" costs them a minute + // of waiting and their tokens for something they never did. Let them take + // it. No auto-dismiss: it is the only way back to this document. + toast.warning(t("chat.agentEditConflict"), { + duration: Number.POSITIVE_INFINITY, + action: { + label: t("chat.applyAnyway"), + onClick: () => void applyEdits({ ignoreConflict: true }), + }, }); } } @@ -1023,7 +1031,9 @@ function ChatStripPanel() { return; } const doc = (result as { document?: unknown }).document; - if (doc) await applyAgentDocument(doc); + // No `expectedRevision`: a rewind REPLACES whatever is live, which is what the + // confirmation dialog now says out loud -- including any manual edit made since. + if (doc) await applyAgentDocumentIfCurrent(doc); setMessages( result.messages.map((m) => ({ id: m.id, @@ -1044,7 +1054,7 @@ function ChatStripPanel() { setRewindFor(null); } }, - [projectId, activeSessionId, applyAgentDocument, t], + [projectId, activeSessionId, t], ); useEffect(() => { diff --git a/src/i18n/locales/ar/editor.json b/src/i18n/locales/ar/editor.json index 76465ab09..64441099d 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -238,6 +238,7 @@ "providerSettings": "إعدادات المزوّد…", "applyEditsFailed": "تعذّر تطبيق تعديلات الوكيل", "agentEditConflict": "لم تُطبَّق تعديلات الوكيل لأن المشروع تغيّر أثناء عمله.", + "applyAnyway": "تطبيق على أي حال", "chatFailed": "فشلت المحادثة", "rewindFailed": "فشلت إعادة الضبط", "rewoundSuccess": "تمت إعادة الضبط إلى بداية تلك الرسالة", @@ -284,7 +285,7 @@ "sendTitle": "إرسال (Enter)", "send": "إرسال", "rewindConfirmTitle": "إعادة الضبط هنا؟", - "rewindConfirmBody": "سيتم التراجع عن تعديلات الوكيل والأدوار اللاحقة بعد هذه النقطة. سيُستعاد المشروع والمحادثة وحالة الوكيل.", + "rewindConfirmBody": "سيتم التراجع عن تعديلات الوكيل والأدوار اللاحقة بعد هذه النقطة. سيُستعاد المشروع والمحادثة وحالة الوكيل. وسيتم استبدال أي تعديلات أجريتها منذ ذلك الحين أيضًا.", "rewindConfirm": "إعادة الضبط", "configureModel": "تهيئة نموذج الذكاء الاصطناعي", "historyDialog": { diff --git a/src/i18n/locales/en/editor.json b/src/i18n/locales/en/editor.json index 1ef754751..316eea139 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -238,6 +238,7 @@ "providerSettings": "Provider settings…", "applyEditsFailed": "Could not apply the agent's edits", "agentEditConflict": "Agent edits were not applied because the project changed while it was working.", + "applyAnyway": "Apply anyway", "chatFailed": "Chat failed", "rewindFailed": "Rewind failed", "rewoundSuccess": "Rewound to the start of that message", @@ -284,7 +285,7 @@ "sendTitle": "Send (Enter)", "send": "Send", "rewindConfirmTitle": "Rewind here?", - "rewindConfirmBody": "The agent's edits and follow-up turns after this point get rolled back. Project, conversation, and agent state will be restored.", + "rewindConfirmBody": "The agent's edits and follow-up turns after this point get rolled back. Project, conversation, and agent state will be restored. Any edits you made since then are replaced too.", "rewindConfirm": "Rewind", "configureModel": "Configure AI Model", "historyDialog": { diff --git a/src/i18n/locales/es/editor.json b/src/i18n/locales/es/editor.json index b3a2df130..00a9e268c 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -238,6 +238,7 @@ "providerSettings": "Configuración del proveedor…", "applyEditsFailed": "No se pudieron aplicar las ediciones del agente", "agentEditConflict": "Las ediciones del agente no se aplicaron porque el proyecto cambió mientras trabajaba.", + "applyAnyway": "Aplicar de todos modos", "chatFailed": "Error en el chat", "rewindFailed": "Error al rebobinar", "rewoundSuccess": "Rebobinado al inicio de ese mensaje", @@ -284,7 +285,7 @@ "sendTitle": "Enviar (Intro)", "send": "Enviar", "rewindConfirmTitle": "¿Rebobinar aquí?", - "rewindConfirmBody": "Las ediciones del agente y los turnos posteriores a este punto se revertirán. Se restaurará el proyecto, la conversación y el estado del agente.", + "rewindConfirmBody": "Las ediciones del agente y los turnos posteriores a este punto se revertirán. Se restaurará el proyecto, la conversación y el estado del agente. Las ediciones que hayas hecho desde entonces también se reemplazarán.", "rewindConfirm": "Rebobinar", "configureModel": "Configurar modelo de IA", "historyDialog": { diff --git a/src/i18n/locales/fr/editor.json b/src/i18n/locales/fr/editor.json index ae581ff08..aaad5b6bb 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -238,6 +238,7 @@ "providerSettings": "Réglages du fournisseur…", "applyEditsFailed": "Impossible d'appliquer les modifications de l'agent", "agentEditConflict": "Les modifications de l'agent n'ont pas été appliquées, car le projet a changé pendant son travail.", + "applyAnyway": "Appliquer quand même", "chatFailed": "Échec du chat", "rewindFailed": "Échec du retour en arrière", "rewoundSuccess": "Retour au début de ce message effectué", @@ -284,7 +285,7 @@ "sendTitle": "Envoyer (Entrée)", "send": "Envoyer", "rewindConfirmTitle": "Revenir ici ?", - "rewindConfirmBody": "Les modifications de l'agent et les échanges suivants après ce point seront annulés. Le projet, la conversation et l'état de l'agent seront restaurés.", + "rewindConfirmBody": "Les modifications de l'agent et les échanges suivants après ce point seront annulés. Le projet, la conversation et l'état de l'agent seront restaurés. Les modifications que vous avez faites depuis seront elles aussi remplacées.", "rewindConfirm": "Revenir en arrière", "configureModel": "Configurer le modèle d'IA", "historyDialog": { diff --git a/src/i18n/locales/it/editor.json b/src/i18n/locales/it/editor.json index 24c191c78..ce5e89260 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -238,6 +238,7 @@ "providerSettings": "Impostazioni provider…", "applyEditsFailed": "Impossibile applicare le modifiche dell'agente", "agentEditConflict": "Le modifiche dell'agente non sono state applicate perché il progetto è cambiato durante l'elaborazione.", + "applyAnyway": "Applica comunque", "chatFailed": "Chat non riuscita", "rewindFailed": "Riavvolgimento non riuscito", "rewoundSuccess": "Riavvolto all'inizio di quel messaggio", @@ -284,7 +285,7 @@ "sendTitle": "Invia (Invio)", "send": "Invia", "rewindConfirmTitle": "Riavvolgere qui?", - "rewindConfirmBody": "Le modifiche dell'agente e i turni successivi a questo punto verranno annullati. Il progetto, la conversazione e lo stato dell'agente verranno ripristinati.", + "rewindConfirmBody": "Le modifiche dell'agente e i turni successivi a questo punto verranno annullati. Il progetto, la conversazione e lo stato dell'agente verranno ripristinati. Anche le modifiche che hai fatto da allora verranno sostituite.", "rewindConfirm": "Riavvolgi", "configureModel": "Configura modello IA", "historyDialog": { diff --git a/src/i18n/locales/ja-JP/editor.json b/src/i18n/locales/ja-JP/editor.json index e9db0679c..12a4a09e7 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -238,6 +238,7 @@ "providerSettings": "プロバイダー設定…", "applyEditsFailed": "エージェントの編集を適用できませんでした", "agentEditConflict": "エージェントの処理中にプロジェクトが変更されたため、編集は適用されませんでした。", + "applyAnyway": "それでも適用", "chatFailed": "チャットに失敗しました", "rewindFailed": "巻き戻しに失敗しました", "rewoundSuccess": "そのメッセージの先頭まで巻き戻しました", @@ -284,7 +285,7 @@ "sendTitle": "送信(Enter)", "send": "送信", "rewindConfirmTitle": "ここまで巻き戻しますか?", - "rewindConfirmBody": "この時点以降のエージェントの編集とやり取りは元に戻されます。プロジェクト、会話、エージェントの状態が復元されます。", + "rewindConfirmBody": "この時点以降のエージェントの編集とやり取りは元に戻されます。プロジェクト、会話、エージェントの状態が復元されます。それ以降にあなたが行った編集も置き換えられます。", "rewindConfirm": "巻き戻す", "configureModel": "AIモデルを設定", "historyDialog": { diff --git a/src/i18n/locales/ko-KR/editor.json b/src/i18n/locales/ko-KR/editor.json index ab9ac788c..a8f8595d8 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -238,6 +238,7 @@ "providerSettings": "제공업체 설정…", "applyEditsFailed": "에이전트의 편집을 적용할 수 없습니다", "agentEditConflict": "에이전트가 작업하는 동안 프로젝트가 변경되어 편집 내용이 적용되지 않았습니다.", + "applyAnyway": "그래도 적용", "chatFailed": "채팅 실패", "rewindFailed": "되감기 실패", "rewoundSuccess": "해당 메시지 시작 지점으로 되감았습니다", @@ -284,7 +285,7 @@ "sendTitle": "보내기 (Enter)", "send": "보내기", "rewindConfirmTitle": "여기로 되감을까요?", - "rewindConfirmBody": "이 시점 이후의 에이전트 편집과 이후 대화 턴이 롤백됩니다. 프로젝트, 대화, 에이전트 상태가 복원됩니다.", + "rewindConfirmBody": "이 시점 이후의 에이전트 편집과 이후 대화 턴이 롤백됩니다. 프로젝트, 대화, 에이전트 상태가 복원됩니다. 그 이후에 직접 한 편집도 함께 대체됩니다.", "rewindConfirm": "되감기", "configureModel": "AI 모델 구성", "historyDialog": { diff --git a/src/i18n/locales/pt-BR/editor.json b/src/i18n/locales/pt-BR/editor.json index 71b46e144..b95566670 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -238,6 +238,7 @@ "providerSettings": "Configurações do provedor…", "applyEditsFailed": "Não foi possível aplicar as edições do agente", "agentEditConflict": "As edições do agente não foram aplicadas porque o projeto mudou durante o processamento.", + "applyAnyway": "Aplicar mesmo assim", "chatFailed": "Falha no chat", "rewindFailed": "Falha ao rebobinar", "rewoundSuccess": "Rebobinado até o início dessa mensagem", @@ -284,7 +285,7 @@ "sendTitle": "Enviar (Enter)", "send": "Enviar", "rewindConfirmTitle": "Rebobinar até aqui?", - "rewindConfirmBody": "As edições do agente e as interações seguintes a partir deste ponto serão desfeitas. O projeto, a conversa e o estado do agente serão restaurados.", + "rewindConfirmBody": "As edições do agente e as interações seguintes a partir deste ponto serão desfeitas. O projeto, a conversa e o estado do agente serão restaurados. As edições que você fez desde então também serão substituídas.", "rewindConfirm": "Rebobinar", "configureModel": "Configurar modelo de IA", "historyDialog": { diff --git a/src/i18n/locales/ru/editor.json b/src/i18n/locales/ru/editor.json index a98939cb4..bbcc623f1 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -238,6 +238,7 @@ "providerSettings": "Настройки провайдера…", "applyEditsFailed": "Не удалось применить правки агента", "agentEditConflict": "Правки агента не применены, потому что проект изменился во время его работы.", + "applyAnyway": "Всё равно применить", "chatFailed": "Ошибка чата", "rewindFailed": "Ошибка отката", "rewoundSuccess": "Откат к началу этого сообщения выполнен", @@ -284,7 +285,7 @@ "sendTitle": "Отправить (Enter)", "send": "Отправить", "rewindConfirmTitle": "Откатить сюда?", - "rewindConfirmBody": "Правки агента и последующие сообщения после этой точки будут отменены. Проект, беседа и состояние агента будут восстановлены.", + "rewindConfirmBody": "Правки агента и последующие сообщения после этой точки будут отменены. Проект, беседа и состояние агента будут восстановлены. Правки, сделанные вами с тех пор, тоже будут заменены.", "rewindConfirm": "Откатить", "configureModel": "Настроить модель ИИ", "historyDialog": { diff --git a/src/i18n/locales/tr/editor.json b/src/i18n/locales/tr/editor.json index 21533c98b..25bc0145d 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -238,6 +238,7 @@ "providerSettings": "Sağlayıcı ayarları…", "applyEditsFailed": "Aracının düzenlemeleri uygulanamadı", "agentEditConflict": "Aracı çalışırken proje değiştiği için düzenlemeleri uygulanmadı.", + "applyAnyway": "Yine de uygula", "chatFailed": "Sohbet başarısız oldu", "rewindFailed": "Geri sarma başarısız oldu", "rewoundSuccess": "O mesajın başına geri sarıldı", @@ -284,7 +285,7 @@ "sendTitle": "Gönder (Enter)", "send": "Gönder", "rewindConfirmTitle": "Buraya geri sarılsın mı?", - "rewindConfirmBody": "Bu noktadan sonraki aracı düzenlemeleri ve takip eden turlar geri alınacak. Proje, konuşma ve aracı durumu geri yüklenecek.", + "rewindConfirmBody": "Bu noktadan sonraki aracı düzenlemeleri ve takip eden turlar geri alınacak. Proje, konuşma ve aracı durumu geri yüklenecek. O zamandan beri yaptığınız düzenlemeler de değiştirilir.", "rewindConfirm": "Geri sar", "configureModel": "Yapay zeka modelini yapılandır", "historyDialog": { diff --git a/src/i18n/locales/vi/editor.json b/src/i18n/locales/vi/editor.json index 0b8fbbc64..d471e20eb 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -238,6 +238,7 @@ "providerSettings": "Cài đặt nhà cung cấp…", "applyEditsFailed": "Không thể áp dụng chỉnh sửa của tác nhân", "agentEditConflict": "Các chỉnh sửa của tác nhân không được áp dụng vì dự án đã thay đổi trong lúc xử lý.", + "applyAnyway": "Vẫn áp dụng", "chatFailed": "Trò chuyện thất bại", "rewindFailed": "Tua lại thất bại", "rewoundSuccess": "Đã tua lại đến đầu tin nhắn đó", @@ -284,7 +285,7 @@ "sendTitle": "Gửi (Enter)", "send": "Gửi", "rewindConfirmTitle": "Tua lại đến đây?", - "rewindConfirmBody": "Các chỉnh sửa của tác nhân và các lượt tiếp theo sau điểm này sẽ được khôi phục lại. Dự án, cuộc trò chuyện và trạng thái tác nhân sẽ được khôi phục.", + "rewindConfirmBody": "Các chỉnh sửa của tác nhân và các lượt tiếp theo sau điểm này sẽ được khôi phục lại. Dự án, cuộc trò chuyện và trạng thái tác nhân sẽ được khôi phục. Các chỉnh sửa bạn đã thực hiện từ lúc đó cũng sẽ bị thay thế.", "rewindConfirm": "Tua lại", "configureModel": "Cấu hình mô hình AI", "historyDialog": { diff --git a/src/i18n/locales/zh-CN/editor.json b/src/i18n/locales/zh-CN/editor.json index 833671b6e..2cc1cffe0 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -238,6 +238,7 @@ "providerSettings": "提供方设置…", "applyEditsFailed": "无法应用代理的编辑", "agentEditConflict": "代理工作期间项目已更改,因此未应用代理的编辑。", + "applyAnyway": "仍然应用", "chatFailed": "聊天失败", "rewindFailed": "回退失败", "rewoundSuccess": "已回退到该消息的开头", @@ -284,7 +285,7 @@ "sendTitle": "发送(回车)", "send": "发送", "rewindConfirmTitle": "要回退到这里吗?", - "rewindConfirmBody": "此时间点之后代理的编辑和后续对话轮次将被回滚。项目、对话和代理状态将被恢复。", + "rewindConfirmBody": "此时间点之后代理的编辑和后续对话轮次将被回滚。项目、对话和代理状态将被恢复。你在那之后所做的编辑也会被替换。", "rewindConfirm": "回退", "configureModel": "配置 AI 模型", "historyDialog": { diff --git a/src/i18n/locales/zh-TW/editor.json b/src/i18n/locales/zh-TW/editor.json index 8ff93ac13..ea338776f 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -238,6 +238,7 @@ "providerSettings": "提供者設定…", "applyEditsFailed": "無法套用代理的編輯", "agentEditConflict": "代理執行期間專案已變更,因此未套用代理的編輯。", + "applyAnyway": "仍然套用", "chatFailed": "聊天失敗", "rewindFailed": "倒轉失敗", "rewoundSuccess": "已倒轉至該訊息的開頭", @@ -284,7 +285,7 @@ "sendTitle": "傳送(Enter)", "send": "傳送", "rewindConfirmTitle": "要倒轉到這裡嗎?", - "rewindConfirmBody": "此時間點之後代理的編輯與後續對話回合將被回復。專案、對話與代理狀態將被還原。", + "rewindConfirmBody": "此時間點之後代理的編輯與後續對話回合將被回復。專案、對話與代理狀態將被還原。你在那之後所做的編輯也會被取代。", "rewindConfirm": "倒轉", "configureModel": "設定 AI 模型", "historyDialog": { diff --git a/src/lib/ai-edition/store/agentDocumentApply.test.ts b/src/lib/ai-edition/store/agentDocumentApply.test.ts index fd38ad0ee..74858ccac 100644 --- a/src/lib/ai-edition/store/agentDocumentApply.test.ts +++ b/src/lib/ai-edition/store/agentDocumentApply.test.ts @@ -1,7 +1,7 @@ // @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; import { createEmptyDocument } from "../schema"; -import { applyAgentDocumentIfCurrent } from "./agentDocumentApply"; +import { applyAgentDocumentIfCurrent, runAgentTurn } from "./agentDocumentApply"; import { useProjectStore } from "./projectStore"; const saveMock = vi.hoisted(() => vi.fn()); @@ -51,6 +51,20 @@ describe("applyAgentDocumentIfCurrent", () => { expect(useProjectStore.getState().document?.project.title).toBe("Manual edit"); }); + it("puts the document back when the save fails", async () => { + // Without this the user is told the edits were rejected while looking at them, and + // `dirty` is left set -- so the next unrelated save writes the rejected document. + const before = createEmptyDocument({ projectId: "project_1", title: "Before" }); + const agentResult = { ...before, project: { ...before.project, title: "Agent edit" } }; + useProjectStore.setState({ projectId: "project_1", document: before, revision: 4 }); + saveMock.mockResolvedValue({ success: false, error: "EACCES" }); + + await expect(applyAgentDocumentIfCurrent(agentResult, 4)).rejects.toThrow("EACCES"); + + expect(useProjectStore.getState().document?.project.title).toBe("Before"); + expect(useProjectStore.getState().dirty).toBe(false); + }); + it("allows an explicit rewind to replace the current revision", async () => { const current = createEmptyDocument({ projectId: "project_1", title: "Current" }); const checkpoint = { @@ -65,3 +79,69 @@ describe("applyAgentDocumentIfCurrent", () => { expect(useProjectStore.getState().document?.project.title).toBe("Checkpoint"); }); }); + +describe("runAgentTurn", () => { + beforeEach(() => { + useProjectStore.getState().clear(); + saveMock.mockReset(); + }); + + it("refuses to apply when the document moved WHILE the turn was running", async () => { + // The assertion the guard actually needs. Reading `revision` after the await -- + // the one-line mistake that restores the bug in full -- leaves every other test in + // this file green, because they all move the store before the turn starts. + const before = createEmptyDocument({ projectId: "project_1", title: "Before" }); + useProjectStore.setState({ projectId: "project_1", document: before, revision: 4 }); + saveMock.mockImplementation(async (document) => ({ success: true, document })); + + const { result, applyDocument } = await runAgentTurn(async (documentSnapshot) => { + // A background transcription landing mid-turn, which is the common case. + useProjectStore.getState().setDocument({ + ...before, + project: { ...before.project, title: "Manual edit" }, + }); + return { + document: { ...documentSnapshot, project: { ...before.project, title: "Agent edit" } }, + }; + }); + + expect(result.document).toBeTruthy(); + await expect(applyDocument()).resolves.toBe("conflict"); + expect(saveMock).not.toHaveBeenCalled(); + expect(useProjectStore.getState().document?.project.title).toBe("Manual edit"); + }); + + it("applies anyway when the user answers the conflict toast", async () => { + const before = createEmptyDocument({ projectId: "project_1", title: "Before" }); + useProjectStore.setState({ projectId: "project_1", document: before, revision: 4 }); + saveMock.mockImplementation(async (document) => ({ success: true, document })); + + const { applyDocument } = await runAgentTurn(async () => { + useProjectStore.getState().setDocument({ + ...before, + project: { ...before.project, title: "Manual edit" }, + }); + return { document: { ...before, project: { ...before.project, title: "Agent edit" } } }; + }); + + await expect(applyDocument()).resolves.toBe("conflict"); + // Same turn, same document, still in hand -- the point of keeping it. + await expect(applyDocument({ ignoreConflict: true })).resolves.toBe("applied"); + expect(useProjectStore.getState().document?.project.title).toBe("Agent edit"); + }); + + it("never writes a text-only turn over a real project", async () => { + // With no document open the agent runs against an empty stand-in that still + // carries the real project id, so a matching revision must not be enough. + useProjectStore.setState({ projectId: "project_1", document: null, revision: 0 }); + saveMock.mockImplementation(async (document) => ({ success: true, document })); + + const { applyDocument } = await runAgentTurn(async (documentSnapshot) => { + expect(documentSnapshot).toBeUndefined(); + return { document: createEmptyDocument({ projectId: "project_1", title: "Empty" }) }; + }); + + await expect(applyDocument()).resolves.toBe("no-live-document"); + expect(saveMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/ai-edition/store/agentDocumentApply.ts b/src/lib/ai-edition/store/agentDocumentApply.ts index c2a0a1e5e..167b3c532 100644 --- a/src/lib/ai-edition/store/agentDocumentApply.ts +++ b/src/lib/ai-edition/store/agentDocumentApply.ts @@ -1,14 +1,16 @@ +import type { AxcutDocument } from "../schema"; import { ensureDocument } from "../schema"; import { useProjectStore } from "./projectStore"; -export type AgentDocumentApplyResult = "applied" | "conflict"; +export type AgentDocumentApplyResult = "applied" | "conflict" | "no-live-document"; /** * Apply a full document returned by the agent only if the live editor is still * on the revision used to start that agent turn. * - * `expectedRevision` is omitted for explicit rewind operations, where replacing - * the current document is the action the user just confirmed. + * `expectedRevision` is omitted for explicit rewind operations, and for the user + * answering the conflict toast with "apply anyway" -- in both cases replacing the + * current document is the action they just asked for. */ export async function applyAgentDocumentIfCurrent( document: unknown, @@ -20,7 +22,65 @@ export async function applyAgentDocumentIfCurrent( } const parsed = ensureDocument(document); + const previous = store.document; + const previousDirty = store.dirty; + // Both calls, not just the save. `setDocument` is the only thing that pushes the + // outgoing document onto the undo stack, so deleting it as "redundant next to + // saveDocument, which sets `document` too" silently breaks Ctrl+Z after an agent edit. + // `saveDocument` is what reaches the disk. store.setDocument(parsed); - await store.saveDocument(parsed); + try { + await store.saveDocument(parsed); + } catch (err) { + // The edits are on screen by now. Leaving them there while the caller toasts + // "could not apply the agent's edits" tells the user two opposite things at once, + // and worse: `dirty` is set, so the next unrelated save would quietly persist the + // document we just said was rejected. + // + // Restored through `setState` rather than `setDocument`, so the rejected document + // does not land on the undo stack. `revision` keeps the bump: it did move, and + // leaving it forward makes any in-flight guard read "conflict", which is the safe + // direction to be wrong in. + if (previous) useProjectStore.setState({ document: previous, dirty: previousDirty }); + throw err; + } return "applied"; } + +export interface AgentTurn { + result: T; + /** + * Apply this turn's document, if it produced one, against the revision the agent + * was actually given. `ignoreConflict` is the user answering the conflict toast. + */ + applyDocument: (options?: { ignoreConflict?: boolean }) => Promise; +} + +/** + * Run one agent turn against the live document and hand back a way to apply its result. + * + * This exists to make the ordering structural. The guard is only worth anything if the + * revision is the one the agent was handed, read from the SAME store snapshot as the + * document and BEFORE the turn is awaited -- and none of that is visible at the call + * site, where the read and the `await` are twenty lines apart in a 2,000-line component. + * Moving the read below the await leaves every assertion about the guard passing while + * the bug is fully restored. In here it is three adjacent lines, and a test can hold a + * turn open, edit the store underneath it, and watch the apply refuse. + */ +export async function runAgentTurn( + run: (documentSnapshot: AxcutDocument | undefined) => Promise, +): Promise> { + const { document, revision } = useProjectStore.getState(); + const snapshot = document ?? undefined; + const result = await run(snapshot); + return { + result, + applyDocument: async ({ ignoreConflict = false } = {}) => { + // No document open means the agent ran text-only, against an empty stand-in that + // still carries the real project id. If it edited that and the revision happened + // to match, applying would write a near-empty document over a real project. + if (!snapshot) return "no-live-document"; + return applyAgentDocumentIfCurrent(result.document, ignoreConflict ? undefined : revision); + }, + }; +}