Skip to content

Commit a1646c4

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 041d0ba commit a1646c4

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
@@ -14457,11 +14457,11 @@ describe("createAgentChatService", () => {
1445714457
);
1445814458
});
1445914459

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

14464-
const streamMessages = buildOpenCodeStreamMessages({
14464+
const streamMessages = await buildOpenCodeStreamMessages({
1446514465
messages: [
1446614466
{
1446714467
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";
@@ -8401,6 +8423,7 @@ export function createAgentChatService(args: {
84018423
managed: ManagedChatSession,
84028424
): UniversalToolSetOptions => ({
84038425
permissionMode: toHarnessPermissionMode(managed.session.permissionMode),
8426+
getDirtyFileTextForPath,
84048427
getTodoItems: () => managed.todoItems,
84058428
onTodoUpdate: (items) => {
84068429
emitChatEvent(managed, {
@@ -9581,7 +9604,7 @@ export function createAgentChatService(args: {
95819604
input.push({ type: "image", url: attachment.url });
95829605
continue;
95839606
}
9584-
const stagedPath = stageAttachmentForCodexInput(attachment);
9607+
const stagedPath = await stageAttachmentForCodexInput(attachment);
95859608
if (attachment.type === "image") {
95869609
input.push({ type: "localImage", path: stagedPath });
95879610
continue;
@@ -10025,10 +10048,11 @@ export function createAgentChatService(args: {
1002510048

1002610049
// Build the message after permission-mode recovery, because rebuilding a
1002710050
// fresh Claude SDK session clears runtime.sdkSessionId.
10028-
const messageToSend = buildClaudeV2Message(basePromptText, resolvedAttachments, {
10051+
const messageToSend = await buildClaudeV2MessageAsync(basePromptText, resolvedAttachments, {
1002910052
baseDir: managed.laneWorktreePath,
1003010053
sessionId: runtime.sdkSessionId,
1003110054
forceUserMessage: true,
10055+
getDirtyFileTextForPath,
1003210056
}) as unknown as SDKUserMessage;
1003310057
messageToSend.uuid = userMessageId;
1003410058
messageToSend.timestamp = new Date().toISOString();
@@ -17384,34 +17408,25 @@ export function createAgentChatService(args: {
1738417408
/** Maximum bytes to inline for a non-image chat attachment. */
1738517409
const MAX_INLINE_BYTES = 512 * 1024; // 512 KB
1738617410

17387-
const buildAgentPromptBlocks = (
17411+
const buildAgentPromptBlocks = async (
1738817412
promptText: string,
1738917413
resolvedAttachments: ResolvedAgentChatFileRef[],
17390-
): Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> => {
17414+
): Promise<Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }>> => {
1739117415
const blocks: Array<
1739217416
{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }
1739317417
> = [{ type: "text", text: promptText }];
1739417418
for (const attachment of resolvedAttachments) {
1739517419
try {
17396-
// Check file size before reading the full contents into memory.
17397-
let fileSize: number;
17398-
try {
17399-
fileSize = fs.statSync(attachment._resolvedPath).size;
17400-
} catch {
17401-
// stat failed -- skip unreadable attachment
17402-
continue;
17403-
}
17420+
const buf = await readResolvedAttachmentBytes(attachment);
1740417421

1740517422
if (attachment.type === "image") {
17406-
const buf = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath);
1740717423
blocks.push({
1740817424
type: "image",
1740917425
data: buf.toString("base64"),
1741017426
mimeType: guessImageMimeForPath(attachment._resolvedPath),
1741117427
});
17412-
} else if (fileSize <= MAX_INLINE_BYTES) {
17428+
} else if (buf.length <= MAX_INLINE_BYTES) {
1741317429
// Non-image file attachment -- include content as text if not binary
17414-
const buf = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath);
1741517430
if (hasNullByte(buf)) {
1741617431
blocks.push({
1741717432
type: "text",
@@ -17428,7 +17443,7 @@ export function createAgentChatService(args: {
1742817443
// File is too large to inline -- push a placeholder with a truncated preview.
1742917444
blocks.push({
1743017445
type: "text",
17431-
text: `[File: ${attachment.path} omitted: size ${fileSize} bytes]`,
17446+
text: `[File: ${attachment.path} omitted: size ${buf.length} bytes]`,
1743217447
});
1743317448
}
1743417449
} catch {
@@ -17938,7 +17953,7 @@ export function createAgentChatService(args: {
1793817953
}
1793917954
}
1794017955

17941-
const promptBlocks = buildAgentPromptBlocks(composed, args.resolvedAttachments);
17956+
const promptBlocks = await buildAgentPromptBlocks(composed, args.resolvedAttachments);
1794217957
const promptText = promptBlocks
1794317958
.filter((block): block is { type: "text"; text: string } => block.type === "text")
1794417959
.map((block) => block.text)
@@ -18280,7 +18295,7 @@ export function createAgentChatService(args: {
1828018295
cloudComposed = `${injected}\n\n${cloudComposed}`;
1828118296
}
1828218297
}
18283-
const promptBlocks = buildAgentPromptBlocks(cloudComposed, args.resolvedAttachments);
18298+
const promptBlocks = await buildAgentPromptBlocks(cloudComposed, args.resolvedAttachments);
1828418299
const promptText = promptBlocks
1828518300
.filter((block): block is { type: "text"; text: string } => block.type === "text")
1828618301
.map((block) => block.text)
@@ -19095,7 +19110,7 @@ export function createAgentChatService(args: {
1909519110
"## User Request",
1909619111
composed,
1909719112
].join("\n");
19098-
const promptBlocks = buildAgentPromptBlocks(sdkInput, args.resolvedAttachments);
19113+
const promptBlocks = await buildAgentPromptBlocks(sdkInput, args.resolvedAttachments);
1909919114
const sdkPromptText = promptBlocks
1910019115
.filter((block): block is { type: "text"; text: string } => block.type === "text")
1910119116
.map((block) => block.text)
@@ -19839,7 +19854,7 @@ export function createAgentChatService(args: {
1983919854
input.push({ type: "image", url: attachment.url });
1984019855
continue;
1984119856
}
19842-
const stagedPath = stageAttachmentForCodexInput(attachment);
19857+
const stagedPath = await stageAttachmentForCodexInput(attachment);
1984319858
if (attachment.type === "image") {
1984419859
input.push({ type: "localImage", path: stagedPath });
1984519860
continue;
@@ -20038,10 +20053,11 @@ export function createAgentChatService(args: {
2003820053
const dispatchUuid = randomUUID();
2003920054
const contextPrompt = buildChatContextAttachmentPrompt(steer.contextAttachments);
2004020055
const inlineSteerText = contextPrompt ? `${contextPrompt}\n\n${steer.text}` : steer.text;
20041-
const sdkMsg = buildClaudeV2Message(inlineSteerText, steer.resolvedAttachments, {
20056+
const sdkMsg = await buildClaudeV2MessageAsync(inlineSteerText, steer.resolvedAttachments, {
2004220057
baseDir: managed.laneWorktreePath,
2004320058
sessionId: runtime.sdkSessionId ?? null,
2004420059
forceUserMessage: true,
20060+
getDirtyFileTextForPath,
2004520061
}) as unknown as SDKUserMessage;
2004620062
sdkMsg.shouldQuery = false;
2004720063
sdkMsg.uuid = dispatchUuid;

0 commit comments

Comments
 (0)