Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 12 additions & 2 deletions src/components/ai-edition/LeftPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -935,13 +935,23 @@ function ChatStripPanel() {
try {
return await applyDocument(options);
} catch (err) {
// Only `ensureDocument` still throws here -- the agent handed back
// something that is not a document. A failed WRITE does not reach this:
// the store reports it itself and `applyDocument` answers "save-failed".
toast.error(t("chat.applyEditsFailed"), {
description: err instanceof Error ? err.message : String(err),
});
return "conflict" as const;
return "malformed" as const;
}
};
if ((await applyEdits()) === "conflict") {
const applyResult = await applyEdits();
if (applyResult === "save-failed") {
// The store has already said WHY the write failed, with the native error.
// This says what it COST, without a description so the two do not repeat
// each other: the assistant's "done, I removed 14 silences" renders either
// way, so a bare save error next to it leaves the two unconnected.
toast.error(t("chat.applyEditsFailed"));
} else if (applyResult === "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
Expand Down
20 changes: 18 additions & 2 deletions src/lib/ai-edition/store/agentDocumentApply.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,20 +51,36 @@ describe("applyAgentDocumentIfCurrent", () => {
expect(useProjectStore.getState().document?.project.title).toBe("Manual edit");
});

it("puts the document back when the save fails", async () => {
it("puts the document back when the save fails, and does not call it applied", 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.
//
// `saveDocument` reports its own failures and resolves false rather than
// throwing. This was written against a `saveDocument` that threw, and the two
// changes landed minutes apart: a dead `catch` type-checks, so the rollback
// stopped firing and "applied" came back for a write that never happened.
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");
await expect(applyAgentDocumentIfCurrent(agentResult, 4)).resolves.toBe("save-failed");

expect(useProjectStore.getState().document?.project.title).toBe("Before");
expect(useProjectStore.getState().dirty).toBe(false);
});

it("still rejects when the agent hands back something that is not a document", async () => {
// The one throw left on this path, and the reason the caller keeps a try/catch.
const before = createEmptyDocument({ projectId: "project_1", title: "Before" });
useProjectStore.setState({ projectId: "project_1", document: before, revision: 4 });

await expect(applyAgentDocumentIfCurrent({ not: "a document" }, 4)).rejects.toThrow();

expect(useProjectStore.getState().document?.project.title).toBe("Before");
expect(saveMock).not.toHaveBeenCalled();
});

it("allows an explicit rewind to replace the current revision", async () => {
const current = createEmptyDocument({ projectId: "project_1", title: "Current" });
const checkpoint = {
Expand Down
37 changes: 20 additions & 17 deletions src/lib/ai-edition/store/agentDocumentApply.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import type { AxcutDocument } from "../schema";
import { ensureDocument } from "../schema";
import { useProjectStore } from "./projectStore";

export type AgentDocumentApplyResult = "applied" | "conflict" | "no-live-document";
export type AgentDocumentApplyResult = "applied" | "conflict" | "save-failed" | "no-live-document";

/**
* Apply a full document returned by the agent only if the live editor is still
Expand All @@ -29,22 +29,25 @@ export async function applyAgentDocumentIfCurrent(
// 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";
if (await store.saveDocument(parsed)) return "applied";

// The edits are on screen by now. Leaving them there while the caller says they
// were not applied 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.
//
// A returned `false` and not a `catch`: `saveDocument` reports its own failures and
// never rejects. This was a `try`/`catch` when it was written, against a
// `saveDocument` that threw -- the two landed within minutes of each other, and a
// dead `catch` type-checks, so the rollback stopped firing and this returned
// "applied" for a write that never happened.
if (previous) useProjectStore.setState({ document: previous, dirty: previousDirty });
return "save-failed";
}

export interface AgentTurn<T> {
Expand Down
Loading