Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1721fdc
feat(editFile): write-first with inline diff in chat
wenzhengjiang Mar 16, 2026
6230201
feat(editFile): add Show Diff button and side-by-side diff view in Ap…
wenzhengjiang Mar 16, 2026
f519781
Revert "feat(editFile): add Show Diff button and side-by-side diff vi…
wenzhengjiang Mar 16, 2026
b5190fe
feat(editFile): show ApplyView with simple mode (no per-block buttons)
wenzhengjiang Mar 16, 2026
5dfaf82
feat(tools): update writeFile description to "Create or rewrite files…
wenzhengjiang Mar 17, 2026
d601865
Merge branch 'master' into feat/editfile-write-first-inline-diff
wenzhengjiang Mar 17, 2026
79217c6
fix: address Codex review — GPT prompt, fuzzy match safety, tool ID m…
wenzhengjiang Mar 17, 2026
71726fa
test(editFile): add unit tests for fuzzy match via applyEditToContent
wenzhengjiang Mar 17, 2026
372fc54
fix: address Codex review — NFKC position mapping and legacy tag compat
wenzhengjiang Mar 17, 2026
fbd6b57
test(modelAdapter): update assertions for editFile oldText/newText pr…
wenzhengjiang Mar 17, 2026
e928dde
refactor(editFile): replace sentinel strings with ApplyEditResult dis…
wenzhengjiang Mar 17, 2026
64c66cc
fix(editFile): sanitize file path before vault lookup
wenzhengjiang Mar 17, 2026
d9bd7e4
fix(editFile): guard against degenerate fuzzy match inside NFKC expan…
wenzhengjiang Mar 17, 2026
7dacb39
fix(builtinTools): remove invalid daily:append/daily:prepend from prompt
wenzhengjiang Mar 17, 2026
5555369
fix(editFile): add Stage 3 to tolerate trailing newline mismatch at EOF
wenzhengjiang Mar 17, 2026
c40d362
fix(tests): update editFile integration test to oldText/newText shape
wenzhengjiang Mar 17, 2026
1ba9690
fix(editFile): count overlapping matches to prevent wrong-location edits
wenzhengjiang Mar 17, 2026
29282aa
fix(editFile): round-trip check guards partial NFKC-expansion fuzzy m…
wenzhengjiang Mar 17, 2026
9fd81ff
Merge branch 'master' into feat/editfile-write-first-inline-diff
wenzhengjiang Mar 17, 2026
f49998e
fix(editFile): return structured failed result on edit errors
wenzhengjiang Mar 17, 2026
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
4 changes: 2 additions & 2 deletions src/LLMProviders/chainRunner/CopilotPlusChainRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import { logInfo, logWarn } from "@/logger";
import { checkIsPlusUser } from "@/plusUtils";
import { getSettings } from "@/settings/model";
import { getSystemPromptWithMemory } from "@/system-prompts/systemPromptBuilder";
import { writeToFileTool } from "@/tools/ComposerTools";
import { writeFileTool } from "@/tools/ComposerTools";
import { ToolManager } from "@/tools/toolManager";
import { ToolResultFormatter } from "@/tools/ToolResultFormatter";
import { ToolRegistry } from "@/tools/ToolRegistry";
Expand Down Expand Up @@ -697,7 +697,7 @@ Include your extracted terms as: [SALIENT_TERMS: term1, term2, term3]`;
contextEnvelope: userMessage.contextEnvelope,
});

const actionStreamer = new ActionBlockStreamer(ToolManager, writeToFileTool);
const actionStreamer = new ActionBlockStreamer(ToolManager, writeFileTool);

// Wrap the stream call with warning suppression
const chatStream = await withSuppressedTokenWarnings(() =>
Expand Down
66 changes: 33 additions & 33 deletions src/LLMProviders/chainRunner/utils/ActionBlockStreamer.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,17 +10,17 @@ const MockedToolManager = ToolManager as jest.Mocked<typeof ToolManager>;
const MockedToolResultFormatter = ToolResultFormatter as jest.Mocked<typeof ToolResultFormatter>;

describe("ActionBlockStreamer", () => {
let writeToFileTool: any;
let writeFileTool: any;
let streamer: ActionBlockStreamer;

beforeEach(() => {
writeToFileTool = { name: "writeToFile" };
writeFileTool = { name: "writeFile" };
MockedToolManager.callTool.mockClear();

// Mock ToolResultFormatter to return the raw result without "File change result: " prefix
MockedToolResultFormatter.format = jest.fn((_toolName, result) => result);

streamer = new ActionBlockStreamer(MockedToolManager, writeToFileTool);
streamer = new ActionBlockStreamer(MockedToolManager, writeFileTool);
});

// Helper function to process chunks and collect results
Expand All @@ -35,7 +35,7 @@ describe("ActionBlockStreamer", () => {
return outputContents;
}

it("should pass through chunks without writeToFile tags unchanged", async () => {
it("should pass through chunks without writeFile tags unchanged", async () => {
const chunks = [{ content: "Hello " }, { content: "world, this is " }, { content: "a test." }];
const output = await processChunks(chunks);

Expand All @@ -46,112 +46,112 @@ describe("ActionBlockStreamer", () => {
expect(MockedToolManager.callTool).not.toHaveBeenCalled();
});

it("should handle a complete writeToFile block in a single chunk", async () => {
it("should handle a complete writeFile block in a single chunk", async () => {
MockedToolManager.callTool.mockResolvedValue("File written successfully.");
const chunks = [
{
content:
"Some text before <writeToFile><path>file.txt</path><content>content</content></writeToFile> and some text after.",
"Some text before <writeFile><path>file.txt</path><content>content</content></writeFile> and some text after.",
},
];
const output = await processChunks(chunks);

// Should yield original chunk plus tool result
expect(output).toEqual([
"Some text before <writeToFile><path>file.txt</path><content>content</content></writeToFile> and some text after.",
"Some text before <writeFile><path>file.txt</path><content>content</content></writeFile> and some text after.",
"\nFile written successfully.\n",
]);

expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "file.txt",
content: "content",
});
});

it("should handle a complete xml-wrapped writeToFile block", async () => {
it("should handle a complete xml-wrapped writeFile block", async () => {
MockedToolManager.callTool.mockResolvedValue("XML file written.");
const chunks = [
{
content:
"```xml\n<writeToFile><path>file.xml</path><content>xml content</content></writeToFile>\n```",
"```xml\n<writeFile><path>file.xml</path><content>xml content</content></writeFile>\n```",
},
];
const output = await processChunks(chunks);

expect(output).toEqual([
"```xml\n<writeToFile><path>file.xml</path><content>xml content</content></writeToFile>\n```",
"```xml\n<writeFile><path>file.xml</path><content>xml content</content></writeFile>\n```",
"\nXML file written.\n",
]);

expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "file.xml",
content: "xml content",
});
});

it("should handle a writeToFile block split across multiple chunks", async () => {
it("should handle a writeFile block split across multiple chunks", async () => {
MockedToolManager.callTool.mockResolvedValue("Split file written.");
const chunks = [
{ content: "Here is a file <writeToFile><path>split.txt</path>" },
{ content: "Here is a file <writeFile><path>split.txt</path>" },
{ content: "<content>split content</content>" },
{ content: "</writeToFile> That was it." },
{ content: "</writeFile> That was it." },
];
const output = await processChunks(chunks);

// All chunks should be yielded as-is, plus tool result when complete block is detected
expect(output).toEqual([
"Here is a file <writeToFile><path>split.txt</path>",
"Here is a file <writeFile><path>split.txt</path>",
"<content>split content</content>",
"</writeToFile> That was it.",
"</writeFile> That was it.",
"\nSplit file written.\n",
]);

expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "split.txt",
content: "split content",
});
});

it("should handle multiple writeToFile blocks in the stream", async () => {
it("should handle multiple writeFile blocks in the stream", async () => {
MockedToolManager.callTool
.mockResolvedValueOnce("File 1 written.")
.mockResolvedValueOnce("File 2 written.");
const chunks = [
{
content:
"<writeToFile><path>f1.txt</path><content>c1</content></writeToFile>Some text<writeToFile><path>f2.txt</path><content>c2</content></writeToFile>",
"<writeFile><path>f1.txt</path><content>c1</content></writeFile>Some text<writeFile><path>f2.txt</path><content>c2</content></writeFile>",
},
];
const output = await processChunks(chunks);

// Should yield original chunk plus both tool results
expect(output).toEqual([
"<writeToFile><path>f1.txt</path><content>c1</content></writeToFile>Some text<writeToFile><path>f2.txt</path><content>c2</content></writeToFile>",
"<writeFile><path>f1.txt</path><content>c1</content></writeFile>Some text<writeFile><path>f2.txt</path><content>c2</content></writeFile>",
"\nFile 1 written.\n",
"\nFile 2 written.\n",
]);

expect(MockedToolManager.callTool).toHaveBeenCalledTimes(2);
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "f1.txt",
content: "c1",
});
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "f2.txt",
content: "c2",
});
});

it("should handle unclosed tags without calling tools", async () => {
const chunks = [
{ content: "Starting... <writeToFile><path>unclosed.txt</path>" },
{ content: "Starting... <writeFile><path>unclosed.txt</path>" },
{ content: "<content>this will not be closed" },
];
const output = await processChunks(chunks);

// Should yield all chunks as-is
expect(output).toEqual([
"Starting... <writeToFile><path>unclosed.txt</path>",
"Starting... <writeFile><path>unclosed.txt</path>",
"<content>this will not be closed",
]);

Expand All @@ -163,17 +163,17 @@ describe("ActionBlockStreamer", () => {
MockedToolManager.callTool.mockRejectedValue(new Error("Tool error"));
const chunks = [
{
content: "<writeToFile><path>error.txt</path><content>content</content></writeToFile>",
content: "<writeFile><path>error.txt</path><content>content</content></writeFile>",
},
];
const output = await processChunks(chunks);

expect(output).toEqual([
"<writeToFile><path>error.txt</path><content>content</content></writeToFile>",
"<writeFile><path>error.txt</path><content>content</content></writeFile>",
"\nError: Tool error\n",
]);

expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "error.txt",
content: "content",
});
Expand All @@ -197,12 +197,12 @@ describe("ActionBlockStreamer", () => {
const chunks = [
{
content:
"<writeToFile><path> spaced.txt </path><content> content with spaces </content></writeToFile>",
"<writeFile><path> spaced.txt </path><content> content with spaces </content></writeFile>",
},
];
await processChunks(chunks);

expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "spaced.txt",
content: "content with spaces",
});
Expand All @@ -212,19 +212,19 @@ describe("ActionBlockStreamer", () => {
MockedToolManager.callTool.mockResolvedValue("Malformed handled.");
const chunks = [
{
content: "<writeToFile><path>missing-content.txt</path></writeToFile>",
content: "<writeFile><path>missing-content.txt</path></writeFile>",
},
];
const output = await processChunks(chunks);

// Should yield chunk as-is plus tool result
expect(output).toEqual([
"<writeToFile><path>missing-content.txt</path></writeToFile>",
"<writeFile><path>missing-content.txt</path></writeFile>",
"\nMalformed handled.\n",
]);

// Tool should be called with undefined content
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeToFileTool, {
expect(MockedToolManager.callTool).toHaveBeenCalledWith(writeFileTool, {
path: "missing-content.txt",
content: undefined,
});
Expand Down
14 changes: 7 additions & 7 deletions src/LLMProviders/chainRunner/utils/ActionBlockStreamer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,24 @@ import { ToolManager } from "@/tools/toolManager";
import { ToolResultFormatter } from "@/tools/ToolResultFormatter";

/**
* ActionBlockStreamer processes streaming chunks to detect and handle writeToFile blocks.
* ActionBlockStreamer processes streaming chunks to detect and handle writeFile blocks.
*
* 1. Accumulates chunks in a buffer
* 2. Detects complete writeToFile blocks
* 3. Calls the writeToFile tool when a complete block is found
* 2. Detects complete writeFile blocks
* 3. Calls the writeFile tool when a complete block is found
* 4. Returns chunks as-is otherwise
*/
export class ActionBlockStreamer {
private buffer = "";

constructor(
private toolManager: typeof ToolManager,
private writeToFileTool: any
private writeFileTool: any
) {}

private findCompleteBlock(str: string) {
// Regex for both formats
const regex = /<writeToFile>[\s\S]*?<\/writeToFile>/;
const regex = /<writeFile>[\s\S]*?<\/writeFile>/;

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept legacy writeToFile blocks in stream parser

The stream parser now only matches <writeFile>...</writeFile> blocks, so any model output that still emits legacy <writeToFile> tags will be ignored and never executed. This is a practical compatibility break during upgrades/ongoing chats (and there are still prompt strings in the repo that mention writeToFile), so users can get raw XML in the response instead of an applied file change.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Preserve compatibility with legacy writeToFile blocks

Keep matching legacy <writeToFile> tags in the stream parser, because this commit removes that path entirely (findCompleteBlock now only recognizes <writeFile>) while prompt text in the same snapshot still instructs writeToFile in at least one flow (e.g. src/tools/builtinTools.ts templates guidance). In those cases the model can emit <writeToFile>...</writeToFile>, and the block will never be executed, so users get raw XML output instead of an applied edit.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep legacy write tag support in action block parsing

The block detector now matches only <writeFile>...</writeFile>, so any streamed output that still uses legacy <writeToFile> tags will never trigger the write tool and will be silently ignored. This is a functional regression for legacy/custom prompts, and it is inconsistent with the rest of the patch, which still normalizes legacy tags for display and copy-cleaning.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Accept legacy writeToFile tags in stream parser

ActionBlockStreamer.findCompleteBlock now matches only <writeFile>...</writeFile>, so if a model response still emits legacy <writeToFile> tags (common in in-progress chats from older history or custom prompts not yet updated), the block is never executed and users only see raw XML instead of getting the file-change preview/apply flow. This is a functional regression in edit execution behavior during migration; the parser should accept both tag names for backward compatibility.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve legacy write tag parsing in action streaming

Restricting block detection to writeFile means streamed <writeToFile>...</writeToFile> blocks are now ignored, so the file tool is never invoked and the assistant appears to “emit edits” without applying them; this is a real compatibility gap because legacy tags are still handled elsewhere (e.g., chat rendering/copy cleanup) and can still surface from prior conversations or model output drift. Consider matching both tag names (or normalizing input before parsing) to avoid silently dropping edit actions.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep legacy writeToFile tags executable in stream parser

In the CopilotPlus streaming path, findCompleteBlock now recognizes only <writeFile>...</writeFile>, so any model output that still uses legacy <writeToFile> tags will be passed through without calling the file tool. Since this commit also adds legacy-tag normalization elsewhere, older chat histories can still surface that format, and those sessions lose automatic file-write execution.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Accept legacy writeToFile blocks in streaming parser

The updated parser now matches only <writeFile>...</writeFile>, so any model output that still emits legacy <writeToFile> tags will no longer trigger tool execution and file edits will be silently skipped. This is a realistic post-migration path (e.g., continued chats with old examples or stale prompt text), and the repo still contains legacy guidance in src/tools/builtinTools.ts (obsidianTemplates instructions) that can prompt that older tag name. Please keep backward-compatible matching (both tag names) or provide an explicit alias path.

Useful? React with 👍 / 👎.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Parse legacy writeToFile action blocks during migration

The stream parser now matches only <writeFile>...</writeFile>, so any model output that still emits legacy <writeToFile> blocks is ignored and the file write never runs. This is a functional regression during migration because legacy naming is still present elsewhere in the repo and can still appear from persisted/custom prompts; previously these blocks were executed. Keep backward-compatible matching for both tags until all prompt surfaces and stored histories are fully migrated.

Useful? React with 👍 / 👎.

const match = str.match(regex);

if (!match || match.index === undefined) {
Expand Down Expand Up @@ -71,13 +71,13 @@ export class ActionBlockStreamer {

// Call the tool
try {
const result = await this.toolManager.callTool(this.writeToFileTool, {
const result = await this.toolManager.callTool(this.writeFileTool, {
path: filePath,
content: fileContent,
});

// Format tool result using ToolResultFormatter for consistency with agent mode
const formattedResult = ToolResultFormatter.format("writeToFile", result);
const formattedResult = ToolResultFormatter.format("writeFile", result);
yield { ...chunk, content: `\n${formattedResult}\n` };
} catch (err: any) {
yield { ...chunk, content: `\nError: ${err?.message || err}\n` };
Expand Down
10 changes: 5 additions & 5 deletions src/LLMProviders/chainRunner/utils/AgentReasoningState.ts
Original file line number Diff line number Diff line change
Expand Up @@ -252,8 +252,8 @@ export function summarizeToolResult(
return "Indexed vault";
case "updateMemory":
return "Updated memory";
case "writeToFile":
case "replaceInFile": {
case "writeFile":
case "editFile": {
// Parse the result to check if accepted/rejected
const filePath = args?.path as string | undefined;
const fileName = filePath ? filePath.split("/").pop() || filePath : "file";
Expand All @@ -269,7 +269,7 @@ export function summarizeToolResult(
// TODO(@wenzhengjiang): Handle no-op cases (e.g., "File is too small", "Search text not found")
// Requires ComposerTools to return structured results instead of plain strings.
// See docs/TODO-composer-tool-redesign.md
return toolName === "writeToFile" ? `Wrote to "${fileName}"` : `Edited "${fileName}"`;
return toolName === "writeFile" ? `Wrote to "${fileName}"` : `Edited "${fileName}"`;
}
default:
return "Done";
Expand Down Expand Up @@ -422,15 +422,15 @@ export function summarizeToolCall(
return "Indexing vault";
case "updateMemory":
return "Saving to memory";
case "writeToFile": {
case "writeFile": {
const filePath = args?.path as string | undefined;
if (filePath) {
const fileName = filePath.split("/").pop() || filePath;
return `Writing to "${fileName}"`;
}
return "Writing to file";
}
case "replaceInFile": {
case "editFile": {
const filePath = args?.path as string | undefined;
if (filePath) {
const fileName = filePath.split("/").pop() || filePath;
Expand Down
18 changes: 9 additions & 9 deletions src/LLMProviders/chainRunner/utils/modelAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ describe("ModelAdapter", () => {
const toolMetadata: ToolMetadata[] = [
createToolMetadata("localSearch", "LocalSearch specific instructions"),
createToolMetadata("webSearch", "WebSearch specific instructions"),
createToolMetadata("writeToFile", "WriteToFile specific instructions"),
createToolMetadata("writeFile", "WriteToFile specific instructions"),
];

const enhancedPrompt = adapter.enhanceSystemPrompt(
basePrompt,
toolDescriptions,
["localSearch", "webSearch", "writeToFile"],
["localSearch", "webSearch", "writeFile"],
toolMetadata
);

Expand All @@ -44,7 +44,7 @@ describe("ModelAdapter", () => {
const toolMetadata: ToolMetadata[] = [
createToolMetadata("localSearch", "LocalSearch specific instructions"),
createToolMetadata("webSearch", "WebSearch specific instructions"),
createToolMetadata("writeToFile", "WriteToFile specific instructions"),
createToolMetadata("writeFile", "WriteToFile specific instructions"),
];

// Only pass localSearch as enabled
Expand Down Expand Up @@ -129,15 +129,15 @@ describe("ModelAdapter", () => {
const enhancedPrompt = adapter.enhanceSystemPrompt(
basePrompt,
toolDescriptions,
["replaceInFile", "writeToFile"],
["editFile", "writeFile"],
[]
);

// Check for composer-specific GPT instructions (simplified without XML examples)
expect(enhancedPrompt).toContain("FILE EDITING WITH COMPOSER TOOLS");
expect(enhancedPrompt).toContain("replaceInFile");
expect(enhancedPrompt).toContain("writeToFile");
expect(enhancedPrompt).toContain("SEARCH/REPLACE format");
expect(enhancedPrompt).toContain("editFile");
expect(enhancedPrompt).toContain("writeFile");
expect(enhancedPrompt).toContain("oldText");
});

it("should rebuild enhanceSystemPrompt output from section metadata", () => {
Expand All @@ -164,8 +164,8 @@ describe("ModelAdapter", () => {
const enhanced = adapter.enhanceUserMessage(editMessage, true);

expect(enhanced).toContain("GPT REMINDER");
expect(enhanced).toContain("replaceInFile");
expect(enhanced).toContain("SEARCH/REPLACE blocks");
expect(enhanced).toContain("editFile");
expect(enhanced).toContain("oldText/newText parameters");
});
});
});
Loading
Loading