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
18 changes: 18 additions & 0 deletions src/lib/ai/tools/__tests__/edit-item-tool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,24 @@ describe("createEditItemTool", () => {
expect(result.message).toMatch(/occurrences/i);
});

it("passes through partial-apply diagnostics when worker returns success=false", async () => {
mockWorkspaceWorker.mockResolvedValueOnce({
success: false,
message: "Applied 1 edit(s), failed 1",
partialApplied: true,
appliedEditIndices: [0],
failedEdits: [{ index: 1, reason: "Could not find edits[1]." }],
});
const tool: any = createEditItemTool(ctx);
const result = await tool.execute({ itemName: "My Document", edits: [{ oldText: "a", newText: "b" }] });

expect(result.success).toBe(false);
expect(result.partialApplied).toBe(true);
expect(result.appliedEditIndices).toEqual([0]);
expect(result.failedEdits).toEqual([{ index: 1, reason: "Could not find edits[1]." }]);
expect(result.itemName).toBe("My Document");
});

it("rejects empty edits array without newName", async () => {
const tool: any = createEditItemTool(ctx);
const result = await tool.execute({ itemName: "My Document", edits: [] });
Expand Down
9 changes: 5 additions & 4 deletions src/lib/ai/tools/edit-item-tool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,8 @@ export function createEditItemTool(ctx: WorkspaceToolContext) {
return withSanitizedModelOutput(tool({
description:
"Edit a document, flashcard deck, quiz, or PDF. You must use workspace_read at least once before editing. " +
"TARGETED EDIT: provide edits array with one or more {oldText, newText} pairs. Each oldText must match exactly and uniquely in the original content. Copy from workspace_read as-is. When changing multiple separate sections, use one call with multiple edits[] entries instead of multiple calls. " +
"TARGETED EDIT: provide edits array with one or more {oldText, newText} pairs. Matches must be unique; whitespace/context fallbacks are supported. " +
"MULTI-EDIT: successful edits may still apply even if some fail; in that case response includes applied/failed details with success=false. " +
"FULL REWRITE: single edit with oldText='' and newText=entire new content (quizzes: only the JSON). " +
"RENAME ONLY: empty edits array [] with newName='new name'. " +
"PDFs: RENAME ONLY — empty edits array with newName. " +
Expand All @@ -31,10 +32,10 @@ export function createEditItemTool(ctx: WorkspaceToolContext) {
.string()
.describe("Name of the item to edit (document, flashcard deck, quiz, or PDF; matched by fuzzy search)"),
edits: z.array(z.object({
oldText: z.string().describe("Exact text to find in the original content. Must be unique. Copy from workspace_read as-is."),
oldText: z.string().describe("Text to find in original content. Must be unique."),
newText: z.string().describe("Replacement text for this edit."),
})).describe(
"One or more targeted replacements. Each edit is matched against the original content, not incrementally. Do not include overlapping edits. If two changes touch the same block or nearby lines, merge them into one edit instead. For full rewrite: use a single edit with oldText='' and newText=entire new content."
"One or more targeted replacements. Prefer non-overlapping edits. For full rewrite: use one edit with oldText='' and full newText."
),
newName: z.string().optional().describe("Rename the item to this. If not provided, the existing name is preserved."),
sources: z
Expand Down Expand Up @@ -152,7 +153,7 @@ export function createEditItemTool(ctx: WorkspaceToolContext) {
sources: input.sources,
});

if (workerResult.success) {
if (workerResult.success || (workerResult as { partialApplied?: boolean }).partialApplied) {
return {
...workerResult,
itemName: newName ?? matchedItem.name,
Expand Down
93 changes: 83 additions & 10 deletions src/lib/ai/workers/workspace-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ import {
checkDuplicateName,
} from "@/lib/workspace/mutation-helpers";
import {
applyEdits,
applyEditsBestEffort,
type FailedEdit,
trimDiff,
normalizeLineEndings,
} from "@/lib/utils/edit-replace";
Expand Down Expand Up @@ -223,6 +224,12 @@ async function updateWorkspaceItem(
});
}

function formatFailedEdits(failedEdits: FailedEdit[]): string {
return failedEdits
.map((f) => `edits[${f.index}]: ${f.reason}`)
.join(" | ");
}

async function deleteWorkspaceItem(workspaceId: string, itemId: string) {
await db.transaction(async (tx) => {
await deleteWorkspaceItemById(tx, { workspaceId, itemId });
Expand Down Expand Up @@ -645,6 +652,13 @@ export async function workspaceWorker(
if (existingItem.type === "flashcard") {
const data = existingItem.data as FlashcardData;
const cards: FlashcardItem[] = data.cards ?? [];
let editDiagnostics:
| {
appliedEditIndices: number[];
failedEdits: FailedEdit[];
hadFailures: boolean;
}
| undefined;

const payload = {
cards: cards.map((c) => ({
Expand All @@ -661,8 +675,14 @@ export async function workspaceWorker(
) {
serialized = params.edits![0].newText;
} else {
const { newContent } = applyEdits(serialized, params.edits!);
const {
newContent,
appliedEditIndices,
failedEdits,
hadFailures,
} = applyEditsBestEffort(serialized, params.edits!);
serialized = newContent;
editDiagnostics = { appliedEditIndices, failedEdits, hadFailures };
}
}

Expand Down Expand Up @@ -720,17 +740,33 @@ export async function workspaceWorker(
);

return {
success: true,
success: !editDiagnostics?.hadFailures,
itemId: params.itemId,
message: `Updated flashcard deck (${newCards.length} cards)`,
message: editDiagnostics?.hadFailures
? `Updated flashcard deck (${newCards.length} cards) with partial edits. Applied ${editDiagnostics.appliedEditIndices.length} edit(s), failed ${editDiagnostics.failedEdits.length}: ${formatFailedEdits(editDiagnostics.failedEdits)}`
: `Updated flashcard deck (${newCards.length} cards)`,
cardCount: newCards.length,
...(editDiagnostics
? {
partialApplied: editDiagnostics.hadFailures,
appliedEditIndices: editDiagnostics.appliedEditIndices,
failedEdits: editDiagnostics.failedEdits,
}
: {}),
};
}

if (existingItem.type === "quiz") {
const data = existingItem.data as QuizData;
const questions = data.questions ?? [];
const payload = { questions };
let editDiagnostics:
| {
appliedEditIndices: number[];
failedEdits: FailedEdit[];
hadFailures: boolean;
}
| undefined;
let serialized = JSON.stringify(payload, null, 2);
if (!isRenameOnly) {
if (
Expand All @@ -739,8 +775,14 @@ export async function workspaceWorker(
) {
serialized = params.edits![0].newText;
} else {
const { newContent } = applyEdits(serialized, params.edits!);
const {
newContent,
appliedEditIndices,
failedEdits,
hadFailures,
} = applyEditsBestEffort(serialized, params.edits!);
serialized = newContent;
editDiagnostics = { appliedEditIndices, failedEdits, hadFailures };
}
}

Expand Down Expand Up @@ -838,16 +880,32 @@ export async function workspaceWorker(
);

return {
success: true,
success: !editDiagnostics?.hadFailures,
itemId: params.itemId,
message: `Updated quiz (${validatedQuestions.length} questions)`,
message: editDiagnostics?.hadFailures
? `Updated quiz (${validatedQuestions.length} questions) with partial edits. Applied ${editDiagnostics.appliedEditIndices.length} edit(s), failed ${editDiagnostics.failedEdits.length}: ${formatFailedEdits(editDiagnostics.failedEdits)}`
: `Updated quiz (${validatedQuestions.length} questions)`,
questionCount: validatedQuestions.length,
...(editDiagnostics
? {
partialApplied: editDiagnostics.hadFailures,
appliedEditIndices: editDiagnostics.appliedEditIndices,
failedEdits: editDiagnostics.failedEdits,
}
: {}),
};
}

if (existingItem.type === "document") {
const changes: Partial<Item> = {};
const docData = existingItem.data as DocumentData;
let editDiagnostics:
| {
appliedEditIndices: number[];
failedEdits: FailedEdit[];
hadFailures: boolean;
}
| undefined;
if (rename) {
const dupError = checkDuplicateName(
currentState,
Expand Down Expand Up @@ -876,8 +934,14 @@ export async function workspaceWorker(
contentNew = params.edits![0].newText;
} else {
contentOld = normalizeLineEndings(docData.markdown ?? "");
const { newContent } = applyEdits(contentOld, params.edits!);
const {
newContent,
appliedEditIndices,
failedEdits,
hadFailures,
} = applyEditsBestEffort(contentOld, params.edits!);
contentNew = newContent;
editDiagnostics = { appliedEditIndices, failedEdits, hadFailures };
}
changes.data = {
markdown: contentNew,
Expand Down Expand Up @@ -929,14 +993,23 @@ export async function workspaceWorker(
}

return {
success: true,
success: !editDiagnostics?.hadFailures,
itemId: params.itemId,
message: "Updated document successfully",
message: editDiagnostics?.hadFailures
? `Updated document with partial edits. Applied ${editDiagnostics.appliedEditIndices.length} edit(s), failed ${editDiagnostics.failedEdits.length}: ${formatFailedEdits(editDiagnostics.failedEdits)}`
: "Updated document successfully",
diff: diffOutput,
filediff: {
additions: filediffAdditions,
deletions: filediffDeletions,
},
...(editDiagnostics
? {
partialApplied: editDiagnostics.hadFailures,
appliedEditIndices: editDiagnostics.appliedEditIndices,
failedEdits: editDiagnostics.failedEdits,
}
: {}),
};
}

Expand Down
81 changes: 81 additions & 0 deletions src/lib/utils/__tests__/edit-replace.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from "vitest";
import {
applyEdits,
applyEditsBestEffort,
replace,
normalizeLineEndings,
trimDiff,
Expand Down Expand Up @@ -206,6 +207,86 @@ describe("applyEdits", () => {
const result = applyEdits(content, [{ oldText: "hello", newText: "goodbye" }]);
expect(result.baseContent).toBe("hello\nworld");
});

it("matches trimmed boundary text when unique", () => {
const content = " \n\n\n- Satisfaction\n - Describe specific nonprofit *in detail*:\n ...\n\n \n\n\n";
const result = applyEdits(content, [
{
oldText: "- Satisfaction\n - Describe specific nonprofit *in detail*:\n ...",
newText: "- Satisfaction\n - Describe specific nonprofit *in detail*:\n - Updated details",
},
]);
expect(result.newContent).toContain("Updated details");
});

it("keeps ambiguity errors under lenient matching", () => {
const block = " \n\n\n- Satisfaction\n ...\n\n \n\n\n";
const content = `${block}middle\n${block}`;
expect(() =>
applyEdits(content, [{ oldText: "- Satisfaction\n ...", newText: "- Satisfaction\n - updated" }]),
).toThrow(/occurrences/i);
});

it("does not accept weak context-aware 50% middle-line matches", () => {
const content = ["- Question", " - correct", " - wrongA", "- End"].join("\n");
expect(() =>
applyEdits(content, [
{
oldText: ["- Question", " - correct", " - wrongB", "- End"].join("\n"),
newText: ["- Question", " - updated", " - wrongB", "- End"].join("\n"),
},
]),
).toThrow(/Could not find/);
});
});

describe("applyEditsBestEffort", () => {
it("applies successful edits and reports failed ones", () => {
const content = "alpha\nbeta\ngamma";
const result = applyEditsBestEffort(content, [
{ oldText: "beta", newText: "BETA" },
{ oldText: "missing", newText: "x" },
]);
expect(result.newContent).toBe("alpha\nBETA\ngamma");
expect(result.appliedEditIndices).toEqual([0]);
expect(result.failedEdits).toEqual([
expect.objectContaining({ index: 1 }),
]);
expect(result.hadFailures).toBe(true);
});

it("marks hadFailures false when all edits apply", () => {
const content = "alpha\nbeta\ngamma";
const result = applyEditsBestEffort(content, [
{ oldText: "alpha", newText: "ALPHA" },
{ oldText: "gamma", newText: "GAMMA" },
]);
expect(result.newContent).toBe("ALPHA\nbeta\nGAMMA");
expect(result.appliedEditIndices).toEqual([0, 1]);
expect(result.failedEdits).toEqual([]);
expect(result.hadFailures).toBe(false);
});

it("retries overlap-rejected edits in pass 2", () => {
const content = "abcde";
const result = applyEditsBestEffort(content, [
{ oldText: "abc", newText: "xabc" },
{ oldText: "abcde", newText: "ABCDE" },
]);
expect(result.newContent).toBe("xABCDE");
expect(result.appliedEditIndices).toEqual([0, 1]);
expect(result.failedEdits).toEqual([]);
expect(result.hadFailures).toBe(false);
});

it("throws when all edits fail", () => {
expect(() =>
applyEditsBestEffort("alpha\nbeta", [
{ oldText: "missing one", newText: "x" },
{ oldText: "missing two", newText: "y" },
]),
).toThrow(/Could not find/);
});
});

describe("replace (deprecated wrapper)", () => {
Expand Down
Loading
Loading