Skip to content

Commit 6eeec1a

Browse files
cursoragentarul28
andcommitted
Fix agent reads to use unsaved Files editor buffers
getDirtyFileTextForPath was wired from the renderer but never consulted on agent read paths. readFile tools and chat attachments always read disk, so agents saw stale content when the user had unsaved edits in the Files tab. Add readAgentAccessibleFileBytes and route universal readFile, Claude/Codex attachments, and orchestration tools through it. Co-authored-by: Arul Sharma <arul28@users.noreply.github.com>
1 parent 4863a52 commit 6eeec1a

7 files changed

Lines changed: 213 additions & 42 deletions

File tree

apps/desktop/src/main/services/ai/tools/readFileRange.test.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,20 @@ describe("createReadFileRangeTool", () => {
3535
// Happy paths
3636
// --------------------------------------------------------------------------
3737

38+
it("prefers unsaved editor buffer text over on-disk content", async () => {
39+
const cwd = makeTmpDir("read-dirty-");
40+
writeFixtureFile(cwd, "dirty.ts", "saved on disk");
41+
42+
const tool = createReadFileRangeTool(cwd, {
43+
getDirtyFileTextForPath: () => "unsaved in editor",
44+
});
45+
const result = await tool.execute({ file_path: "dirty.ts" });
46+
47+
expect(result.error).toBeUndefined();
48+
expect(result.content).toContain("unsaved in editor");
49+
expect(result.content).not.toContain("saved on disk");
50+
});
51+
3852
it("reads an entire file when no offset or limit is given", async () => {
3953
const cwd = makeTmpDir("read-full-");
4054
writeFixtureFile(cwd, "sample.ts", FIVE_LINES);

apps/desktop/src/main/services/ai/tools/readFileRange.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,23 @@ import { executableTool as tool } from "./executableTool";
22
import { z } from "zod";
33
import fs from "node:fs";
44
import path from "node:path";
5-
import { getErrorMessage, readFileWithinRootSecure, resolvePathWithinRoot } from "../../shared/utils";
5+
import {
6+
getErrorMessage,
7+
readAgentAccessibleFileBytes,
8+
readFileWithinRootSecure,
9+
resolvePathWithinRoot,
10+
type DirtyFileTextLookup,
11+
} from "../../shared/utils";
612

713
function toDisplayPath(root: string, filePath: string): string {
814
return path.relative(root, filePath).replace(/\\/g, "/");
915
}
1016

11-
export function createReadFileRangeTool(cwd: string) {
17+
export type ReadFileRangeToolOptions = {
18+
getDirtyFileTextForPath?: DirtyFileTextLookup;
19+
};
20+
21+
export function createReadFileRangeTool(cwd: string, options: ReadFileRangeToolOptions = {}) {
1222
return tool({
1323
description:
1424
"Read a file's contents with line numbers. Accepts an absolute path or a path relative to the active repo root.",
@@ -40,7 +50,13 @@ export function createReadFileRangeTool(cwd: string) {
4050
return { content: "", totalLines: 0, error: `Error reading file: ${message}` };
4151
}
4252

43-
const raw = readFileWithinRootSecure(root, file_path).toString("utf-8");
53+
const raw = (
54+
await readAgentAccessibleFileBytes({
55+
rootPath: root,
56+
resolvedPath: resolvedPath,
57+
getDirtyFileTextForPath: options.getDirtyFileTextForPath,
58+
})
59+
).toString("utf-8");
4460
const allLines = raw.split("\n");
4561
const totalLines = allLines.length;
4662

apps/desktop/src/main/services/ai/tools/universalTools.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,13 @@ import { webFetchTool } from "./webFetch";
1212
import { webSearchTool } from "./webSearch";
1313
import type { AgentChatApprovalDecision, AgentChatEvent, PendingInputKind, WorkerSandboxConfig } from "../../../../shared/types";
1414
import { DEFAULT_WORKER_SANDBOX_CONFIG } from "./workerSandboxDefaults";
15-
import { getErrorMessage, isEnoentError, isWithinDir, resolvePathWithinRoot } from "../../shared/utils";
15+
import {
16+
getErrorMessage,
17+
isEnoentError,
18+
isWithinDir,
19+
resolvePathWithinRoot,
20+
type DirtyFileTextLookup,
21+
} from "../../shared/utils";
1622
import { terminateProcessTree } from "../../shared/processExecution";
1723

1824
const execFileAsync = promisify(execFile);
@@ -75,6 +81,8 @@ export interface UniversalToolSetOptions {
7581
* controller and abort it when an external policy event cancels the session.
7682
*/
7783
registerActiveBash?: (controller: AbortController) => (() => void) | void;
84+
/** Prefer unsaved Files-tab editor buffers over on-disk content for readFile. */
85+
getDirtyFileTextForPath?: DirtyFileTextLookup;
7886
}
7987

8088
// ── Permission helpers ──────────────────────────────────────────────
@@ -2708,7 +2716,9 @@ export function createUniversalToolSet(
27082716
// eslint-disable-next-line @typescript-eslint/no-explicit-any
27092717
const tools: Record<string, Tool<any, any>> = {
27102718
// Read-only tools (auto-allowed in all modes)
2711-
readFile: createReadFileRangeTool(cwd),
2719+
readFile: createReadFileRangeTool(cwd, {
2720+
getDirtyFileTextForPath: opts.getDirtyFileTextForPath,
2721+
}),
27122722
grep: createGrepSearchTool(cwd),
27132723
glob: createGlobSearchTool(cwd),
27142724
listDir: createListDirTool(cwd),

apps/desktop/src/main/services/chat/agentChatService.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14660,11 +14660,11 @@ describe("createAgentChatService", () => {
1466014660
);
1466114661
});
1466214662

14663-
it("preserves original attachments across local auto-continuation retries", () => {
14663+
it("preserves original attachments across local auto-continuation retries", async () => {
1466414664
const resolvedPath = path.join(tmpRoot, "note.txt");
1466514665
fs.writeFileSync(resolvedPath, "remember this", "utf8");
1466614666

14667-
const streamMessages = buildOpenCodeStreamMessages({
14667+
const streamMessages = await buildOpenCodeStreamMessages({
1466814668
messages: [
1466914669
{
1467014670
role: "user",

apps/desktop/src/main/services/chat/agentChatService.ts

Lines changed: 48 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import type {
3434
WarmQuery,
3535
} from "@anthropic-ai/claude-agent-sdk";
3636
import { z, type ZodType } from "zod";
37-
import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message";
37+
import { buildClaudeV2MessageAsync, inferAttachmentMediaType } from "./buildClaudeV2Message";
3838
import { ClaudeInputPump } from "./claudeInputPump";
3939
import {
4040
isCorruptThinkingTranscriptError,
@@ -87,6 +87,7 @@ import {
8787
hasNullByte,
8888
isEnoentError,
8989
nowIso,
90+
readAgentAccessibleFileBytes,
9091
readFileWithinRootSecure,
9192
resolvePathWithinRoot,
9293
stableStringify,
@@ -3105,15 +3106,16 @@ function normalizeClaudeTodoItems(
31053106
return items.length ? items : null;
31063107
}
31073108

3108-
function buildStreamingUserContent(
3109+
async function buildStreamingUserContent(
31093110
args: {
31103111
baseText: string;
31113112
attachments: ResolvedAgentChatFileRef[];
31123113
runtimeKind: "claude" | "opencode";
31133114
modelDescriptor?: ModelDescriptor;
31143115
logger?: Logger;
3116+
readAttachmentBytes: (attachment: ResolvedAgentChatFileRef) => Promise<Buffer>;
31153117
},
3116-
): UserContent {
3118+
): Promise<UserContent> {
31173119
if (!args.attachments.length) {
31183120
return args.baseText;
31193121
}
@@ -3131,7 +3133,7 @@ function buildStreamingUserContent(
31313133
});
31323134
continue;
31333135
}
3134-
const data = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath);
3136+
const data = await args.readAttachmentBytes(attachment);
31353137
const mediaType = inferAttachmentMediaType(attachment);
31363138

31373139
if (attachment.type === "image") {
@@ -3185,14 +3187,24 @@ function buildStreamingUserContent(
31853187
return parts;
31863188
}
31873189

3188-
export function buildOpenCodeStreamMessages(args: {
3190+
export async function buildOpenCodeStreamMessages(args: {
31893191
messages: Array<{ role: string; content: string }>;
31903192
persistedTurnUserMessageIndex: number;
31913193
resolvedAttachments: ResolvedAgentChatFileRef[];
31923194
modelDescriptor: ModelDescriptor;
31933195
logger?: Logger;
3194-
}): ModelMessage[] {
3195-
return args.messages.map((message, index): ModelMessage => {
3196+
getDirtyFileTextForPath?: (absPath: string) => string | undefined | Promise<string | undefined>;
3197+
}): Promise<ModelMessage[]> {
3198+
const readAttachmentBytes = args.getDirtyFileTextForPath
3199+
? async (attachment: ResolvedAgentChatFileRef) => readAgentAccessibleFileBytes({
3200+
rootPath: attachment._rootPath,
3201+
resolvedPath: attachment._resolvedPath,
3202+
getDirtyFileTextForPath: args.getDirtyFileTextForPath,
3203+
})
3204+
: async (attachment: ResolvedAgentChatFileRef) =>
3205+
readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath);
3206+
3207+
const mapped = await Promise.all(args.messages.map(async (message, index): Promise<ModelMessage> => {
31963208
const isPersistedTurnUserMessage = index === args.persistedTurnUserMessageIndex && message.role === "user";
31973209
if (!isPersistedTurnUserMessage) {
31983210
return {
@@ -3203,15 +3215,17 @@ export function buildOpenCodeStreamMessages(args: {
32033215

32043216
return {
32053217
role: "user",
3206-
content: buildStreamingUserContent({
3218+
content: await buildStreamingUserContent({
32073219
baseText: message.content,
32083220
attachments: args.resolvedAttachments,
32093221
runtimeKind: "opencode",
32103222
modelDescriptor: args.modelDescriptor,
32113223
logger: args.logger,
3224+
readAttachmentBytes,
32123225
}),
32133226
};
3214-
});
3227+
}));
3228+
return mapped;
32153229
}
32163230

32173231
function buildExecutionModeDirective(
@@ -4717,6 +4731,14 @@ export function createAgentChatService(args: {
47174731
if (!getDirtyFileTextForPath) {
47184732
throw new Error("createAgentChatService: getDirtyFileTextForPath is required");
47194733
}
4734+
4735+
const readResolvedAttachmentBytes = async (
4736+
attachment: ResolvedAgentChatFileRef,
4737+
): Promise<Buffer> => readAgentAccessibleFileBytes({
4738+
rootPath: attachment._rootPath,
4739+
resolvedPath: attachment._resolvedPath,
4740+
getDirtyFileTextForPath,
4741+
});
47204742
if (!issueInventoryService) {
47214743
throw new Error("Issue inventory service is required to initialize agent chat.");
47224744
}
@@ -4777,8 +4799,8 @@ export function createAgentChatService(args: {
47774799
});
47784800
};
47794801

4780-
const stageAttachmentForCodexInput = (attachment: ResolvedAgentChatFileRef): string => {
4781-
const content = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath);
4802+
const stageAttachmentForCodexInput = async (attachment: ResolvedAgentChatFileRef): Promise<string> => {
4803+
const content = await readResolvedAttachmentBytes(attachment);
47824804
const stagedDir = path.join(layout.tmpDir, "agent-chat-attachments");
47834805
fs.mkdirSync(stagedDir, { recursive: true });
47844806
const baseName = path.basename(attachment.path) || path.basename(attachment._resolvedPath) || "attachment";
@@ -8421,6 +8443,7 @@ export function createAgentChatService(args: {
84218443
managed: ManagedChatSession,
84228444
): UniversalToolSetOptions => ({
84238445
permissionMode: toHarnessPermissionMode(managed.session.permissionMode),
8446+
getDirtyFileTextForPath,
84248447
getTodoItems: () => managed.todoItems,
84258448
onTodoUpdate: (items) => {
84268449
emitChatEvent(managed, {
@@ -9601,7 +9624,7 @@ export function createAgentChatService(args: {
96019624
input.push({ type: "image", url: attachment.url });
96029625
continue;
96039626
}
9604-
const stagedPath = stageAttachmentForCodexInput(attachment);
9627+
const stagedPath = await stageAttachmentForCodexInput(attachment);
96059628
if (attachment.type === "image") {
96069629
input.push({ type: "localImage", path: stagedPath });
96079630
continue;
@@ -10045,10 +10068,11 @@ export function createAgentChatService(args: {
1004510068

1004610069
// Build the message after permission-mode recovery, because rebuilding a
1004710070
// fresh Claude SDK session clears runtime.sdkSessionId.
10048-
const messageToSend = buildClaudeV2Message(basePromptText, resolvedAttachments, {
10071+
const messageToSend = await buildClaudeV2MessageAsync(basePromptText, resolvedAttachments, {
1004910072
baseDir: managed.laneWorktreePath,
1005010073
sessionId: runtime.sdkSessionId,
1005110074
forceUserMessage: true,
10075+
getDirtyFileTextForPath,
1005210076
}) as unknown as SDKUserMessage;
1005310077
messageToSend.uuid = userMessageId;
1005410078
messageToSend.timestamp = new Date().toISOString();
@@ -17404,34 +17428,25 @@ export function createAgentChatService(args: {
1740417428
/** Maximum bytes to inline for a non-image chat attachment. */
1740517429
const MAX_INLINE_BYTES = 512 * 1024; // 512 KB
1740617430

17407-
const buildAgentPromptBlocks = (
17431+
const buildAgentPromptBlocks = async (
1740817432
promptText: string,
1740917433
resolvedAttachments: ResolvedAgentChatFileRef[],
17410-
): Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> => {
17434+
): Promise<Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>> => {
1741117435
const blocks: Array<
1741217436
{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
1741317437
> = [{ type: "text", text: promptText }];
1741417438
for (const attachment of resolvedAttachments) {
1741517439
try {
17416-
// Check file size before reading the full contents into memory.
17417-
let fileSize: number;
17418-
try {
17419-
fileSize = fs.statSync(attachment._resolvedPath).size;
17420-
} catch {
17421-
// stat failed -- skip unreadable attachment
17422-
continue;
17423-
}
17440+
const buf = await readResolvedAttachmentBytes(attachment);
1742417441

1742517442
if (attachment.type === "image") {
17426-
const buf = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath);
1742717443
blocks.push({
1742817444
type: "image",
1742917445
data: buf.toString("base64"),
1743017446
mimeType: guessImageMimeForPath(attachment._resolvedPath),
1743117447
});
17432-
} else if (fileSize <= MAX_INLINE_BYTES) {
17448+
} else if (buf.length <= MAX_INLINE_BYTES) {
1743317449
// Non-image file attachment -- include content as text if not binary
17434-
const buf = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath);
1743517450
if (hasNullByte(buf)) {
1743617451
blocks.push({
1743717452
type: "text",
@@ -17448,7 +17463,7 @@ export function createAgentChatService(args: {
1744817463
// File is too large to inline -- push a placeholder with a truncated preview.
1744917464
blocks.push({
1745017465
type: "text",
17451-
text: `[File: ${attachment.path} omitted: size ${fileSize} bytes]`,
17466+
text: `[File: ${attachment.path} omitted: size ${buf.length} bytes]`,
1745217467
});
1745317468
}
1745417469
} catch {
@@ -17958,7 +17973,7 @@ export function createAgentChatService(args: {
1795817973
}
1795917974
}
1796017975

17961-
const promptBlocks = buildAgentPromptBlocks(composed, args.resolvedAttachments);
17976+
const promptBlocks = await buildAgentPromptBlocks(composed, args.resolvedAttachments);
1796217977
const promptText = promptBlocks
1796317978
.filter((block): block is { type: "text"; text: string } => block.type === "text")
1796417979
.map((block) => block.text)
@@ -18300,7 +18315,7 @@ export function createAgentChatService(args: {
1830018315
cloudComposed = `${injected}\n\n${cloudComposed}`;
1830118316
}
1830218317
}
18303-
const promptBlocks = buildAgentPromptBlocks(cloudComposed, args.resolvedAttachments);
18318+
const promptBlocks = await buildAgentPromptBlocks(cloudComposed, args.resolvedAttachments);
1830418319
const promptText = promptBlocks
1830518320
.filter((block): block is { type: "text"; text: string } => block.type === "text")
1830618321
.map((block) => block.text)
@@ -19115,7 +19130,7 @@ export function createAgentChatService(args: {
1911519130
"## User Request",
1911619131
composed,
1911719132
].join("\n");
19118-
const promptBlocks = buildAgentPromptBlocks(sdkInput, args.resolvedAttachments);
19133+
const promptBlocks = await buildAgentPromptBlocks(sdkInput, args.resolvedAttachments);
1911919134
const sdkPromptText = promptBlocks
1912019135
.filter((block): block is { type: "text"; text: string } => block.type === "text")
1912119136
.map((block) => block.text)
@@ -19859,7 +19874,7 @@ export function createAgentChatService(args: {
1985919874
input.push({ type: "image", url: attachment.url });
1986019875
continue;
1986119876
}
19862-
const stagedPath = stageAttachmentForCodexInput(attachment);
19877+
const stagedPath = await stageAttachmentForCodexInput(attachment);
1986319878
if (attachment.type === "image") {
1986419879
input.push({ type: "localImage", path: stagedPath });
1986519880
continue;
@@ -20058,10 +20073,11 @@ export function createAgentChatService(args: {
2005820073
const dispatchUuid = randomUUID();
2005920074
const contextPrompt = buildChatContextAttachmentPrompt(steer.contextAttachments);
2006020075
const inlineSteerText = contextPrompt ? `${contextPrompt}\n\n${steer.text}` : steer.text;
20061-
const sdkMsg = buildClaudeV2Message(inlineSteerText, steer.resolvedAttachments, {
20076+
const sdkMsg = await buildClaudeV2MessageAsync(inlineSteerText, steer.resolvedAttachments, {
2006220077
baseDir: managed.laneWorktreePath,
2006320078
sessionId: runtime.sdkSessionId ?? null,
2006420079
forceUserMessage: true,
20080+
getDirtyFileTextForPath,
2006520081
}) as unknown as SDKUserMessage;
2006620082
sdkMsg.shouldQuery = false;
2006720083
sdkMsg.uuid = dispatchUuid;

0 commit comments

Comments
 (0)