diff --git a/src/components/ai-edition/LeftPanel.tsx b/src/components/ai-edition/LeftPanel.tsx index 1942d1da2..e21a98281 100644 --- a/src/components/ai-edition/LeftPanel.tsx +++ b/src/components/ai-edition/LeftPanel.tsx @@ -3,7 +3,11 @@ 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, + runAgentTurn, +} from "@/lib/ai-edition/store/agentDocumentApply"; import { useProjectStore } from "@/lib/ai-edition/store/projectStore"; import { useAssetTranscriptions, @@ -869,16 +873,6 @@ 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); - }, []); - const send = async (overrideText?: string) => { const text = (overrideText ?? input).trim(); if (!projectId || !text || busy) return; @@ -928,21 +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 documentSnapshot = useProjectStore.getState().document ?? undefined; - 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 { - await applyAgentDocument(result.document); - } catch (err) { - toast.error(t("chat.applyEditsFailed"), { - description: err instanceof Error ? err.message : String(err), + 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; + } + }; + 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 }), + }, }); } } @@ -1019,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, @@ -1040,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 80dedcf6d..64441099d 100644 --- a/src/i18n/locales/ar/editor.json +++ b/src/i18n/locales/ar/editor.json @@ -237,6 +237,8 @@ "selectModelFailed": "تعذّر اختيار النموذج", "providerSettings": "إعدادات المزوّد…", "applyEditsFailed": "تعذّر تطبيق تعديلات الوكيل", + "agentEditConflict": "لم تُطبَّق تعديلات الوكيل لأن المشروع تغيّر أثناء عمله.", + "applyAnyway": "تطبيق على أي حال", "chatFailed": "فشلت المحادثة", "rewindFailed": "فشلت إعادة الضبط", "rewoundSuccess": "تمت إعادة الضبط إلى بداية تلك الرسالة", @@ -283,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 429803556..316eea139 100644 --- a/src/i18n/locales/en/editor.json +++ b/src/i18n/locales/en/editor.json @@ -237,6 +237,8 @@ "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.", + "applyAnyway": "Apply anyway", "chatFailed": "Chat failed", "rewindFailed": "Rewind failed", "rewoundSuccess": "Rewound to the start of that message", @@ -283,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 0e052ad78..00a9e268c 100644 --- a/src/i18n/locales/es/editor.json +++ b/src/i18n/locales/es/editor.json @@ -237,6 +237,8 @@ "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.", + "applyAnyway": "Aplicar de todos modos", "chatFailed": "Error en el chat", "rewindFailed": "Error al rebobinar", "rewoundSuccess": "Rebobinado al inicio de ese mensaje", @@ -283,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 5df6ab06d..aaad5b6bb 100644 --- a/src/i18n/locales/fr/editor.json +++ b/src/i18n/locales/fr/editor.json @@ -237,6 +237,8 @@ "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.", + "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é", @@ -283,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 c19fc3543..ce5e89260 100644 --- a/src/i18n/locales/it/editor.json +++ b/src/i18n/locales/it/editor.json @@ -237,6 +237,8 @@ "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.", + "applyAnyway": "Applica comunque", "chatFailed": "Chat non riuscita", "rewindFailed": "Riavvolgimento non riuscito", "rewoundSuccess": "Riavvolto all'inizio di quel messaggio", @@ -283,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 516640491..12a4a09e7 100644 --- a/src/i18n/locales/ja-JP/editor.json +++ b/src/i18n/locales/ja-JP/editor.json @@ -237,6 +237,8 @@ "selectModelFailed": "モデルを選択できませんでした", "providerSettings": "プロバイダー設定…", "applyEditsFailed": "エージェントの編集を適用できませんでした", + "agentEditConflict": "エージェントの処理中にプロジェクトが変更されたため、編集は適用されませんでした。", + "applyAnyway": "それでも適用", "chatFailed": "チャットに失敗しました", "rewindFailed": "巻き戻しに失敗しました", "rewoundSuccess": "そのメッセージの先頭まで巻き戻しました", @@ -283,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 f2ebcdc01..a8f8595d8 100644 --- a/src/i18n/locales/ko-KR/editor.json +++ b/src/i18n/locales/ko-KR/editor.json @@ -237,6 +237,8 @@ "selectModelFailed": "모델을 선택할 수 없습니다", "providerSettings": "제공업체 설정…", "applyEditsFailed": "에이전트의 편집을 적용할 수 없습니다", + "agentEditConflict": "에이전트가 작업하는 동안 프로젝트가 변경되어 편집 내용이 적용되지 않았습니다.", + "applyAnyway": "그래도 적용", "chatFailed": "채팅 실패", "rewindFailed": "되감기 실패", "rewoundSuccess": "해당 메시지 시작 지점으로 되감았습니다", @@ -283,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 c5ab7154a..b95566670 100644 --- a/src/i18n/locales/pt-BR/editor.json +++ b/src/i18n/locales/pt-BR/editor.json @@ -237,6 +237,8 @@ "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.", + "applyAnyway": "Aplicar mesmo assim", "chatFailed": "Falha no chat", "rewindFailed": "Falha ao rebobinar", "rewoundSuccess": "Rebobinado até o início dessa mensagem", @@ -283,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 881ea68d3..bbcc623f1 100644 --- a/src/i18n/locales/ru/editor.json +++ b/src/i18n/locales/ru/editor.json @@ -237,6 +237,8 @@ "selectModelFailed": "Не удалось выбрать модель", "providerSettings": "Настройки провайдера…", "applyEditsFailed": "Не удалось применить правки агента", + "agentEditConflict": "Правки агента не применены, потому что проект изменился во время его работы.", + "applyAnyway": "Всё равно применить", "chatFailed": "Ошибка чата", "rewindFailed": "Ошибка отката", "rewoundSuccess": "Откат к началу этого сообщения выполнен", @@ -283,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 dfe1832fd..25bc0145d 100644 --- a/src/i18n/locales/tr/editor.json +++ b/src/i18n/locales/tr/editor.json @@ -237,6 +237,8 @@ "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ı.", + "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ı", @@ -283,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 ee17bc1b8..d471e20eb 100644 --- a/src/i18n/locales/vi/editor.json +++ b/src/i18n/locales/vi/editor.json @@ -237,6 +237,8 @@ "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ý.", + "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 đó", @@ -283,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 41be4e22f..2cc1cffe0 100644 --- a/src/i18n/locales/zh-CN/editor.json +++ b/src/i18n/locales/zh-CN/editor.json @@ -237,6 +237,8 @@ "selectModelFailed": "无法选择模型", "providerSettings": "提供方设置…", "applyEditsFailed": "无法应用代理的编辑", + "agentEditConflict": "代理工作期间项目已更改,因此未应用代理的编辑。", + "applyAnyway": "仍然应用", "chatFailed": "聊天失败", "rewindFailed": "回退失败", "rewoundSuccess": "已回退到该消息的开头", @@ -283,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 de53c8676..ea338776f 100644 --- a/src/i18n/locales/zh-TW/editor.json +++ b/src/i18n/locales/zh-TW/editor.json @@ -237,6 +237,8 @@ "selectModelFailed": "無法選擇模型", "providerSettings": "提供者設定…", "applyEditsFailed": "無法套用代理的編輯", + "agentEditConflict": "代理執行期間專案已變更,因此未套用代理的編輯。", + "applyAnyway": "仍然套用", "chatFailed": "聊天失敗", "rewindFailed": "倒轉失敗", "rewoundSuccess": "已倒轉至該訊息的開頭", @@ -283,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 new file mode 100644 index 000000000..74858ccac --- /dev/null +++ b/src/lib/ai-edition/store/agentDocumentApply.test.ts @@ -0,0 +1,147 @@ +// @vitest-environment jsdom +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createEmptyDocument } from "../schema"; +import { applyAgentDocumentIfCurrent, runAgentTurn } 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("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 = { + ...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"); + }); +}); + +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 new file mode 100644 index 000000000..167b3c532 --- /dev/null +++ b/src/lib/ai-edition/store/agentDocumentApply.ts @@ -0,0 +1,86 @@ +import type { AxcutDocument } from "../schema"; +import { ensureDocument } from "../schema"; +import { useProjectStore } from "./projectStore"; + +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, 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, + expectedRevision?: number, +): Promise { + const store = useProjectStore.getState(); + if (expectedRevision !== undefined && store.revision !== expectedRevision) { + return "conflict"; + } + + 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); + 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); + }, + }; +}