From 6eeec1ae4c8cde9cc1c70a7bb9849b6edbaadd9a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 29 May 2026 00:26:09 +0000 Subject: [PATCH 1/5] 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 --- .../services/ai/tools/readFileRange.test.ts | 14 ++++ .../main/services/ai/tools/readFileRange.ts | 22 ++++- .../main/services/ai/tools/universalTools.ts | 14 +++- .../services/chat/agentChatService.test.ts | 4 +- .../main/services/chat/agentChatService.ts | 80 +++++++++++------- .../services/chat/buildClaudeV2Message.ts | 84 ++++++++++++++++++- .../desktop/src/main/services/shared/utils.ts | 37 ++++++++ 7 files changed, 213 insertions(+), 42 deletions(-) diff --git a/apps/desktop/src/main/services/ai/tools/readFileRange.test.ts b/apps/desktop/src/main/services/ai/tools/readFileRange.test.ts index 961253517..569dcf978 100644 --- a/apps/desktop/src/main/services/ai/tools/readFileRange.test.ts +++ b/apps/desktop/src/main/services/ai/tools/readFileRange.test.ts @@ -35,6 +35,20 @@ describe("createReadFileRangeTool", () => { // Happy paths // -------------------------------------------------------------------------- + it("prefers unsaved editor buffer text over on-disk content", async () => { + const cwd = makeTmpDir("read-dirty-"); + writeFixtureFile(cwd, "dirty.ts", "saved on disk"); + + const tool = createReadFileRangeTool(cwd, { + getDirtyFileTextForPath: () => "unsaved in editor", + }); + const result = await tool.execute({ file_path: "dirty.ts" }); + + expect(result.error).toBeUndefined(); + expect(result.content).toContain("unsaved in editor"); + expect(result.content).not.toContain("saved on disk"); + }); + it("reads an entire file when no offset or limit is given", async () => { const cwd = makeTmpDir("read-full-"); writeFixtureFile(cwd, "sample.ts", FIVE_LINES); diff --git a/apps/desktop/src/main/services/ai/tools/readFileRange.ts b/apps/desktop/src/main/services/ai/tools/readFileRange.ts index 8f3f47cfc..4e67463bc 100644 --- a/apps/desktop/src/main/services/ai/tools/readFileRange.ts +++ b/apps/desktop/src/main/services/ai/tools/readFileRange.ts @@ -2,13 +2,23 @@ import { executableTool as tool } from "./executableTool"; import { z } from "zod"; import fs from "node:fs"; import path from "node:path"; -import { getErrorMessage, readFileWithinRootSecure, resolvePathWithinRoot } from "../../shared/utils"; +import { + getErrorMessage, + readAgentAccessibleFileBytes, + readFileWithinRootSecure, + resolvePathWithinRoot, + type DirtyFileTextLookup, +} from "../../shared/utils"; function toDisplayPath(root: string, filePath: string): string { return path.relative(root, filePath).replace(/\\/g, "/"); } -export function createReadFileRangeTool(cwd: string) { +export type ReadFileRangeToolOptions = { + getDirtyFileTextForPath?: DirtyFileTextLookup; +}; + +export function createReadFileRangeTool(cwd: string, options: ReadFileRangeToolOptions = {}) { return tool({ description: "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) { return { content: "", totalLines: 0, error: `Error reading file: ${message}` }; } - const raw = readFileWithinRootSecure(root, file_path).toString("utf-8"); + const raw = ( + await readAgentAccessibleFileBytes({ + rootPath: root, + resolvedPath: resolvedPath, + getDirtyFileTextForPath: options.getDirtyFileTextForPath, + }) + ).toString("utf-8"); const allLines = raw.split("\n"); const totalLines = allLines.length; diff --git a/apps/desktop/src/main/services/ai/tools/universalTools.ts b/apps/desktop/src/main/services/ai/tools/universalTools.ts index 38bd91189..f28214fd6 100644 --- a/apps/desktop/src/main/services/ai/tools/universalTools.ts +++ b/apps/desktop/src/main/services/ai/tools/universalTools.ts @@ -12,7 +12,13 @@ import { webFetchTool } from "./webFetch"; import { webSearchTool } from "./webSearch"; import type { AgentChatApprovalDecision, AgentChatEvent, PendingInputKind, WorkerSandboxConfig } from "../../../../shared/types"; import { DEFAULT_WORKER_SANDBOX_CONFIG } from "./workerSandboxDefaults"; -import { getErrorMessage, isEnoentError, isWithinDir, resolvePathWithinRoot } from "../../shared/utils"; +import { + getErrorMessage, + isEnoentError, + isWithinDir, + resolvePathWithinRoot, + type DirtyFileTextLookup, +} from "../../shared/utils"; import { terminateProcessTree } from "../../shared/processExecution"; const execFileAsync = promisify(execFile); @@ -75,6 +81,8 @@ export interface UniversalToolSetOptions { * controller and abort it when an external policy event cancels the session. */ registerActiveBash?: (controller: AbortController) => (() => void) | void; + /** Prefer unsaved Files-tab editor buffers over on-disk content for readFile. */ + getDirtyFileTextForPath?: DirtyFileTextLookup; } // ── Permission helpers ────────────────────────────────────────────── @@ -2708,7 +2716,9 @@ export function createUniversalToolSet( // eslint-disable-next-line @typescript-eslint/no-explicit-any const tools: Record> = { // Read-only tools (auto-allowed in all modes) - readFile: createReadFileRangeTool(cwd), + readFile: createReadFileRangeTool(cwd, { + getDirtyFileTextForPath: opts.getDirtyFileTextForPath, + }), grep: createGrepSearchTool(cwd), glob: createGlobSearchTool(cwd), listDir: createListDirTool(cwd), diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index e8a4981d0..2549046ab 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -14660,11 +14660,11 @@ describe("createAgentChatService", () => { ); }); - it("preserves original attachments across local auto-continuation retries", () => { + it("preserves original attachments across local auto-continuation retries", async () => { const resolvedPath = path.join(tmpRoot, "note.txt"); fs.writeFileSync(resolvedPath, "remember this", "utf8"); - const streamMessages = buildOpenCodeStreamMessages({ + const streamMessages = await buildOpenCodeStreamMessages({ messages: [ { role: "user", diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 67de1141c..26ecf404e 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -34,7 +34,7 @@ import type { WarmQuery, } from "@anthropic-ai/claude-agent-sdk"; import { z, type ZodType } from "zod"; -import { buildClaudeV2Message, inferAttachmentMediaType } from "./buildClaudeV2Message"; +import { buildClaudeV2MessageAsync, inferAttachmentMediaType } from "./buildClaudeV2Message"; import { ClaudeInputPump } from "./claudeInputPump"; import { isCorruptThinkingTranscriptError, @@ -87,6 +87,7 @@ import { hasNullByte, isEnoentError, nowIso, + readAgentAccessibleFileBytes, readFileWithinRootSecure, resolvePathWithinRoot, stableStringify, @@ -3105,15 +3106,16 @@ function normalizeClaudeTodoItems( return items.length ? items : null; } -function buildStreamingUserContent( +async function buildStreamingUserContent( args: { baseText: string; attachments: ResolvedAgentChatFileRef[]; runtimeKind: "claude" | "opencode"; modelDescriptor?: ModelDescriptor; logger?: Logger; + readAttachmentBytes: (attachment: ResolvedAgentChatFileRef) => Promise; }, -): UserContent { +): Promise { if (!args.attachments.length) { return args.baseText; } @@ -3131,7 +3133,7 @@ function buildStreamingUserContent( }); continue; } - const data = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath); + const data = await args.readAttachmentBytes(attachment); const mediaType = inferAttachmentMediaType(attachment); if (attachment.type === "image") { @@ -3185,14 +3187,24 @@ function buildStreamingUserContent( return parts; } -export function buildOpenCodeStreamMessages(args: { +export async function buildOpenCodeStreamMessages(args: { messages: Array<{ role: string; content: string }>; persistedTurnUserMessageIndex: number; resolvedAttachments: ResolvedAgentChatFileRef[]; modelDescriptor: ModelDescriptor; logger?: Logger; -}): ModelMessage[] { - return args.messages.map((message, index): ModelMessage => { + getDirtyFileTextForPath?: (absPath: string) => string | undefined | Promise; +}): Promise { + const readAttachmentBytes = args.getDirtyFileTextForPath + ? async (attachment: ResolvedAgentChatFileRef) => readAgentAccessibleFileBytes({ + rootPath: attachment._rootPath, + resolvedPath: attachment._resolvedPath, + getDirtyFileTextForPath: args.getDirtyFileTextForPath, + }) + : async (attachment: ResolvedAgentChatFileRef) => + readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath); + + const mapped = await Promise.all(args.messages.map(async (message, index): Promise => { const isPersistedTurnUserMessage = index === args.persistedTurnUserMessageIndex && message.role === "user"; if (!isPersistedTurnUserMessage) { return { @@ -3203,15 +3215,17 @@ export function buildOpenCodeStreamMessages(args: { return { role: "user", - content: buildStreamingUserContent({ + content: await buildStreamingUserContent({ baseText: message.content, attachments: args.resolvedAttachments, runtimeKind: "opencode", modelDescriptor: args.modelDescriptor, logger: args.logger, + readAttachmentBytes, }), }; - }); + })); + return mapped; } function buildExecutionModeDirective( @@ -4717,6 +4731,14 @@ export function createAgentChatService(args: { if (!getDirtyFileTextForPath) { throw new Error("createAgentChatService: getDirtyFileTextForPath is required"); } + + const readResolvedAttachmentBytes = async ( + attachment: ResolvedAgentChatFileRef, + ): Promise => readAgentAccessibleFileBytes({ + rootPath: attachment._rootPath, + resolvedPath: attachment._resolvedPath, + getDirtyFileTextForPath, + }); if (!issueInventoryService) { throw new Error("Issue inventory service is required to initialize agent chat."); } @@ -4777,8 +4799,8 @@ export function createAgentChatService(args: { }); }; - const stageAttachmentForCodexInput = (attachment: ResolvedAgentChatFileRef): string => { - const content = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath); + const stageAttachmentForCodexInput = async (attachment: ResolvedAgentChatFileRef): Promise => { + const content = await readResolvedAttachmentBytes(attachment); const stagedDir = path.join(layout.tmpDir, "agent-chat-attachments"); fs.mkdirSync(stagedDir, { recursive: true }); const baseName = path.basename(attachment.path) || path.basename(attachment._resolvedPath) || "attachment"; @@ -8421,6 +8443,7 @@ export function createAgentChatService(args: { managed: ManagedChatSession, ): UniversalToolSetOptions => ({ permissionMode: toHarnessPermissionMode(managed.session.permissionMode), + getDirtyFileTextForPath, getTodoItems: () => managed.todoItems, onTodoUpdate: (items) => { emitChatEvent(managed, { @@ -9601,7 +9624,7 @@ export function createAgentChatService(args: { input.push({ type: "image", url: attachment.url }); continue; } - const stagedPath = stageAttachmentForCodexInput(attachment); + const stagedPath = await stageAttachmentForCodexInput(attachment); if (attachment.type === "image") { input.push({ type: "localImage", path: stagedPath }); continue; @@ -10045,10 +10068,11 @@ export function createAgentChatService(args: { // Build the message after permission-mode recovery, because rebuilding a // fresh Claude SDK session clears runtime.sdkSessionId. - const messageToSend = buildClaudeV2Message(basePromptText, resolvedAttachments, { + const messageToSend = await buildClaudeV2MessageAsync(basePromptText, resolvedAttachments, { baseDir: managed.laneWorktreePath, sessionId: runtime.sdkSessionId, forceUserMessage: true, + getDirtyFileTextForPath, }) as unknown as SDKUserMessage; messageToSend.uuid = userMessageId; messageToSend.timestamp = new Date().toISOString(); @@ -17404,34 +17428,25 @@ export function createAgentChatService(args: { /** Maximum bytes to inline for a non-image chat attachment. */ const MAX_INLINE_BYTES = 512 * 1024; // 512 KB - const buildAgentPromptBlocks = ( + const buildAgentPromptBlocks = async ( promptText: string, resolvedAttachments: ResolvedAgentChatFileRef[], - ): Array<{ type: "text"; text: string } | { type: "image"; data: string; mimeType: string }> => { + ): Promise> => { const blocks: Array< { type: "text"; text: string } | { type: "image"; data: string; mimeType: string } > = [{ type: "text", text: promptText }]; for (const attachment of resolvedAttachments) { try { - // Check file size before reading the full contents into memory. - let fileSize: number; - try { - fileSize = fs.statSync(attachment._resolvedPath).size; - } catch { - // stat failed -- skip unreadable attachment - continue; - } + const buf = await readResolvedAttachmentBytes(attachment); if (attachment.type === "image") { - const buf = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath); blocks.push({ type: "image", data: buf.toString("base64"), mimeType: guessImageMimeForPath(attachment._resolvedPath), }); - } else if (fileSize <= MAX_INLINE_BYTES) { + } else if (buf.length <= MAX_INLINE_BYTES) { // Non-image file attachment -- include content as text if not binary - const buf = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath); if (hasNullByte(buf)) { blocks.push({ type: "text", @@ -17448,7 +17463,7 @@ export function createAgentChatService(args: { // File is too large to inline -- push a placeholder with a truncated preview. blocks.push({ type: "text", - text: `[File: ${attachment.path} omitted: size ${fileSize} bytes]`, + text: `[File: ${attachment.path} omitted: size ${buf.length} bytes]`, }); } } catch { @@ -17958,7 +17973,7 @@ export function createAgentChatService(args: { } } - const promptBlocks = buildAgentPromptBlocks(composed, args.resolvedAttachments); + const promptBlocks = await buildAgentPromptBlocks(composed, args.resolvedAttachments); const promptText = promptBlocks .filter((block): block is { type: "text"; text: string } => block.type === "text") .map((block) => block.text) @@ -18300,7 +18315,7 @@ export function createAgentChatService(args: { cloudComposed = `${injected}\n\n${cloudComposed}`; } } - const promptBlocks = buildAgentPromptBlocks(cloudComposed, args.resolvedAttachments); + const promptBlocks = await buildAgentPromptBlocks(cloudComposed, args.resolvedAttachments); const promptText = promptBlocks .filter((block): block is { type: "text"; text: string } => block.type === "text") .map((block) => block.text) @@ -19115,7 +19130,7 @@ export function createAgentChatService(args: { "## User Request", composed, ].join("\n"); - const promptBlocks = buildAgentPromptBlocks(sdkInput, args.resolvedAttachments); + const promptBlocks = await buildAgentPromptBlocks(sdkInput, args.resolvedAttachments); const sdkPromptText = promptBlocks .filter((block): block is { type: "text"; text: string } => block.type === "text") .map((block) => block.text) @@ -19859,7 +19874,7 @@ export function createAgentChatService(args: { input.push({ type: "image", url: attachment.url }); continue; } - const stagedPath = stageAttachmentForCodexInput(attachment); + const stagedPath = await stageAttachmentForCodexInput(attachment); if (attachment.type === "image") { input.push({ type: "localImage", path: stagedPath }); continue; @@ -20058,10 +20073,11 @@ export function createAgentChatService(args: { const dispatchUuid = randomUUID(); const contextPrompt = buildChatContextAttachmentPrompt(steer.contextAttachments); const inlineSteerText = contextPrompt ? `${contextPrompt}\n\n${steer.text}` : steer.text; - const sdkMsg = buildClaudeV2Message(inlineSteerText, steer.resolvedAttachments, { + const sdkMsg = await buildClaudeV2MessageAsync(inlineSteerText, steer.resolvedAttachments, { baseDir: managed.laneWorktreePath, sessionId: runtime.sdkSessionId ?? null, forceUserMessage: true, + getDirtyFileTextForPath, }) as unknown as SDKUserMessage; sdkMsg.shouldQuery = false; sdkMsg.uuid = dispatchUuid; diff --git a/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts b/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts index 6656ee6fb..87479e46d 100644 --- a/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts +++ b/apps/desktop/src/main/services/chat/buildClaudeV2Message.ts @@ -1,7 +1,11 @@ import fs from "node:fs"; import path from "node:path"; import type { AgentChatFileRef } from "../../../shared/types/chat"; -import { readFileWithinRootSecure } from "../shared/utils"; +import { + readAgentAccessibleFileBytes, + readFileWithinRootSecure, + type DirtyFileTextLookup, +} from "../shared/utils"; type ResolvedAgentChatFileRef = AgentChatFileRef & { _resolvedPath?: string; @@ -74,6 +78,80 @@ export type SDKUserMessagePartial = { }; }; +type BuildClaudeV2MessageOptions = { + baseDir?: string; + sessionId?: string | null; + forceUserMessage?: boolean; + getDirtyFileTextForPath?: DirtyFileTextLookup; +}; + +export async function buildClaudeV2MessageAsync( + promptText: string, + attachments: ResolvedAgentChatFileRef[], + options: BuildClaudeV2MessageOptions = {}, +): Promise { + const wrapAsUserMessage = (text: string): SDKUserMessagePartial => ({ + type: "user", + session_id: options.sessionId?.trim() ?? "", + parent_tool_use_id: null, + message: { role: "user", content: [{ type: "text", text }] }, + }); + + const imageAttachments = attachments.filter((a) => a.type === "image"); + if (!imageAttachments.length) { + const text = attachments.length + ? `${promptText}\n\n${attachments.map((a) => `[File attached: ${a.path}]`).join("\n")}` + : promptText; + return options.forceUserMessage ? wrapAsUserMessage(text) : text; + } + + const content: Array> = [ + { type: "text", text: promptText }, + ]; + + for (const attachment of attachments) { + if (attachment.type !== "image") { + content.push({ type: "text", text: `\n[File attached: ${attachment.path}]` }); + continue; + } + + try { + const mediaType = inferAttachmentMediaType(attachment); + if (!ANTHROPIC_IMAGE_MEDIA_TYPES.has(mediaType)) { + content.push({ type: "text", text: `\n[Image attached (${mediaType}): ${attachment.path}]` }); + continue; + } + const secureRoot = attachment._rootPath ?? options.baseDir; + const resolvedPath = attachment._resolvedPath ?? attachment.path; + if (!secureRoot) { + content.push({ type: "text", text: `\n[Image unavailable: ${attachment.path}]` }); + continue; + } + const data = await readAgentAccessibleFileBytes({ + rootPath: secureRoot, + resolvedPath, + getDirtyFileTextForPath: options.getDirtyFileTextForPath, + }); + content.push({ + type: "image", + source: { type: "base64", media_type: mediaType, data: data.toString("base64") }, + }); + } catch (error) { + content.push({ + type: "text", + text: `\n[Image unavailable: ${attachment.path}${error instanceof Error ? ` (${error.message})` : ""}]`, + }); + } + } + + return { + type: "user", + session_id: options.sessionId?.trim() ?? "", + parent_tool_use_id: null, + message: { role: "user", content }, + }; +} + /** * Build the message payload for a Claude SDK session turn. * When image attachments are present, returns a streaming-input-format @@ -88,12 +166,12 @@ export function buildClaudeV2Message( export function buildClaudeV2Message( promptText: string, attachments: ResolvedAgentChatFileRef[], - options?: { baseDir?: string; sessionId?: string | null; forceUserMessage?: boolean }, + options?: BuildClaudeV2MessageOptions, ): string | SDKUserMessagePartial; export function buildClaudeV2Message( promptText: string, attachments: ResolvedAgentChatFileRef[], - options: { baseDir?: string; sessionId?: string | null; forceUserMessage?: boolean } = {}, + options: BuildClaudeV2MessageOptions = {}, ): string | SDKUserMessagePartial { const wrapAsUserMessage = (text: string): SDKUserMessagePartial => ({ type: "user", diff --git a/apps/desktop/src/main/services/shared/utils.ts b/apps/desktop/src/main/services/shared/utils.ts index d9b3bb1fa..df6426e5d 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -460,6 +460,43 @@ function writeFileByDescriptor( * Re-resolve and validate a file at open time, then read it through the file * descriptor so callers do not rely on a previously checked path string. */ +export type DirtyFileTextLookup = ( + absPath: string, +) => string | undefined | Promise; + +/** + * Read file bytes for agent/tool consumption, preferring unsaved editor buffers + * from the renderer when available. + */ +export async function readAgentAccessibleFileBytes(args: { + rootPath: string; + resolvedPath: string; + getDirtyFileTextForPath?: DirtyFileTextLookup; +}): Promise { + const root = path.resolve(args.rootPath); + let absPath: string; + try { + absPath = path.isAbsolute(args.resolvedPath) + ? path.resolve(args.resolvedPath) + : resolvePathWithinRoot(root, args.resolvedPath, { allowMissing: false }); + } catch { + return readFileWithinRootSecure(root, args.resolvedPath); + } + + if (args.getDirtyFileTextForPath) { + try { + const dirty = await Promise.resolve(args.getDirtyFileTextForPath(absPath)); + if (typeof dirty === "string") { + return Buffer.from(dirty, "utf8"); + } + } catch { + // Fall back to on-disk content when renderer lookup fails. + } + } + + return readFileWithinRootSecure(root, args.resolvedPath); +} + export function readFileWithinRootSecure(root: string, candidate: string): Buffer { let expectedPath: string; try { From d6f32d79599eaf7c20454a2fe2fc0a215d32c527 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 28 May 2026 23:43:26 -0400 Subject: [PATCH 2/5] Fix Files tab navigation and dirty agent reads --- .../main/services/ai/tools/readFileRange.ts | 1 - .../services/chat/agentChatService.test.ts | 46 ++++ .../main/services/chat/agentChatService.ts | 214 ++++++++++++++++-- .../chat/buildClaudeV2Message.test.ts | 20 +- .../main/services/files/fileService.test.ts | 1 + .../src/main/services/files/fileService.ts | 88 +++++-- .../localRuntimeConnectionPool.test.ts | 56 +++++ .../localRuntimeConnectionPool.ts | 35 ++- .../src/main/services/shared/utils.test.ts | 40 ++++ .../desktop/src/main/services/shared/utils.ts | 6 +- apps/desktop/src/preload/preload.test.ts | 134 +++++++++-- apps/desktop/src/preload/preload.ts | 16 +- .../components/files/FilesExplorer.tsx | 19 +- .../components/files/FilesPage.test.tsx | 44 ++++ .../renderer/components/files/FilesPage.tsx | 158 +++++++++---- apps/desktop/src/shared/types/chat.ts | 10 +- apps/desktop/src/shared/types/files.ts | 2 + 17 files changed, 773 insertions(+), 117 deletions(-) diff --git a/apps/desktop/src/main/services/ai/tools/readFileRange.ts b/apps/desktop/src/main/services/ai/tools/readFileRange.ts index 4e67463bc..d2f619dd8 100644 --- a/apps/desktop/src/main/services/ai/tools/readFileRange.ts +++ b/apps/desktop/src/main/services/ai/tools/readFileRange.ts @@ -5,7 +5,6 @@ import path from "node:path"; import { getErrorMessage, readAgentAccessibleFileBytes, - readFileWithinRootSecure, resolvePathWithinRoot, type DirtyFileTextLookup, } from "../../shared/utils"; diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 2549046ab..948f0b2ce 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -6591,6 +6591,48 @@ describe("createAgentChatService", () => { expect(sessionService.deleteSession).toHaveBeenCalledWith(session.id); }); + it("purges a running Codex chat even when app-server interrupt and archive requests hang", async () => { + const events: AgentChatEventEnvelope[] = []; + const { service, sessionService } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.4", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Start a Codex turn.", + }, { awaitDispatch: true }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "status" + && event.event.turnStatus === "started" + && event.event.turnId === "turn-1", + ); + + mockState.delayedCodexMethods.add("turn/interrupt"); + mockState.delayedCodexMethods.add("thread/archive"); + vi.useFakeTimers(); + try { + const deleted = service.deleteSession({ sessionId: session.id }); + await vi.advanceTimersByTimeAsync(10_000); + await expect(deleted).resolves.toBeUndefined(); + } finally { + vi.useRealTimers(); + } + + expect(sessionService.end).toHaveBeenCalledWith( + expect.objectContaining({ sessionId: session.id, status: "disposed" }), + ); + expect(sessionService.deleteSession).toHaveBeenCalledWith(session.id); + expect(sessionService.get(session.id)).toBeNull(); + }); + it("does not follow transcript symlinks outside ADE during purge", async () => { const { service, sessionService } = createService(); const session = await service.createSession({ @@ -14700,6 +14742,7 @@ describe("createAgentChatService", () => { isCliWrapped: false, harnessProfile: "verified", } as any, + getDirtyFileTextForPath: () => "remember unsaved edits", logger: createLogger() as any, }); @@ -14708,6 +14751,9 @@ describe("createAgentChatService", () => { expect.objectContaining({ type: "text" }), expect.objectContaining({ type: "file", filename: "note.txt" }), ])); + const persistedContent = streamMessages[0]?.content as Array>; + const filePart = persistedContent.find((part) => part.type === "file") as { data?: Buffer } | undefined; + expect(filePart?.data?.toString("utf8")).toBe("remember unsaved edits"); expect(streamMessages[2]).toEqual({ role: "user", content: "Continue from your last step.", diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 26ecf404e..9848e5601 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -471,10 +471,31 @@ type PersistedPendingSteer = { }; type PendingRpc = { + method: string; + timer: NodeJS.Timeout | null; resolve: (value: any) => void; reject: (error: Error) => void; }; +type CodexRequestOptions = { + timeoutMs?: number | null; +}; + +function isCodexRequestTimeoutError(error: unknown): boolean { + const message = error instanceof Error ? error.message : String(error); + return /^Codex request '.+' timed out after \d+ms\.$/.test(message); +} + +function rejectPendingCodexRequests( + pending: Map, + message: string, +): void { + for (const request of pending.values()) { + request.reject(new Error(`${message} Pending request: ${request.method}.`)); + } + pending.clear(); +} + type PendingCodexApproval = { requestId: string | number; kind: "command" | "file_change" | "permissions" | "structured_question" | "plan_approval"; @@ -546,7 +567,7 @@ type CodexRuntime = { turnId: string | null; followupText: string; }>; - request: (method: string, params?: unknown) => Promise; + request: (method: string, params?: unknown, options?: CodexRequestOptions) => Promise; notify: (method: string, params?: unknown) => void; sendResponse: (id: string | number, result: unknown) => void; sendError: (id: string | number, message: string, code?: number) => void; @@ -1809,6 +1830,10 @@ const REASONING_ACTIVITY_DETAIL = "Thinking through the answer"; const WORKING_ACTIVITY_DETAIL = "Preparing response"; const DEFAULT_RUN_SESSION_TURN_TIMEOUT_MS = 300_000; const DEFAULT_COLLABORATION_MODES_LIST_TIMEOUT_MS = 1_500; +const CODEX_REQUEST_TIMEOUT_MS = 30_000; +const CODEX_INLINE_COMMAND_TIMEOUT_MS = 10_000; +const CODEX_INTERRUPT_REQUEST_TIMEOUT_MS = 2_500; +const CODEX_ARCHIVE_REQUEST_TIMEOUT_MS = 3_000; // Idle stream watchdog removed — time-based idle detection produced false // positives during long-running tool calls (Agent, Bash, etc.) where no // stream events are emitted while the SDK waits for tool results. The user @@ -8736,7 +8761,10 @@ export function createAgentChatService(args: { runtime.process, runtime.killTimer, ); - runtime.pending.clear(); + rejectPendingCodexRequests( + runtime.pending, + `Codex app-server runtime was torn down (${openCodeReason}).`, + ); for (const followup of runtime.pendingPlanFollowups.splice(0)) { emitPendingInputResolved(managed, { itemId: followup.itemId, @@ -9335,9 +9363,17 @@ export function createAgentChatService(args: { failurePrefix: string, ): Promise<{ ok: true; result: T } | { ok: false }> => { try { - return { ok: true, result: await runtime.request(method, params) }; + return { + ok: true, + result: await runtime.request(method, params, { + timeoutMs: CODEX_INLINE_COMMAND_TIMEOUT_MS, + }), + }; } catch (error) { completeFailedInlineCodexSlash(failurePrefix, error); + if (isCodexRequestTimeoutError(error)) { + teardownRuntime(managed, "handle_close"); + } return { ok: false }; } }; @@ -13004,6 +13040,57 @@ export function createAgentChatService(args: { } }; + const finishCodexTurnInterruptedLocally = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + turnId: string | null | undefined, + summary: string, + ): void => { + const interruptedTurnId = turnId?.trim() || runtime.activeTurnId || runtime.startedTurnId || randomUUID(); + rememberInterruptedCodexTurn(runtime, interruptedTurnId); + rememberTerminalCodexTurn(runtime, interruptedTurnId, managed); + runtime.awaitingTurnStart = false; + runtime.canAttachResumedTurnStart = false; + runtime.activeTurnId = null; + runtime.startedTurnId = null; + runtime.pendingTurnPlanningApprovalGuarded = null; + runtime.ignoredTurnIds.delete(interruptedTurnId); + resetAssistantMessageStream(managed); + runtime.itemTurnIdByItemId.clear(); + runtime.commandOutputByItemId.clear(); + runtime.fileDeltaByItemId.clear(); + runtime.fileChangesByItemId.clear(); + runtime.planTextByItemId.clear(); + runtime.webSearchActionsByItemId.clear(); + runtime.agentMessageScopeByTurn.clear(); + runtime.agentMessageTextByTurn.clear(); + runtime.recentNotificationKeys.clear(); + for (const followup of runtime.pendingPlanFollowups.splice(0)) { + emitPendingInputResolved(managed, { + itemId: followup.itemId, + decision: "cancel", + turnId: followup.turnId, + }); + } + runtime.approvals.clear(); + markSessionIdleWithFreshCache(managed); + stopActiveCodexSubagents(managed, runtime, interruptedTurnId, summary); + emitChatEvent(managed, { + type: "status", + turnStatus: "interrupted", + turnId: interruptedTurnId, + }); + void emitTurnDiffSummaryIfChanged(managed, interruptedTurnId); + emitChatEvent(managed, { + type: "done", + turnId: interruptedTurnId, + status: "interrupted", + model: managed.session.model, + ...(managed.session.modelId ? { modelId: managed.session.modelId } : {}), + }); + persistChatState(managed); + }; + const stopActiveClaudeSubagents = async ( managed: ManagedChatSession, runtime: ClaudeRuntime, @@ -14493,9 +14580,9 @@ export function createAgentChatService(args: { goalBudgetClearRetryAfterByThreadId: new Map(), dynamicTools: new Map(), dynamicToolSpecs: [], - request: async (method: string, params?: unknown): Promise => { - const id = runtime.nextRequestId; - runtime.nextRequestId += 1; + request: async (method: string, params?: unknown, options?: CodexRequestOptions): Promise => { + const id = runtime.nextRequestId; + runtime.nextRequestId += 1; const payload: JsonRpcEnvelope = { id, @@ -14508,8 +14595,47 @@ export function createAgentChatService(args: { } return new Promise((resolve, reject) => { - pending.set(String(id), { resolve, reject }); - proc.stdin.write(`${JSON.stringify(payload)}\n`); + const key = String(id); + const timeoutMs = options?.timeoutMs === null + ? null + : Math.max(1, Math.floor(options?.timeoutMs ?? CODEX_REQUEST_TIMEOUT_MS)); + let timer: NodeJS.Timeout | null = null; + const clearPendingTimer = () => { + if (!timer) return; + clearTimeout(timer); + timer = null; + }; + if (timeoutMs != null) { + timer = setTimeout(() => { + pending.delete(key); + logger.warn("agent_chat.codex_request_timeout", { + sessionId: managed.session.id, + method, + timeoutMs, + }); + reject(new Error(`Codex request '${method}' timed out after ${timeoutMs}ms.`)); + }, timeoutMs); + timer.unref?.(); + } + pending.set(key, { + method, + timer, + resolve: (value) => { + clearPendingTimer(); + resolve(value as T); + }, + reject: (error) => { + clearPendingTimer(); + reject(error); + }, + }); + try { + proc.stdin.write(`${JSON.stringify(payload)}\n`); + } catch (error) { + pending.delete(key); + clearPendingTimer(); + reject(error instanceof Error ? error : new Error(String(error))); + } }); }, notify: (method: string, params?: unknown) => { @@ -14587,10 +14713,7 @@ export function createAgentChatService(args: { error: error instanceof Error ? error.message : String(error), }); - for (const request of pending.values()) { - request.reject(new Error(message)); - } - pending.clear(); + rejectPendingCodexRequests(pending, message); for (const followup of runtime.pendingPlanFollowups.splice(0)) { emitPendingInputResolved(managed, { itemId: followup.itemId, @@ -14618,10 +14741,7 @@ export function createAgentChatService(args: { runtime.killTimer = null; } - for (const request of pending.values()) { - request.reject(new Error(message)); - } - pending.clear(); + rejectPendingCodexRequests(pending, message); for (const followup of runtime.pendingPlanFollowups.splice(0)) { emitPendingInputResolved(managed, { @@ -16706,7 +16826,21 @@ export function createAgentChatService(args: { if (managed.runtime?.kind === "codex" && !isBusyError) { managed.runtime.activeTurnId = null; managed.runtime.startedTurnId = null; + managed.runtime.awaitingTurnStart = false; + managed.runtime.canAttachResumedTurnStart = false; + managed.runtime.pendingTurnPlanningApprovalGuarded = null; managed.runtime.itemTurnIdByItemId.clear(); + managed.runtime.commandOutputByItemId.clear(); + managed.runtime.fileDeltaByItemId.clear(); + managed.runtime.fileChangesByItemId.clear(); + managed.runtime.planTextByItemId.clear(); + managed.runtime.webSearchActionsByItemId.clear(); + managed.runtime.agentMessageScopeByTurn.clear(); + managed.runtime.agentMessageTextByTurn.clear(); + managed.runtime.recentNotificationKeys.clear(); + if (isCodexRequestTimeoutError(error)) { + teardownRuntime(managed, "handle_close"); + } } if (managed.runtime?.kind === "opencode" && !isBusyError) { setOpenCodeRuntimeBusy(managed.runtime, false); @@ -20292,14 +20426,26 @@ export function createAgentChatService(args: { await runtime.request("turn/interrupt", { threadId: managed.session.threadId, turnId, - }); + }, { timeoutMs: CODEX_INTERRUPT_REQUEST_TIMEOUT_MS }); }; try { await interruptActiveTurn(interruptedTurnId); } catch (error) { const mismatch = parseCodexActiveTurnMismatch(error); if (!mismatch || mismatch.expectedTurnId !== interruptedTurnId) { - throw error; + logger.warn("agent_chat.codex_interrupt_failed", { + sessionId: managed.session.id, + turnId: interruptedTurnId, + error: error instanceof Error ? error.message : String(error), + }); + finishCodexTurnInterruptedLocally( + managed, + runtime, + interruptedTurnId, + "Interrupted by user before Codex app-server acknowledged the interrupt", + ); + teardownRuntime(managed, "handle_close"); + return; } adoptCodexActiveTurnId( managed, @@ -20310,7 +20456,23 @@ export function createAgentChatService(args: { ); persistChatState(managed); interruptedTurnId = mismatch.foundTurnId; - await interruptActiveTurn(interruptedTurnId); + try { + await interruptActiveTurn(interruptedTurnId); + } catch (retryError) { + logger.warn("agent_chat.codex_interrupt_retry_failed", { + sessionId: managed.session.id, + turnId: interruptedTurnId, + error: retryError instanceof Error ? retryError.message : String(retryError), + }); + finishCodexTurnInterruptedLocally( + managed, + runtime, + interruptedTurnId, + "Interrupted by user before Codex app-server acknowledged the interrupt", + ); + teardownRuntime(managed, "handle_close"); + return; + } } stopActiveCodexSubagents(managed, runtime, interruptedTurnId, "Interrupted by user"); return; @@ -21814,7 +21976,7 @@ export function createAgentChatService(args: { await runtime.request("turn/interrupt", { threadId: managed.session.threadId, turnId, - }); + }, { timeoutMs: CODEX_INTERRUPT_REQUEST_TIMEOUT_MS }); }; try { await interruptTurnForDispose(interruptedTurnId); @@ -21832,7 +21994,15 @@ export function createAgentChatService(args: { ); persistChatState(managed); interruptedTurnId = mismatch.foundTurnId; - await interruptTurnForDispose(interruptedTurnId); + try { + await interruptTurnForDispose(interruptedTurnId); + } catch (retryError) { + logger.warn("agent_chat.codex_dispose_interrupt_retry_failed", { + sessionId: managed.session.id, + turnId: interruptedTurnId, + error: retryError instanceof Error ? retryError.message : String(retryError), + }); + } } stopActiveCodexSubagents( managed, @@ -21850,7 +22020,7 @@ export function createAgentChatService(args: { try { await managed.runtime.request("thread/archive", { threadId: managed.session.threadId, - }); + }, { timeoutMs: CODEX_ARCHIVE_REQUEST_TIMEOUT_MS }); } catch { // thread/archive not supported or already archived — ignore } diff --git a/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts b/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts index cec13485b..433a1f8d2 100644 --- a/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts +++ b/apps/desktop/src/main/services/chat/buildClaudeV2Message.test.ts @@ -1,10 +1,11 @@ import fs from "node:fs"; import os from "node:os"; import path from "node:path"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; import type { AgentChatFileRef } from "../../../shared/types/chat"; import { buildClaudeV2Message, + buildClaudeV2MessageAsync, ANTHROPIC_IMAGE_MEDIA_TYPES, inferAttachmentMediaType, type SDKUserMessagePartial, @@ -129,6 +130,23 @@ describe("buildClaudeV2Message", () => { expect(Buffer.from(source.data as string, "base64").toString()).toBe("fake-image-bytes"); }); + it("prefers dirty editor bytes when building async image attachments", async () => { + writeFakeImage("dirty-photo.png", "saved-image-bytes"); + const attachments: AgentChatFileRef[] = [ + { path: "dirty-photo.png", type: "image" }, + ]; + + const result = await buildClaudeV2MessageAsync("Describe this image", attachments, { + baseDir: tmpDir, + getDirtyFileTextForPath: () => "unsaved-image-bytes", + }); + const msg = result as SDKUserMessagePartial; + const imgBlock = msg.message.content[1] as Record; + const source = imgBlock.source as Record; + + expect(Buffer.from(source.data as string, "base64").toString()).toBe("unsaved-image-bytes"); + }); + // ───────────────────────────────────────────────────────────────────────── // 4. Missing image file -> text fallback // ───────────────────────────────────────────────────────────────────────── diff --git a/apps/desktop/src/main/services/files/fileService.test.ts b/apps/desktop/src/main/services/files/fileService.test.ts index 442222080..dcf159d18 100644 --- a/apps/desktop/src/main/services/files/fileService.test.ts +++ b/apps/desktop/src/main/services/files/fileService.test.ts @@ -340,6 +340,7 @@ describe("fileService", () => { workspaceId: "workspace-1", depth: 1, includeIgnored: true, + forceFreshStatus: true, }); expect(rootNodes.find((node) => node.path === "package-renamed.json")?.changeStatus).toBe("renamed"); diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index 7e1a49357..ee4b600ca 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -40,6 +40,8 @@ const MAX_INLINE_IMAGE_PREVIEW_BYTES = 1024 * 1024; const MAX_INLINE_BINARY_BYTES = 256 * 1024; const MAX_TREE_CHILDREN_PER_DIRECTORY = 1_000; const GIT_STATUS_CACHE_TTL_MS = 5_000; +const GIT_STATUS_BACKGROUND_TIMEOUT_MS = 2_000; +const GIT_STATUS_FOREGROUND_TIMEOUT_MS = 10_000; const VOLATILE_ADE_PREFIXES = [ ".ade/artifacts/", ".ade/cache/", @@ -334,6 +336,12 @@ type GitStatusSnapshot = { changedDirectories: Set; }; +type GitStatusCacheEntry = { + fetchedAt: number; + snapshot: GitStatusSnapshot; + inFlight: Promise | null; +}; + function buildGitStatusSnapshot(fileStatus: Map): GitStatusSnapshot { const changedDirectories = new Set(); for (const [filePath, status] of fileStatus) { @@ -373,7 +381,8 @@ export function createFileService({ const indexService = createFileSearchIndexService(); const ignoreCache = new Map(); const ignoredPrefixCache = new Set(); - const gitStatusCache = new Map(); + const emptyGitStatusSnapshot = buildGitStatusSnapshot(new Map()); + const gitStatusCache = new Map(); const clearIgnoreCacheForRoot = (rootPath: string): void => { const prefix = `${rootPath}::`; @@ -390,7 +399,13 @@ export function createFileService({ }; const invalidateGitStatusCache = (rootPath: string): void => { - gitStatusCache.delete(rootPath); + const previous = gitStatusCache.get(rootPath); + if (!previous) return; + gitStatusCache.set(rootPath, { + fetchedAt: 0, + snapshot: previous.snapshot, + inFlight: previous.inFlight, + }); }; const resolveWorkspace = (workspaceId: string) => laneService.resolveWorkspaceById(workspaceId); @@ -479,14 +494,8 @@ export function createFileService({ })); }; - const getGitStatusSnapshot = async (rootPath: string): Promise => { - const cached = gitStatusCache.get(rootPath); - const now = Date.now(); - if (cached && now - cached.fetchedAt <= GIT_STATUS_CACHE_TTL_MS) { - return cached.snapshot; - } - - const res = await runGit(["status", "--porcelain=v1"], { cwd: rootPath, timeoutMs: 10_000 }); + const readGitStatusSnapshot = async (rootPath: string, timeoutMs: number): Promise => { + const res = await runGit(["status", "--porcelain=v1"], { cwd: rootPath, timeoutMs }); const out = new Map(); if (res.exitCode !== 0) return buildGitStatusSnapshot(out); const lines = res.stdout.split("\n").map((line) => line.trimEnd()).filter(Boolean); @@ -501,9 +510,58 @@ export function createFileService({ const normalized = normalizeRelative(rel); out.set(normalized, parseFileTreeStatus(code)); } - const snapshot = buildGitStatusSnapshot(out); - gitStatusCache.set(rootPath, { fetchedAt: now, snapshot }); - return snapshot; + return buildGitStatusSnapshot(out); + }; + + const refreshGitStatusSnapshot = ( + rootPath: string, + timeoutMs: number, + opts: { forceFresh?: boolean } = {}, + ): Promise => { + const cached = gitStatusCache.get(rootPath); + if (cached?.inFlight && !opts.forceFresh) return cached.inFlight; + + const startedAt = Date.now(); + const inFlight = readGitStatusSnapshot(rootPath, timeoutMs) + .catch(() => emptyGitStatusSnapshot) + .then((snapshot) => { + const current = gitStatusCache.get(rootPath); + if (!opts.forceFresh && current && current.fetchedAt > startedAt) { + return current.snapshot; + } + gitStatusCache.set(rootPath, { + fetchedAt: Date.now(), + snapshot, + inFlight: current?.inFlight === inFlight ? null : current?.inFlight ?? null, + }); + return snapshot; + }); + + gitStatusCache.set(rootPath, { + fetchedAt: cached?.fetchedAt ?? 0, + snapshot: cached?.snapshot ?? emptyGitStatusSnapshot, + inFlight: opts.forceFresh ? cached?.inFlight ?? null : inFlight, + }); + + return inFlight; + }; + + const getGitStatusSnapshot = async ( + rootPath: string, + opts: { forceFresh?: boolean } = {}, + ): Promise => { + if (opts.forceFresh) { + return await refreshGitStatusSnapshot(rootPath, GIT_STATUS_FOREGROUND_TIMEOUT_MS, { forceFresh: true }); + } + + const cached = gitStatusCache.get(rootPath); + const now = Date.now(); + if (cached && now - cached.fetchedAt <= GIT_STATUS_CACHE_TTL_MS) { + return cached.snapshot; + } + + void refreshGitStatusSnapshot(rootPath, GIT_STATUS_BACKGROUND_TIMEOUT_MS); + return cached?.snapshot ?? emptyGitStatusSnapshot; }; const isIgnoredPath = async (rootPath: string, relPath: string, includeIgnored: boolean): Promise => { @@ -620,7 +678,9 @@ export function createFileService({ const workspace = resolveWorkspace(args.workspaceId); const depth = Number.isFinite(args.depth) ? Math.max(1, Math.min(8, Math.floor(args.depth ?? 1))) : 1; const parentPath = normalizeRelative(args.parentPath ?? ""); - const statusSnapshot = await getGitStatusSnapshot(workspace.rootPath); + const statusSnapshot = await getGitStatusSnapshot(workspace.rootPath, { + forceFresh: args.forceFreshStatus === true, + }); const result = await listTreeNode({ rootPath: workspace.rootPath, parentPath, diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index ef81aa5d8..0c7bca9d9 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -1289,6 +1289,62 @@ describe("local runtime connection pool", () => { ); }); + it("bounds non-file action calls and drops a timed-out runtime connection", async () => { + const timeout = new Error("Remote ADE service timed out waiting for method ade/actions/call (30000ms)."); + const call = vi.fn().mockRejectedValue(timeout); + const close = vi.fn(); + const child = { + pid: 1234, + kill: vi.fn(), + once: vi.fn(), + }; + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never); + const rootPath = path.resolve("/repo"); + (pool as unknown as { projectsByRoot: Map }).projectsByRoot.set(rootPath, { + projectId: "project-1", + rootPath, + displayName: "repo", + addedAt: 1, + lastOpenedAt: 1, + gitOriginUrl: null, + }); + (pool as unknown as { connection: Promise }).connection = Promise.resolve({ + client: { call, close }, + child, + socketPath: "/tmp/ade.sock", + }); + (pool as unknown as { ownedRuntimeChild: unknown }).ownedRuntimeChild = child; + + await expect(pool.callActionForRoot(rootPath, { + domain: "chat", + action: "deleteSession", + args: { sessionId: "chat-1" }, + })).rejects.toThrow(/timed out waiting for method ade\/actions\/call/i); + + expect(call).toHaveBeenCalledWith( + "ade/actions/call", + { + projectId: "project-1", + name: "run_ade_action", + arguments: { + domain: "chat", + action: "deleteSession", + args: { sessionId: "chat-1" }, + }, + }, + { timeoutMs: 30_000 }, + ); + expect(close).toHaveBeenCalled(); + expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect((pool as unknown as { connection: unknown }).connection).toBeNull(); + expect((pool as unknown as { ownedRuntimeChild: unknown }).ownedRuntimeChild).toBeNull(); + }); + it("routes local sync calls through the project-scoped runtime RPC", async () => { const call = vi.fn().mockResolvedValue({ mode: "standalone", diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index e9fd72a26..00ab601e4 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -60,6 +60,7 @@ type LocalRuntimeNodePathOptions = { }; const LOCAL_RUNTIME_PROJECT_TIMEOUT_MS = 3_000; +const LOCAL_RUNTIME_ACTION_TIMEOUT_MS = 30_000; const LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS = 8_000; const LOCAL_RUNTIME_EVENT_POLL_TIMEOUT_MS = 2_000; const PLACEHOLDER_RUNTIME_VERSION = "0.0.0"; @@ -349,6 +350,10 @@ function closeRuntimeClient(client: RuntimeRpcClient): void { } catch {} } +function isRuntimeActionCallTimeout(error: Error): boolean { + return /timed out waiting for method ade\/actions\/call/i.test(error.message); +} + function signalRuntimeChildProcess(child: ChildProcess | null, signal: NodeJS.Signals): void { if (!child?.pid) return; try { @@ -694,9 +699,11 @@ export class LocalRuntimeConnectionPool { const tProject = Date.now(); const entry = await this.connect(); const tConnect = Date.now(); - const actionCallOptions = request.domain === "file" - ? { timeoutMs: LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS } - : undefined; + const actionCallOptions = { + timeoutMs: request.domain === "file" + ? LOCAL_RUNTIME_FILE_ACTION_TIMEOUT_MS + : LOCAL_RUNTIME_ACTION_TIMEOUT_MS, + }; let value: unknown = undefined; let callError: Error | null = null; try { @@ -732,6 +739,15 @@ export class LocalRuntimeConnectionPool { error: callError?.message ?? null, }); } + if (callError && isRuntimeActionCallTimeout(callError)) { + this.logger.warn("local_runtime.action_timeout_reset_connection", { + domain: request.domain, + action: request.action, + socketPath: entry.socketPath, + totalMs, + }); + this.resetConnectionAfterActionTimeout(entry); + } } if (callError) throw callError; @@ -868,6 +884,19 @@ export class LocalRuntimeConnectionPool { return this.connection; } + private resetConnectionAfterActionTimeout(entry: LocalRuntimeConnection): void { + if (!this.activeClient || this.activeClient === entry.client) { + this.connection = null; + this.activeClient = null; + this.projectsByRoot.clear(); + } + if (entry.child && this.ownedRuntimeChild === entry.child) { + this.ownedRuntimeChild = null; + } + closeRuntimeClient(entry.client); + disposeOwnedRuntimeChild(entry.child, entry.socketPath, { unlinkSocket: true }); + } + private async createConnection(): Promise { const layout = resolveMachineAdeLayout(); const socketPath = process.env.ADE_RUNTIME_SOCKET_PATH?.trim() || layout.socketPath; diff --git a/apps/desktop/src/main/services/shared/utils.test.ts b/apps/desktop/src/main/services/shared/utils.test.ts index 50410ab7a..56926f95b 100644 --- a/apps/desktop/src/main/services/shared/utils.test.ts +++ b/apps/desktop/src/main/services/shared/utils.test.ts @@ -26,6 +26,7 @@ import { sha256Hex, stableStringify, toBase64Url, + readAgentAccessibleFileBytes, createPkcePair, escapeRegExp, globToRegExp, @@ -327,6 +328,45 @@ describe("resolvePathWithinRoot", () => { }); }); +describe("readAgentAccessibleFileBytes", () => { + it("prefers dirty editor text for files inside the workspace", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-dirty-read-")); + try { + const filePath = path.join(root, "src", "app.ts"); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, "saved", "utf8"); + + const bytes = await readAgentAccessibleFileBytes({ + rootPath: root, + resolvedPath: filePath, + getDirtyFileTextForPath: () => "unsaved", + }); + + expect(bytes.toString("utf8")).toBe("unsaved"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } + }); + + it("does not let dirty-buffer lookup bypass the workspace boundary", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "ade-dirty-read-root-")); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), "ade-dirty-read-outside-")); + try { + const outsidePath = path.join(outside, "secret.txt"); + fs.writeFileSync(outsidePath, "outside disk", "utf8"); + + await expect(readAgentAccessibleFileBytes({ + rootPath: root, + resolvedPath: outsidePath, + getDirtyFileTextForPath: () => "outside dirty", + })).rejects.toThrow("Path escapes root"); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + fs.rmSync(outside, { recursive: true, force: true }); + } + }); +}); + describe("toOptionalString", () => { it("returns trimmed string for non-empty values", () => { expect(toOptionalString(" hello ")).toBe("hello"); diff --git a/apps/desktop/src/main/services/shared/utils.ts b/apps/desktop/src/main/services/shared/utils.ts index df6426e5d..cba941536 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -476,9 +476,7 @@ export async function readAgentAccessibleFileBytes(args: { const root = path.resolve(args.rootPath); let absPath: string; try { - absPath = path.isAbsolute(args.resolvedPath) - ? path.resolve(args.resolvedPath) - : resolvePathWithinRoot(root, args.resolvedPath, { allowMissing: false }); + absPath = resolvePathWithinRoot(root, args.resolvedPath, { allowMissing: false }); } catch { return readFileWithinRootSecure(root, args.resolvedPath); } @@ -494,7 +492,7 @@ export async function readAgentAccessibleFileBytes(args: { } } - return readFileWithinRootSecure(root, args.resolvedPath); + return readFileWithinRootSecure(root, absPath); } export function readFileWithinRootSecure(root: string, candidate: string): Buffer { diff --git a/apps/desktop/src/preload/preload.test.ts b/apps/desktop/src/preload/preload.test.ts index 40820f31c..1663421b5 100644 --- a/apps/desktop/src/preload/preload.test.ts +++ b/apps/desktop/src/preload/preload.test.ts @@ -1488,7 +1488,7 @@ describe("preload OAuth bridge", () => { ); }); - it("routes local project file operations through the local runtime when bound", async () => { + it("uses in-process file IPC for local project file operations when bound", async () => { const binding = { kind: "local", key: "local:/repo", @@ -1501,15 +1501,10 @@ describe("preload OAuth bridge", () => { return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; } if (channel === IPC.localRuntimeCallAction) { - return { - domain: "file", - action: "listWorkspaces", - result: workspaces, - statusHints: {}, - }; + throw new Error("local file operations should not call the local runtime daemon"); } if (channel === IPC.filesListWorkspaces) { - throw new Error("runtime-bound files should not use in-process IPC"); + return workspaces; } throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); }); @@ -1535,20 +1530,18 @@ describe("preload OAuth bridge", () => { const bridge = (globalThis as any).__adeBridge; await expect(bridge.files.listWorkspaces()).resolves.toEqual(workspaces); - expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { - rootPath: "/repo", - request: { domain: "file", action: "listWorkspaces", args: {} }, - }); - expect(invoke).not.toHaveBeenCalledWith(IPC.filesListWorkspaces, expect.anything()); + expect(invoke).toHaveBeenCalledWith(IPC.filesListWorkspaces, {}); + expect(invoke).not.toHaveBeenCalledWith(IPC.localRuntimeCallAction, expect.anything()); }); - it("does not fall through to in-process file IPC when a bound local runtime file call fails", async () => { + it("uses in-process tree IPC for local folders even when the local runtime is bound", async () => { const binding = { kind: "local", key: "local:/repo", rootPath: "/repo", displayName: "Project", }; + const tree = [{ name: "src", path: "src", type: "directory" }]; const runtimeError = new Error( "Error invoking remote method 'ade.localRuntime.callAction': Error: IPC handler for 'ade.localRuntime.callAction' timed out after 30000ms", ); @@ -1559,8 +1552,8 @@ describe("preload OAuth bridge", () => { if (channel === IPC.localRuntimeCallAction) { throw runtimeError; } - if (channel === IPC.filesListWorkspaces) { - throw new Error("runtime-bound files should not fall through to missing in-process IPC"); + if (channel === IPC.filesListTree) { + return tree; } throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); }); @@ -1584,13 +1577,69 @@ describe("preload OAuth bridge", () => { await import("./preload"); const bridge = (globalThis as any).__adeBridge; - await expect(bridge.files.listWorkspaces()).rejects.toThrow("ade.localRuntime.callAction"); + await expect(bridge.files.listTree({ workspaceId: "primary", parentPath: "src" })).resolves.toEqual(tree); - expect(invoke).toHaveBeenCalledWith(IPC.localRuntimeCallAction, { + expect(invoke).toHaveBeenCalledWith(IPC.filesListTree, { + workspaceId: "primary", + parentPath: "src", + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.localRuntimeCallAction, expect.anything()); + }); + + it("uses in-process diff IPC for local project diff reads when bound", async () => { + const binding = { + kind: "local", + key: "local:/repo", rootPath: "/repo", - request: { domain: "file", action: "listWorkspaces", args: {} }, + displayName: "Project", + }; + const patch = { + path: "src/app.ts", + mode: "working", + patch: "diff --git a/src/app.ts b/src/app.ts\n", + }; + const invoke = vi.fn(async (channel: string, arg?: unknown) => { + if (channel === IPC.appGetWindowSession) { + return { windowId: 1, project: { rootPath: "/repo", displayName: "Project" }, binding }; + } + if (channel === IPC.localRuntimeCallAction) { + throw new Error("local diff reads should not call the local runtime daemon"); + } + if (channel === IPC.diffGetFilePatch) { + return patch; + } + throw new Error(`unexpected IPC: ${channel} ${JSON.stringify(arg)}`); }); - expect(invoke).not.toHaveBeenCalledWith(IPC.filesListWorkspaces, expect.anything()); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + await expect( + bridge.diff.getFilePatch({ laneId: "lane-1", path: "src/app.ts", mode: "working" }), + ).resolves.toEqual(patch); + + expect(invoke).toHaveBeenCalledWith(IPC.diffGetFilePatch, { + laneId: "lane-1", + path: "src/app.ts", + mode: "working", + }); + expect(invoke).not.toHaveBeenCalledWith(IPC.localRuntimeCallAction, expect.anything()); }); it("keeps remote runtime routing for remote project file operations", async () => { @@ -4479,6 +4528,51 @@ describe("preload OAuth bridge", () => { await pendingSwitch; }); + it("blocks mutating local file actions while a project switch is in flight", async () => { + let resolveSwitch!: (project: unknown) => void; + const switchPromise = new Promise((resolve) => { + resolveSwitch = resolve; + }); + const invoke = vi.fn(async (channel: string) => { + if (channel === IPC.projectSwitchToPath) { + return switchPromise; + } + throw new Error(`unexpected IPC: ${channel}`); + }); + const on = vi.fn(); + const removeListener = vi.fn(); + const exposeInMainWorld = vi.fn((name: string, value: unknown) => { + (globalThis as any).__bridgeName = name; + (globalThis as any).__adeBridge = value; + }); + + vi.doMock("electron", () => ({ + contextBridge: { exposeInMainWorld }, + ipcRenderer: { invoke, on, removeListener }, + webFrame: { + getZoomLevel: vi.fn(() => 0), + setZoomLevel: vi.fn(), + getZoomFactor: vi.fn(() => 1), + }, + })); + + await import("./preload"); + + const bridge = (globalThis as any).__adeBridge; + const pendingSwitch = bridge.project.switchToPath("/next"); + + await expect( + bridge.files.writeText({ workspaceId: "primary", path: "src/app.ts", text: "next" }), + ).rejects.toThrow(/Project is switching/i); + + expect(invoke).toHaveBeenCalledWith(IPC.projectSwitchToPath, { rootPath: "/next" }); + expect(invoke).not.toHaveBeenCalledWith(IPC.filesWriteText, expect.anything()); + expect(invoke).not.toHaveBeenCalledWith(IPC.localRuntimeCallAction, expect.anything()); + + resolveSwitch({ rootPath: "/next", displayName: "Next", baseRef: "main" }); + await pendingSwitch; + }); + it("keeps the previous local runtime binding until a project switch succeeds", async () => { let resolveSwitch!: (project: unknown) => void; const switchPromise = new Promise((resolve) => { diff --git a/apps/desktop/src/preload/preload.ts b/apps/desktop/src/preload/preload.ts index ba5068621..a70bcddc3 100644 --- a/apps/desktop/src/preload/preload.ts +++ b/apps/desktop/src/preload/preload.ts @@ -1316,18 +1316,16 @@ async function callProjectFileRuntimeActionOr( request: Omit, local: () => Promise, ): Promise { + if (shouldBypassProjectRuntimeDuringTransition("file", action)) { + return local(); + } const remote = await callRemoteProjectActionIfBound( "file", action, request, ); if (remote.handled) return remote.result; - const localRuntime = await callLocalProjectActionStrictIfBound( - "file", - action, - request, - ); - return localRuntime.handled ? localRuntime.result : local(); + return local(); } async function callRemoteProjectSyncIfBound( @@ -5841,7 +5839,7 @@ contextBridge.exposeInMainWorld("ade", { }, diff: { getChanges: async (args: GetDiffChangesArgs): Promise => { - const runtime = await callProjectRuntimeActionIfBound( + const runtime = await callRemoteProjectActionIfBound( "diff", "getChanges", { arg: args.laneId }, @@ -5850,7 +5848,7 @@ contextBridge.exposeInMainWorld("ade", { return diffChangesCache.get(serializeIpcCacheArgs(args)); }, getFile: async (args: GetFileDiffArgs): Promise => { - const runtime = await callProjectRuntimeActionIfBound( + const runtime = await callRemoteProjectActionIfBound( "diff", "getFileDiff", { @@ -5868,7 +5866,7 @@ contextBridge.exposeInMainWorld("ade", { : ipcRenderer.invoke(IPC.diffGetFile, args); }, getFilePatch: async (args: GetFilePatchArgs): Promise => { - const runtime = await callProjectRuntimeActionIfBound( + const runtime = await callRemoteProjectActionIfBound( "diff", "getFilePatch", { diff --git a/apps/desktop/src/renderer/components/files/FilesExplorer.tsx b/apps/desktop/src/renderer/components/files/FilesExplorer.tsx index e9d120ea4..854f39a4a 100644 --- a/apps/desktop/src/renderer/components/files/FilesExplorer.tsx +++ b/apps/desktop/src/renderer/components/files/FilesExplorer.tsx @@ -40,6 +40,7 @@ export type FilesExplorerContextMenuEvent = { export type FilesExplorerProps = { tree: FileTreeNode[]; expanded: Set; + loadingDirectories: Set; selectedNodePath: string | null; activeTabPath: string | null; activeContextDir: string; @@ -114,6 +115,7 @@ function flattenVisibleRows(args: { export function FilesExplorer({ tree, expanded, + loadingDirectories, selectedNodePath, activeTabPath, activeContextDir, @@ -373,6 +375,7 @@ export function FilesExplorer({ if (!row) return null; const { node, level } = row; const isExpanded = expanded.has(node.path); + const isLoading = node.type === "directory" && loadingDirectories.has(node.path); const isActive = (activeTabPath != null && arePathsEqual(activeTabPath, node.path, workspaceComparisonRoot)) || (selectedNodePath != null && arePathsEqual(selectedNodePath, node.path, workspaceComparisonRoot)); const statusColor = changeStatusColor(node.changeStatus ?? null); @@ -396,7 +399,7 @@ export function FilesExplorer({ const handleRowActivate = () => { onSelectNode(node.path); if (node.type === "directory") { - onToggleDirectory(node.path, isExpanded, Boolean(node.children)); + onToggleDirectory(node.path, isExpanded, Array.isArray(node.children)); return; } onOpenFile(node.path); @@ -485,6 +488,20 @@ export function FilesExplorer({ {node.type === "directory" && node.changeStatus ? ( ) : null} + {isLoading ? ( + + ... + + ) : null} {node.type === "file" && statusLabel ? ( void; @@ -385,6 +386,7 @@ describe("FilesPage", () => { afterEach(() => { cleanup(); + clearDirtyBuffersForWorkspace(projectRoot); latestMockEditor = null; createdMockEditors = []; changeListener = null; @@ -804,6 +806,47 @@ describe("FilesPage", () => { expect((window.ade.files.readFile as any).mock.calls.some(([arg]: [{ path: string }]) => arg.path === "src/main.ts")).toBe(true); }); + it("loads folder children while a root refresh is still pending", async () => { + const rootTree: FileTreeNode[] = [{ name: "src", path: "src", type: "directory" }]; + const childTree: FileTreeNode[] = [{ name: "index.ts", path: "src/index.ts", type: "file" }]; + let rootCalls = 0; + let resolveRootRefresh!: (nodes: FileTreeNode[]) => void; + const pendingRootRefresh = new Promise((resolve) => { + resolveRootRefresh = resolve; + }); + vi.mocked(window.ade.files.listTree).mockImplementation(async ({ parentPath }: { parentPath?: string }) => { + if (parentPath === "src") return cloneTree(childTree); + rootCalls += 1; + if (rootCalls === 1) return cloneTree(rootTree); + return pendingRootRefresh; + }); + + renderFilesPage(); + + expect(await screen.findByTitle("src")).toBeTruthy(); + await waitForFilesWatcherStartup(); + emitFileChange({ + workspaceId: "primary", + type: "modified", + path: "README.md", + ts: new Date().toISOString(), + }); + await new Promise((resolve) => setTimeout(resolve, 150)); + + fireEvent.click(screen.getByTitle("src")); + + expect(await screen.findByTitle("src/index.ts")).toBeTruthy(); + expect(window.ade.files.listTree).toHaveBeenCalledWith(expect.objectContaining({ + workspaceId: "primary", + parentPath: "src", + })); + + act(() => resolveRootRefresh(cloneTree(rootTree))); + await waitFor(() => { + expect(screen.getByTitle("src/index.ts")).toBeTruthy(); + }); + }); + it("renames the selected tree row inline with F2", async () => { renderFilesPage({ preferPrimaryWorkspace: true }); @@ -976,6 +1019,7 @@ describe("FilesPage", () => { await waitFor(() => { expect(screen.getByText(/OPEN A FILE TO START EDITING/i)).toBeTruthy(); }); + expect(getDirtyFileTextForWindow(`${projectRoot}/.ade/worktrees/large-a/src/index.ts`)).toBeUndefined(); act(() => { useAppStore.setState({ selectedLaneId: laneA }); diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index 2474d2e5b..9d286d40f 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -216,6 +216,44 @@ function writeCachedFilesRootTree(projectRoot: string, workspaceId: string, node } } +function fileTreeNodeByPath(nodes: FileTreeNode[]): Map { + const out = new Map(); + const walk = (items: FileTreeNode[]) => { + for (const item of items) { + out.set(item.path, item); + if (item.children?.length) walk(item.children); + } + }; + walk(nodes); + return out; +} + +function mergeTreePreservingLoadedChildren(nextNodes: FileTreeNode[], previousNodes: FileTreeNode[]): FileTreeNode[] { + const previousByPath = fileTreeNodeByPath(previousNodes); + return nextNodes.map((node) => { + if (node.type !== "directory") return node; + const previous = previousByPath.get(node.path); + if (!previous?.children || node.children) return node; + return { + ...node, + children: previous.children, + childrenTruncated: previous.childrenTruncated, + }; + }); +} + +function replaceTreeNodeChildren(nodes: FileTreeNode[], parentPath: string, children: FileTreeNode[]): FileTreeNode[] { + return nodes.map((node) => { + if (node.path === parentPath) { + return { ...node, children }; + } + if (node.children?.length) { + return { ...node, children: replaceTreeNodeChildren(node.children, parentPath, children) }; + } + return node; + }); +} + function defaultFilesWorkspaceId( workspaces: FilesWorkspace[], preferredLaneId: string | null, @@ -600,10 +638,14 @@ export function FilesPage({ const [workspaces, setWorkspaces] = useState(initialCachedWorkspaces); const [workspaceId, setWorkspaceId] = useState(initialWorkspaceId); + const workspaceIdRef = useRef(initialWorkspaceId); + workspaceIdRef.current = workspaceId; + const dirtyWorkspaceRootRef = useRef(null); const [unavailableWorkspaceIds, setUnavailableWorkspaceIds] = useState>(() => new Set()); const [allowPrimaryEdit, setAllowPrimaryEdit] = useState(initialSession?.allowPrimaryEdit ?? false); const [tree, setTree] = useState(() => readCachedFilesRootTree(projectRootPath, initialWorkspaceId)); const [expanded, setExpanded] = useState>(new Set()); + const [loadingDirectories, setLoadingDirectories] = useState>(new Set()); const [selectedNodePath, setSelectedNodePath] = useState(initialSession?.selectedNodePath ?? null); const pendingOpenRef = useRef<{ filePath: string; @@ -617,11 +659,13 @@ export function FilesPage({ const pendingRevealRef = useRef<{ mode: EditorViewMode; startLine: number; startColumn?: number; targetPath?: string } | null>(null); const diffViewRef = useRef(null); const treeRefreshStateRef = useRef<{ - inFlight: boolean; + rootInFlight: boolean; + inFlightParents: Set; queuedFull: boolean; queuedParents: Set; }>({ - inFlight: false, + rootInFlight: false, + inFlightParents: new Set(), queuedFull: false, queuedParents: new Set() }); @@ -741,8 +785,14 @@ export function FilesPage({ }, [activeTabIsMarkdown, markdownPreviewEnabled]); useEffect(() => { - if (!activeWorkspace?.rootPath) return; - replaceDirtyBuffersForWorkspace(activeWorkspace.rootPath, openTabs); + const nextRootPath = activeWorkspace?.rootPath ?? null; + const previousRootPath = dirtyWorkspaceRootRef.current; + if (previousRootPath && previousRootPath !== nextRootPath) { + clearDirtyBuffersForWorkspace(previousRootPath); + } + dirtyWorkspaceRootRef.current = nextRootPath; + if (!nextRootPath) return; + replaceDirtyBuffersForWorkspace(nextRootPath, openTabs); }, [activeWorkspace?.rootPath, openTabs]); const prevSessionKeyRef = useRef(sessionKey); @@ -997,52 +1047,56 @@ export function FilesPage({ const refreshTreeNow = useCallback(async (parentPath?: string) => { if (!workspaceId) return; + const requestWorkspaceId = workspaceId; const isRootRefresh = !parentPath; const startedAt = isRootRefresh ? performance.now() : 0; if (isRootRefresh) { logRendererDebugEvent("renderer.files.refresh_tree.begin", { - workspaceId, + workspaceId: requestWorkspaceId, }); } try { const nodes = await window.ade.files.listTree({ - workspaceId, + workspaceId: requestWorkspaceId, parentPath, depth: 1, includeIgnored: true }); + if (workspaceIdRef.current !== requestWorkspaceId) return; if (isRootRefresh) { logRendererDebugEvent("renderer.files.refresh_tree.done", { - workspaceId, + workspaceId: requestWorkspaceId, durationMs: Math.round(performance.now() - startedAt), rootNodeCount: nodes.length, }); } if (!parentPath) { - writeCachedFilesRootTree(projectRootPath, workspaceId, nodes); - setTree(nodes); + setTree((prev) => { + const nextTree = mergeTreePreservingLoadedChildren(nodes, prev); + writeCachedFilesRootTree(projectRootPath, requestWorkspaceId, nextTree); + return nextTree; + }); setError(null); setUnavailableWorkspaceIds((prev) => { - if (!prev.has(workspaceId)) return prev; + if (!prev.has(requestWorkspaceId)) return prev; const next = new Set(prev); - next.delete(workspaceId); + next.delete(requestWorkspaceId); return next; }); return; } - const merge = (items: FileTreeNode[]): FileTreeNode[] => - items.map((item) => { - if (item.path === parentPath) return { ...item, children: nodes }; - if (item.children?.length) return { ...item, children: merge(item.children) }; - return item; - }); - setTree((prev) => merge(prev)); + setTree((prev) => { + const nextTree = replaceTreeNodeChildren(prev, parentPath, nodes); + writeCachedFilesRootTree(projectRootPath, requestWorkspaceId, nextTree); + return nextTree; + }); } catch (err) { + if (workspaceIdRef.current !== requestWorkspaceId) return; const message = formatFilesError(err); if (isRootRefresh) { logRendererDebugEvent("renderer.files.refresh_tree.failed", { - workspaceId, + workspaceId: requestWorkspaceId, durationMs: Math.round(performance.now() - startedAt), error: message, }); @@ -1050,10 +1104,11 @@ export function FilesPage({ const primaryWorkspace = activeWorkspace?.kind !== "primary" ? workspaces.find((workspace) => workspace.kind === "primary") : null; - if (isRootRefresh && primaryWorkspace && primaryWorkspace.id !== workspaceId && isMissingWorkspaceRootError(message) && !hasUnsavedTabs) { - setUnavailableWorkspaceIds((prev) => new Set(prev).add(workspaceId)); + if (isRootRefresh && primaryWorkspace && primaryWorkspace.id !== requestWorkspaceId && isMissingWorkspaceRootError(message) && !hasUnsavedTabs) { + setUnavailableWorkspaceIds((prev) => new Set(prev).add(requestWorkspaceId)); setTree([]); setExpanded(new Set()); + setLoadingDirectories(new Set()); setSelectedNodePath(null); setOpenTabs([]); setActiveTabPath(null); @@ -1070,36 +1125,37 @@ export function FilesPage({ if (!workspaceId) return; const normalizedParent = parentPath?.trim() ? parentPath : undefined; const state = treeRefreshStateRef.current; - if (state.inFlight) { - if (!normalizedParent) { - state.queuedFull = true; - state.queuedParents.clear(); - } else if (!state.queuedFull) { - state.queuedParents.add(normalizedParent); + + if (normalizedParent) { + if (state.inFlightParents.has(normalizedParent)) return; + state.inFlightParents.add(normalizedParent); + try { + await refreshTreeNowRef.current!(normalizedParent); + } finally { + state.inFlightParents.delete(normalizedParent); } return; } - state.inFlight = true; + + if (state.rootInFlight) { + state.queuedFull = true; + state.queuedParents.clear(); + return; + } + + state.rootInFlight = true; try { - let nextParent: string | undefined = normalizedParent; while (true) { - await refreshTreeNowRef.current!(nextParent); + await refreshTreeNowRef.current!(undefined); if (state.queuedFull) { state.queuedFull = false; state.queuedParents.clear(); - nextParent = undefined; - continue; - } - const [queuedParent] = state.queuedParents; - if (queuedParent) { - state.queuedParents.delete(queuedParent); - nextParent = queuedParent; continue; } break; } } finally { - state.inFlight = false; + state.rootInFlight = false; } }, [refreshTreeNow, workspaceId]); @@ -1442,9 +1498,11 @@ export function FilesPage({ setTree(cachedTree); setExpanded(new Set()); setContextMenu(null); - treeRefreshStateRef.current.inFlight = false; + treeRefreshStateRef.current.rootInFlight = false; + treeRefreshStateRef.current.inFlightParents.clear(); treeRefreshStateRef.current.queuedFull = false; treeRefreshStateRef.current.queuedParents.clear(); + setLoadingDirectories(new Set()); void refreshTree(); }, [active, projectRootPath, workspaceId, refreshTree]); @@ -1946,9 +2004,25 @@ export function FilesPage({ return next; }); if (!isExpanded && !hasLoadedChildren) { - refreshTree(nodePath).catch(() => {}); + setLoadingDirectories((prev) => { + if (prev.has(nodePath)) return prev; + const next = new Set(prev); + next.add(nodePath); + return next; + }); + refreshTree(nodePath) + .catch(() => {}) + .finally(() => { + if (workspaceIdRef.current !== workspaceId) return; + setLoadingDirectories((prev) => { + if (!prev.has(nodePath)) return prev; + const next = new Set(prev); + next.delete(nodePath); + return next; + }); + }); } - }, [refreshTree]); + }, [refreshTree, workspaceId]); const runContextAction = (fn: () => Promise) => { setContextMenu(null); @@ -1968,6 +2042,7 @@ export function FilesPage({ compact={embedded} tree={tree} expanded={expanded} + loadingDirectories={loadingDirectories} selectedNodePath={selectedTreeNodePath} activeTabPath={activeTabPath} activeContextDir={activeContextDir} @@ -2295,6 +2370,7 @@ export function FilesPage({ resolvedConflictKeys, createFileAt, createDirectoryAt, saveActive, closeTab, stagePath, unstagePath, discardPath, openFile, setShowQuickOpen, navigate, applyConflictResolution, setEditorHostRef, workspaceComparisonRoot, toggleDirectory, renamePathTo, + loadingDirectories, embedded ]); diff --git a/apps/desktop/src/shared/types/chat.ts b/apps/desktop/src/shared/types/chat.ts index e6583babc..651671751 100644 --- a/apps/desktop/src/shared/types/chat.ts +++ b/apps/desktop/src/shared/types/chat.ts @@ -285,7 +285,15 @@ export type CodexThreadTokenUsage = { modelContextWindow?: number | null; }; -export type CodexThreadGoalStatus = "active" | "paused" | "budget_limited" | "complete" | "cancelled" | "unknown"; +export type CodexThreadGoalStatus = + | "active" + | "paused" + | "blocked" + | "usage_limited" + | "budget_limited" + | "complete" + | "cancelled" + | "unknown"; export type CodexThreadGoal = { objective?: string | null; diff --git a/apps/desktop/src/shared/types/files.ts b/apps/desktop/src/shared/types/files.ts index 64f4fa184..e332650ca 100644 --- a/apps/desktop/src/shared/types/files.ts +++ b/apps/desktop/src/shared/types/files.ts @@ -49,6 +49,8 @@ export type FilesListTreeArgs = { parentPath?: string; depth?: number; includeIgnored?: boolean; + /** Prefer cached/background Git decorations unless the caller explicitly needs fresh status. */ + forceFreshStatus?: boolean; }; export type FilePreviewKind = "text" | "image" | "binary"; From 1591852f3208a0c44570ca08086069ea3b0d9d57 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 29 May 2026 00:12:32 -0400 Subject: [PATCH 3/5] Fix Codex goal slash handling --- .../services/chat/agentChatService.test.ts | 164 +++++++- .../main/services/chat/agentChatService.ts | 397 +++++++++++++++--- .../components/chat/AgentChatPane.tsx | 28 +- .../components/chat/codex/CodexGoalBanner.tsx | 5 + .../components/chat/codex/CodexGoalCard.tsx | 14 + 5 files changed, 535 insertions(+), 73 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 948f0b2ce..d2050be02 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -11267,6 +11267,129 @@ describe("createAgentChatService", () => { }); }); + it("sets typed Codex /goal text and starts a real app-server turn", async () => { + mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { + const params = payload.params as Record; + return { + goal: { + objective: params.objective, + status: params.status ?? "active", + tokenBudget: null, + }, + }; + }); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/goal Ship CLI parity", + }, { awaitDispatch: true }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + const goalRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set"); + expect(goalRequest?.params).toMatchObject({ + threadId: expect.any(String), + objective: "Ship CLI parity", + status: "active", + }); + const turnStartRequest = mockState.codexRequestPayloads.find((payload) => payload.method === "turn/start"); + const turnParams = turnStartRequest?.params as { input?: Array<{ text?: unknown }> } | undefined; + const turnInputText = turnParams?.input?.map((entry) => String(entry.text ?? "")).join("\n") ?? ""; + expect(turnInputText).toContain("Ship CLI parity"); + expect(turnInputText).not.toContain("/goal"); + expect(events.some((event) => + event.event.type === "user_message" + && event.event.text.includes("/goal") + )).toBe(false); + expect(events.some((event) => + event.event.type === "status" + && event.event.turnStatus === "completed" + )).toBe(false); + expect(events.some((event) => + event.event.type === "done" + && event.event.status === "completed" + )).toBe(false); + }); + + it("asks before replacing an existing typed Codex goal", async () => { + mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { + const params = payload.params as Record; + return { + goal: { + objective: params.objective, + status: params.status ?? "active", + tokenBudget: null, + }, + }; + }); + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => events.push(event), + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "/goal set Existing goal", + }, { awaitDispatch: true }); + mockState.codexRequestPayloads = []; + + await service.sendMessage({ + sessionId: session.id, + text: "/goal Replacement goal", + }, { awaitDispatch: true }); + + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/goal/set")).toBe(false); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); + const approvalEvent = await waitForEvent( + events, + (event): event is AgentChatEventEnvelope & { + event: Extract; + } => { + const detail = event.event.type === "approval_request" + ? (event.event.detail as { request?: PendingInputRequest } | undefined) + : undefined; + return event.event.type === "approval_request" + && detail?.request?.providerMetadata?.kind === "codex_goal_replace"; + }, + ); + const request = (approvalEvent.event.detail as { request?: PendingInputRequest } | undefined)?.request; + expect(request?.questions[0]?.options?.map((option) => option.value)).toEqual(["update_goal", "clear_goal"]); + + await service.respondToInput({ + sessionId: session.id, + itemId: approvalEvent.event.itemId, + decision: "accept", + answers: { + goal_action: "update_goal", + }, + }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => + payload.method === "thread/goal/set" + && (payload.params as { objective?: unknown } | undefined)?.objective === "Replacement goal" + )).toBe(true); + }); + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + }); + }); + it("automatically removes incoming Codex goal token limits and resumes limited goals", async () => { mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { const params = payload.params as Record; @@ -11478,7 +11601,7 @@ describe("createAgentChatService", () => { expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(false); }); - it("completes Codex /goal slash commands when the app-server RPC fails", async () => { + it("reports Codex /goal slash command failures without completing a fake slash turn", async () => { mockState.delayedCodexMethods.add("thread/goal/set"); const events: AgentChatEventEnvelope[] = []; const { service } = createService({ @@ -11518,11 +11641,46 @@ describe("createAgentChatService", () => { expect(events.some((event) => event.event.type === "status" && event.event.turnStatus === "completed" - )).toBe(true); + )).toBe(false); expect(events.some((event) => event.event.type === "done" && event.event.status === "completed" - )).toBe(true); + )).toBe(false); + }); + + it("routes Codex goal edits through goal RPC while a turn is active instead of turn steer", async () => { + mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { + const params = payload.params as Record; + return { + goal: { + objective: params.objective, + status: params.status ?? "active", + tokenBudget: null, + }, + }; + }); + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + await service.sendMessage({ + sessionId: session.id, + text: "Start a long-running turn.", + }, { awaitDispatch: true }); + + mockState.codexRequestPayloads = []; + await service.steer({ + sessionId: session.id, + text: "/goal set Updated from UI", + }); + + expect(mockState.codexRequestPayloads.find((payload) => payload.method === "thread/goal/set")?.params).toMatchObject({ + objective: "Updated from UI", + }); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/steer")).toBe(false); }); it("routes Codex /inject to thread/inject_items and emits a notice", async () => { diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index 9848e5601..ed2467a6f 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2201,8 +2201,10 @@ function normalizeCodexGoalPayload(value: unknown): CodexThreadGoal | null { const goalRecord = asRecord(record.goal) ?? record; const statusRaw = stringOrNull(goalRecord.status)?.toLowerCase() ?? null; const status: CodexThreadGoal["status"] = - statusRaw === "active" || statusRaw === "paused" || statusRaw === "complete" || statusRaw === "cancelled" + statusRaw === "active" || statusRaw === "paused" || statusRaw === "blocked" || statusRaw === "complete" || statusRaw === "cancelled" ? statusRaw + : statusRaw === "usagelimited" || statusRaw === "usage_limited" || statusRaw === "usage-limited" + ? "usage_limited" : statusRaw === "budgetlimited" || statusRaw === "budget_limited" || statusRaw === "budget-limited" ? "budget_limited" : statusRaw @@ -2220,6 +2222,66 @@ function normalizeCodexGoalPayload(value: unknown): CodexThreadGoal | null { return Object.values(normalized).some((entry) => entry != null) ? normalized : null; } +type CodexGoalSettableStatus = "active" | "paused" | "blocked" | "complete"; + +type ParsedCodexGoalSlashCommand = + | { kind: "show" } + | { kind: "clear" } + | { kind: "status"; status: CodexGoalSettableStatus } + | { kind: "objective"; objective: string; explicitSet: boolean } + | { kind: "invalid"; message: string }; + +function isCodexGoalSlashInput(value: string): boolean { + return /^\/goal(?:\s|$)/i.test(value.trim()); +} + +function normalizeCodexGoalObjectiveText(value: string | null | undefined): string { + return String(value ?? "").replace(/\s+/g, " ").trim(); +} + +function parseCodexGoalSlashCommand(value: string): ParsedCodexGoalSlashCommand { + const goalArgs = value.trim().replace(/^\/goal(?:\s+|$)/i, "").trim(); + if (!goalArgs || /^show$/i.test(goalArgs) || /^status$/i.test(goalArgs)) { + return { kind: "show" }; + } + if (/^(clear|reset|none)$/i.test(goalArgs)) { + return { kind: "clear" }; + } + + const statusMatch = /^status\s+(active|paused|blocked|complete)$/i.exec(goalArgs); + if (statusMatch) { + return { kind: "status", status: statusMatch[1]!.toLowerCase() as CodexGoalSettableStatus }; + } + if (/^status(?:\s|$)/i.test(goalArgs)) { + return { kind: "invalid", message: "Usage: /goal status active|paused|blocked|complete." }; + } + + const directStatusMatch = /^(pause|resume|block|complete)$/i.exec(goalArgs); + if (directStatusMatch) { + const rawStatus = directStatusMatch[1]!.toLowerCase(); + return { + kind: "status", + status: rawStatus === "pause" + ? "paused" + : rawStatus === "resume" + ? "active" + : rawStatus === "block" + ? "blocked" + : "complete", + }; + } + + const explicitSetMatch = /^set(?:\s+([\s\S]*))?$/i.exec(goalArgs); + if (explicitSetMatch) { + const objective = normalizeCodexGoalObjectiveText(explicitSetMatch[1] ?? ""); + return objective + ? { kind: "objective", objective, explicitSet: true } + : { kind: "invalid", message: "Usage: /goal ." }; + } + + return { kind: "objective", objective: normalizeCodexGoalObjectiveText(goalArgs), explicitSet: false }; +} + function normalizeCodexWebSearchAction(value: unknown): CodexWebSearchAction | null { if (typeof value === "string") { const action = value.trim(); @@ -9288,9 +9350,6 @@ export function createAgentChatService(args: { if (!managed.runtime || managed.runtime.kind !== "codex") { throw new Error(`Codex runtime is not available for session '${managed.session.id}'.`); } - if (managed.runtime.activeTurnId) { - throw new Error("A turn is already active. Use steer or interrupt."); - } const runtime = managed.runtime; const attachments = args.attachments ?? []; const contextAttachments = args.contextAttachments ?? []; @@ -9308,6 +9367,262 @@ export function createAgentChatService(args: { onDispatched = undefined; callback(); }; + const slashText = args.promptText.trim(); + + const emitCodexGoalNotice = ( + message: string, + noticeKind: "info" | "error" = "info", + ): void => { + emitChatEvent(managed, { + type: "system_notice", + noticeKind, + ...(noticeKind === "error" ? { severity: "error" as const } : {}), + message, + ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), + }); + }; + const completeCodexGoalControl = (message?: string, noticeKind: "info" | "error" = "info") => { + markDispatched(); + persistDeliveredLaneDirectiveKey(managed, args.laneDirectiveKey); + if (message) { + emitCodexGoalNotice(message, noticeKind); + } + persistChatState(managed); + }; + const requestCodexGoalControl = async ( + method: string, + params: Record, + failurePrefix: string, + ): Promise => { + try { + return await runtime.request(method, params, { + timeoutMs: CODEX_INLINE_COMMAND_TIMEOUT_MS, + }); + } catch (error) { + completeCodexGoalControl( + `${failurePrefix}: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + if (isCodexRequestTimeoutError(error)) { + teardownRuntime(managed, "handle_close"); + } + return null; + } + }; + const applyCodexGoalUpdateWithFallback = ( + value: unknown, + fallback: CodexThreadGoal | null, + ): CodexThreadGoal | null => { + const normalized = normalizeCodexGoalPayload(value); + if (normalized) { + return applyCodexGoalUpdate(managed, runtime, normalized); + } + managed.session.codexGoal = fallback; + emitChatEvent(managed, { + type: "codex_goal_updated", + goal: fallback, + ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), + }); + maybeClearCodexGoalBudget(managed, runtime, fallback, runtime.activeTurnId ?? undefined); + return fallback; + }; + const setCodexGoalObjective = async (objective: string): Promise => { + const response = await requestCodexGoalControl<{ goal?: unknown }>("thread/goal/set", { + threadId: managed.session.threadId, + objective, + status: "active", + }, "Codex goal command failed"); + if (!response) return null; + return applyCodexGoalUpdateWithFallback(response, { + objective, + status: "active", + tokenBudget: null, + }); + }; + const setCodexGoalStatus = async (status: CodexGoalSettableStatus): Promise => { + const previous = managed.session.codexGoal ?? null; + const response = await requestCodexGoalControl<{ goal?: unknown }>("thread/goal/set", { + threadId: managed.session.threadId, + status, + }, "Codex goal command failed"); + if (!response) return null; + return applyCodexGoalUpdateWithFallback(response, previous + ? { ...previous, status } + : { status }); + }; + const clearCodexGoal = async (): Promise => { + const response = await requestCodexGoalControl("thread/goal/clear", { + threadId: managed.session.threadId, + }, "Codex goal command failed"); + if (!response) return false; + managed.session.codexGoal = null; + emitChatEvent(managed, { + type: "codex_goal_cleared", + ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), + }); + return true; + }; + const startCodexGoalObjectiveTurn = async (objective: string, dispatched?: () => void): Promise => { + await sendCodexMessage(managed, { + promptText: objective, + userText: objective, + displayText: objective, + attachments, + contextAttachments, + resolvedAttachments, + metadata: args.metadata, + laneDirectiveKey: null, + providerSlashCommand: false, + optimisticCodexTurnStart: false, + ...(dispatched ? { onDispatched: dispatched } : {}), + }); + }; + const handleCodexGoalReplaceResponse = async ( + currentObjective: string, + nextObjective: string, + response: { decision: string; answers: Record; responseText: string | null }, + ): Promise => { + const answer = normalizeCodexGoalObjectiveText( + response.answers.goal_action?.[0] ?? response.responseText ?? "", + ).toLowerCase(); + if (response.decision !== "accept" && response.decision !== "accept_for_session") { + emitCodexGoalNotice("Keeping the current Codex goal."); + persistChatState(managed); + return; + } + if (answer === "clear_goal") { + if (await clearCodexGoal()) { + emitCodexGoalNotice("Codex goal cleared."); + persistChatState(managed); + } + return; + } + if (answer !== "update_goal") { + emitCodexGoalNotice("Keeping the current Codex goal."); + persistChatState(managed); + return; + } + const updatedGoal = await setCodexGoalObjective(nextObjective); + if (!updatedGoal) return; + emitCodexGoalNotice("Codex goal updated."); + persistChatState(managed); + if (!runtime.activeTurnId) { + const preparedGoalTurn = prepareSendMessage({ + sessionId: managed.session.id, + text: nextObjective, + displayText: nextObjective, + }); + if (!preparedGoalTurn) return; + void executePreparedSendMessage(preparedGoalTurn).catch((error) => { + logger.warn("agent_chat.codex_goal_followup_failed", { + sessionId: managed.session.id, + currentObjective, + error: error instanceof Error ? error.message : String(error), + }); + emitDispatchedSendFailure(preparedGoalTurn, error); + }); + } + }; + + if (isCodexGoalSlashInput(slashText)) { + const parsedGoalCommand = parseCodexGoalSlashCommand(slashText); + if (parsedGoalCommand.kind === "invalid") { + completeCodexGoalControl(parsedGoalCommand.message); + return; + } + if (parsedGoalCommand.kind === "show") { + const response = await requestCodexGoalControl<{ goal?: unknown }>("thread/goal/get", { + threadId: managed.session.threadId, + }, "Codex goal command failed"); + if (!response) return; + const goal = applyCodexGoalUpdate(managed, runtime, response); + completeCodexGoalControl(goal?.objective ? "Codex goal is current." : "No active Codex goal."); + return; + } + if (parsedGoalCommand.kind === "clear") { + if (await clearCodexGoal()) { + completeCodexGoalControl("Codex goal cleared."); + } + return; + } + if (parsedGoalCommand.kind === "status") { + const updatedGoal = await setCodexGoalStatus(parsedGoalCommand.status); + if (!updatedGoal) return; + completeCodexGoalControl(`Codex goal ${parsedGoalCommand.status === "active" ? "resumed" : parsedGoalCommand.status}.`); + return; + } + + const existingObjective = normalizeCodexGoalObjectiveText(managed.session.codexGoal?.objective); + const shouldConfirmReplacement = Boolean(existingObjective) + && existingObjective !== parsedGoalCommand.objective + && !parsedGoalCommand.explicitSet; + if (shouldConfirmReplacement) { + const responsePromise = requestChatInput({ + chatSessionId: managed.session.id, + title: "Replace Codex goal?", + body: `Current goal: ${existingObjective}\nNew goal: ${parsedGoalCommand.objective}`, + source: "codex", + kind: "structured_question", + allowsFreeform: false, + providerMetadata: { + kind: "codex_goal_replace", + currentObjective: existingObjective, + nextObjective: parsedGoalCommand.objective, + }, + eventDescription: "Codex goal replacement needs confirmation.", + questions: [{ + id: "goal_action", + header: "Goal", + question: "A Codex goal already exists. What should ADE do?", + allowsFreeform: false, + options: [ + { + label: "Update goal", + value: "update_goal", + description: "Replace the current goal and continue with the new one.", + recommended: true, + }, + { + label: "Clear goal", + value: "clear_goal", + description: "Remove the current goal without starting a new turn.", + }, + ], + }], + }); + completeCodexGoalControl(); + responsePromise + .then((response) => handleCodexGoalReplaceResponse( + existingObjective, + parsedGoalCommand.objective, + response, + )) + .catch((error) => { + logger.warn("agent_chat.codex_goal_replace_prompt_failed", { + sessionId: managed.session.id, + error: error instanceof Error ? error.message : String(error), + }); + emitCodexGoalNotice( + `Codex goal command failed: ${error instanceof Error ? error.message : String(error)}`, + "error", + ); + }); + return; + } + + const updatedGoal = await setCodexGoalObjective(parsedGoalCommand.objective); + if (!updatedGoal) return; + if (parsedGoalCommand.explicitSet || runtime.activeTurnId) { + completeCodexGoalControl("Codex goal updated."); + return; + } + await startCodexGoalObjectiveTurn(parsedGoalCommand.objective, markDispatched); + return; + } + + if (runtime.activeTurnId) { + throw new Error("A turn is already active. Use steer or interrupt."); + } setSessionActive(managed); if (!args.optimisticCodexTurnStart) { emitPreparedUserMessage(managed, { @@ -9450,7 +9765,6 @@ export function createAgentChatService(args: { return; } - const slashText = args.promptText.trim(); let effectivePromptText = args.promptText; const planSlashCommand = /^\/plan(?:\s|$)/i.test(slashText); @@ -9558,60 +9872,6 @@ export function createAgentChatService(args: { return; } - if (/^\/goal(?:\s|$)/i.test(slashText)) { - const goalArgs = slashText.replace(/^\/goal(?:\s+|$)/i, "").trim(); - if (!goalArgs || /^show$/i.test(goalArgs) || /^status$/i.test(goalArgs)) { - const response = await requestInlineCodexSlash<{ goal?: unknown }>("thread/goal/get", { - threadId: managed.session.threadId, - }, "Codex goal command failed"); - if (!response.ok) return; - const goal = applyCodexGoalUpdate(managed, runtime, response.result); - completeInlineCodexSlash(goal?.objective ? "Codex goal is current." : "No active Codex goal."); - return; - } - if (/^(clear|reset|none)$/i.test(goalArgs)) { - const response = await requestInlineCodexSlash("thread/goal/clear", { - threadId: managed.session.threadId, - }, "Codex goal command failed"); - if (!response.ok) return; - managed.session.codexGoal = null; - emitChatEvent(managed, { type: "codex_goal_cleared" }); - completeInlineCodexSlash("Codex goal cleared."); - return; - } - const statusMatch = /^status\s+(active|paused|complete)$/i.exec(goalArgs); - const pauseResumeMatch = /^(pause|resume)$/i.exec(goalArgs); - if (/^status(?:\s|$)/i.test(goalArgs) && !statusMatch) { - completeInlineCodexSlash("Usage: /goal status active|paused|complete."); - return; - } - if (statusMatch || pauseResumeMatch) { - const rawStatus = (statusMatch?.[1] ?? pauseResumeMatch?.[1] ?? "active").toLowerCase(); - const status = rawStatus === "pause" ? "paused" : rawStatus === "resume" ? "active" : rawStatus; - const response = await requestInlineCodexSlash<{ goal?: unknown }>("thread/goal/set", { - threadId: managed.session.threadId, - status, - }, "Codex goal command failed"); - if (!response.ok) return; - applyCodexGoalUpdate(managed, runtime, response.result); - completeInlineCodexSlash(`Codex goal ${status === "active" ? "resumed" : status}.`); - return; - } - const objective = goalArgs.replace(/^set\s+/i, "").trim(); - if (!objective) { - completeInlineCodexSlash("No Codex goal text was provided."); - return; - } - const response = await requestInlineCodexSlash<{ goal?: unknown }>("thread/goal/set", { - threadId: managed.session.threadId, - objective, - }, "Codex goal command failed"); - if (!response.ok) return; - applyCodexGoalUpdate(managed, runtime, response.result); - completeInlineCodexSlash("Codex goal updated."); - return; - } - const suppressTurnContext = providerSlashCommand && !planSlashCommand; const input: Array> = []; @@ -16766,9 +17026,15 @@ export function createAgentChatService(args: { ), contextAttachmentPrompt || null, ]); - const autoTitleSeed = providerSlashCommand + const codexGoalTitleSeed = managed.session.provider === "codex" && isCodexGoalSlashInput(trimmed) + ? (() => { + const parsed = parseCodexGoalSlashCommand(trimmed); + return parsed.kind === "objective" ? parsed.objective : null; + })() + : null; + const autoTitleSeed = codexGoalTitleSeed ?? (providerSlashCommand ? expandedSlashCommandPrompt ?? null - : visibleText; + : visibleText); if (!managed.autoTitleSeed && autoTitleSeed) { managed.autoTitleSeed = autoTitleSeed; void maybeAutoTitleSession(managed, { @@ -19734,7 +20000,10 @@ export function createAgentChatService(args: { // acknowledged the prompt. } - if (prepared.managed.session.provider === "codex") { + const isCodexGoalControlMessage = + prepared.managed.session.provider === "codex" + && isCodexGoalSlashInput(prepared.submittedText); + if (prepared.managed.session.provider === "codex" && !isCodexGoalControlMessage) { prepared.optimisticCodexTurnStart = true; emitPreparedUserMessage(prepared.managed, { text: prepared.submittedText, @@ -19983,6 +20252,10 @@ export function createAgentChatService(args: { if (!preparedSteer) { return { steerId, queued: false }; } + if (isCodexGoalSlashInput(trimmed)) { + await executePreparedSendMessage(preparedSteer); + return { steerId, queued: false }; + } if (!managed.session.threadId || !runtime.activeTurnId) { await executePreparedSendMessage(preparedSteer); return { steerId, queued: false }; diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index f8ee14e0c..9e4470389 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -2021,6 +2021,10 @@ function isNoActiveTurnToSteerError(error: unknown): boolean { return /no active turn to steer/i.test(errorMessage(error)); } +function isCodexGoalSlashInput(value: string): boolean { + return /^\/goal(?:\s|$)/i.test(value.trim()); +} + export function formatParallelLaunchFailureMessage(args: { launchError: string; cleanupIssues: ParallelLaunchCleanupIssue[]; @@ -2920,6 +2924,7 @@ export function AgentChatPane({ const sendCodexControlMessage = useCallback(async (sessionId: string, text: string) => { setError(null); try { + const isGoalControlMessage = isCodexGoalSlashInput(text); const steerControlMessage = async () => { await window.ade.agentChat.steer({ sessionId, text }); }; @@ -2927,7 +2932,7 @@ export function AgentChatPane({ try { await window.ade.agentChat.send({ sessionId, text }); } catch (sendError) { - if (isTurnAlreadyActiveError(sendError)) { + if (!isGoalControlMessage && isTurnAlreadyActiveError(sendError)) { setError(null); try { await steerControlMessage(); @@ -2942,7 +2947,7 @@ export function AgentChatPane({ } }; - if (turnActiveBySession[sessionId]) { + if (turnActiveBySession[sessionId] && !isGoalControlMessage) { try { await steerControlMessage(); } catch (steerError) { @@ -6605,6 +6610,8 @@ export function AgentChatPane({ const draftSnapshot = draft; const attachmentsSnapshot = attachments; const isLiteralSlashCommand = isProviderSlashCommandInput(text); + const isCodexGoalSlashCommand = sessionProvider === "codex" && isCodexGoalSlashInput(text); + const suppressOptimisticOutgoing = isCodexGoalSlashCommand; const deferComposerClear = selectedSessionId == null; submitInFlightRef.current = true; @@ -6631,7 +6638,7 @@ export function AgentChatPane({ : contextAttachmentsSnapshot.length ? "Attached issue context" : text; - if (selectedSessionId && !turnActiveBySession[selectedSessionId]) { + if (selectedSessionId && !turnActiveBySession[selectedSessionId] && !suppressOptimisticOutgoing) { setOptimisticOutgoingMessageSynced({ sessionId: selectedSessionId, envelope: { @@ -6689,6 +6696,10 @@ export function AgentChatPane({ deliveryState: "queued", }, }); + const setOptimisticIfAllowed = (nextSessionId: string) => { + if (suppressOptimisticOutgoing) return; + setOptimisticOutgoingMessageSynced({ sessionId: nextSessionId, envelope: optimisticEnvelope(nextSessionId) }); + }; if (sessionId && !turnActive && ( selectedModelChanged @@ -6696,7 +6707,7 @@ export function AgentChatPane({ || hasComputerUseSelectionChanged || shouldPromoteLightSession )) { - setOptimisticOutgoingMessageSynced({ sessionId, envelope: optimisticEnvelope(sessionId) }); + setOptimisticIfAllowed(sessionId); const desc = resolveModelDescriptorWithRuntimeCatalog(modelId) ?? getModelById(modelId); const provider = resolveChatRuntimeProvider(desc); await window.ade.agentChat.updateSession({ @@ -6714,7 +6725,7 @@ export function AgentChatPane({ throw new Error("Unable to create chat session."); } justCreatedSession = true; - setOptimisticOutgoingMessageSynced({ sessionId, envelope: optimisticEnvelope(sessionId) }); + setOptimisticIfAllowed(sessionId); } if (!sessionId) { throw new Error("Unable to create chat session."); @@ -6746,7 +6757,7 @@ export function AgentChatPane({ const sendMessageOrSteerIfBusy = async (retryOnStaleSteer = true) => { try { - setOptimisticOutgoingMessageSynced({ sessionId, envelope: optimisticEnvelope(sessionId) }); + setOptimisticIfAllowed(sessionId); const sendInteractionMode: AgentChatInteractionMode | null = sessionProvider === "claude" ? ( @@ -6770,7 +6781,7 @@ export function AgentChatPane({ // Race condition: the turn may have started between our state check // and the backend call. If so, automatically fall back to steer // instead of surfacing a confusing error to the user. - if (isTurnAlreadyActiveError(sendError)) { + if (!isCodexGoalSlashCommand && isTurnAlreadyActiveError(sendError)) { try { await steerMessage(); } catch (steerError) { @@ -6784,7 +6795,7 @@ export function AgentChatPane({ } }; - if (turnActiveBySession[sessionId]) { + if (turnActiveBySession[sessionId] && !isCodexGoalSlashCommand) { setOptimisticOutgoingMessageSynced(null); try { await steerMessage(); @@ -6859,6 +6870,7 @@ export function AgentChatPane({ selectedSession, selectedSessionId, selectedSessionModelId, + setOptimisticOutgoingMessageSynced, sessionProvider, cursorRuntime, touchSession, diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx index ebbec136c..23a761f68 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalBanner.tsx @@ -36,6 +36,10 @@ function statusPillClass(status: CodexThreadGoal["status"]): string { return "bg-fg/8 text-fg/55 ring-1 ring-inset ring-fg/15"; case "cancelled": return "bg-fg/8 text-fg/45 ring-1 ring-inset ring-fg/15"; + case "blocked": + return "bg-rose-500/12 text-rose-100 ring-1 ring-inset ring-rose-400/25"; + case "usage_limited": + return "bg-sky-500/12 text-sky-100 ring-1 ring-inset ring-sky-400/25"; case "budget_limited": case "active": default: @@ -46,6 +50,7 @@ function statusPillClass(status: CodexThreadGoal["status"]): string { function statusLabel(status: CodexThreadGoal["status"]): string { if (!status || status === "unknown") return "active"; if (status === "budget_limited") return "active"; + if (status === "usage_limited") return "usage hit"; return status.replace("_", " "); } diff --git a/apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.tsx b/apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.tsx index c48f13457..c768d8e63 100644 --- a/apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.tsx +++ b/apps/desktop/src/renderer/components/chat/codex/CodexGoalCard.tsx @@ -47,6 +47,20 @@ function statusTone( dot: "bg-fg/50", label: "paused", }; + case "blocked": + return { + pill: "bg-rose-500/12 text-rose-100 ring-1 ring-inset ring-rose-400/30", + rail: "bg-rose-400/60", + dot: "bg-rose-300/90", + label: "blocked", + }; + case "usage_limited": + return { + pill: "bg-sky-500/12 text-sky-100 ring-1 ring-inset ring-sky-400/30", + rail: "bg-sky-400/60", + dot: "bg-sky-300/90", + label: "usage hit", + }; case "cancelled": return { pill: "bg-fg/8 text-fg/45 ring-1 ring-inset ring-fg/15", From 961402bab7abcf9036dff806bc66ab2228b71e09 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 29 May 2026 00:19:00 -0400 Subject: [PATCH 4/5] ship: iteration 1 address review feedback --- .../services/chat/agentChatService.test.ts | 80 +++++++++++++++++++ .../main/services/chat/agentChatService.ts | 56 +++++++++++-- .../src/main/services/files/fileService.ts | 5 +- .../renderer/components/files/FilesPage.tsx | 5 ++ 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index d2050be02..693464e36 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -11648,6 +11648,53 @@ describe("createAgentChatService", () => { )).toBe(false); }); + it("reports Codex /goal slash timeouts without tearing down the runtime", async () => { + mockState.delayedCodexMethods.add("thread/goal/set"); + const processKillSpy = vi.spyOn(process, "kill").mockImplementation(() => true as any); + vi.useFakeTimers(); + try { + const events: AgentChatEventEnvelope[] = []; + const { service } = createService({ + onEvent: (event: AgentChatEventEnvelope) => { + events.push(event); + }, + }); + const session = await service.createSession({ + laneId: "lane-1", + provider: "codex", + model: "gpt-5.5", + }); + + const sendPromise = service.sendMessage({ + sessionId: session.id, + text: "/goal status paused", + }, { awaitDispatch: true }); + + await vi.waitFor(() => { + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/goal/set")).toBe(true); + }); + await vi.advanceTimersByTimeAsync(10_050); + await sendPromise; + + expect(events.some((event) => + event.event.type === "system_notice" + && event.event.message.includes("timed out") + )).toBe(true); + expect(processKillSpy).not.toHaveBeenCalled(); + + mockState.delayedCodexMethods.clear(); + mockState.codexRequestPayloads = []; + await service.sendMessage({ + sessionId: session.id, + text: "Continue after the slash timeout.", + }, { awaitDispatch: true }); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + } finally { + vi.useRealTimers(); + processKillSpy.mockRestore(); + } + }); + it("routes Codex goal edits through goal RPC while a turn is active instead of turn steer", async () => { mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { const params = payload.params as Record; @@ -14777,6 +14824,39 @@ describe("createAgentChatService", () => { expect(message.content[1]?.type).toBe("image"); expect((message.content[1]?.source as Record).type).toBe("base64"); }); + + it("omits large Cursor SDK file attachments without reading the full file", async () => { + process.env.CURSOR_API_KEY = "cursor-test-key"; + const { service } = createService(); + const session = await service.createSession({ + laneId: "lane-1", + provider: "cursor", + model: "composer-2", + modelId: "cursor/composer-2", + }); + + const largePath = path.join(tmpRoot, "large-context.txt"); + const largeContent = `${"x".repeat(512 * 1024 + 1)}large-tail-marker`; + fs.writeFileSync(largePath, largeContent); + + const readFileSpy = vi.spyOn(fs, "readFileSync"); + let readFileCalls: unknown[][] = []; + try { + await service.runSessionTurn({ + sessionId: session.id, + text: "Use this large file", + attachments: [{ path: largePath, type: "file" }], + }); + readFileCalls = [...readFileSpy.mock.calls]; + } finally { + readFileSpy.mockRestore(); + } + + expect(readFileCalls.some(([target]) => typeof target === "number")).toBe(false); + const payloadText = String(mockState.cursorSdkSendCalls.at(-1)?.promptText ?? ""); + expect(payloadText).toContain(`[File: ${largePath} omitted: size ${largeContent.length} bytes]`); + expect(payloadText).not.toContain("large-tail-marker"); + }); }); // -------------------------------------------------------------------------- diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index ed2467a6f..8ea038b36 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -4826,6 +4826,35 @@ export function createAgentChatService(args: { resolvedPath: attachment._resolvedPath, getDirtyFileTextForPath, }); + const readDirtyResolvedAttachmentBytes = async ( + attachment: ResolvedAgentChatFileRef, + ): Promise => { + let absPath: string; + try { + absPath = resolvePathWithinRoot(path.resolve(attachment._rootPath), attachment._resolvedPath, { + allowMissing: false, + }); + } catch { + return null; + } + try { + const dirty = await Promise.resolve(getDirtyFileTextForPath(absPath)); + return typeof dirty === "string" ? Buffer.from(dirty, "utf8") : null; + } catch { + return null; + } + }; + const resolvedAttachmentDiskSize = (attachment: ResolvedAgentChatFileRef): number | null => { + try { + const absPath = resolvePathWithinRoot(path.resolve(attachment._rootPath), attachment._resolvedPath, { + allowMissing: false, + }); + const stat = fs.statSync(absPath); + return stat.isFile() ? stat.size : null; + } catch { + return null; + } + }; if (!issueInventoryService) { throw new Error("Issue inventory service is required to initialize agent chat."); } @@ -9403,9 +9432,6 @@ export function createAgentChatService(args: { `${failurePrefix}: ${error instanceof Error ? error.message : String(error)}`, "error", ); - if (isCodexRequestTimeoutError(error)) { - teardownRuntime(managed, "handle_close"); - } return null; } }; @@ -9686,9 +9712,6 @@ export function createAgentChatService(args: { }; } catch (error) { completeFailedInlineCodexSlash(failurePrefix, error); - if (isCodexRequestTimeoutError(error)) { - teardownRuntime(managed, "handle_close"); - } return { ok: false }; } }; @@ -17837,7 +17860,26 @@ export function createAgentChatService(args: { > = [{ type: "text", text: promptText }]; for (const attachment of resolvedAttachments) { try { - const buf = await readResolvedAttachmentBytes(attachment); + let buf: Buffer; + if (attachment.type === "image") { + buf = await readResolvedAttachmentBytes(attachment); + } else { + const dirtyBuf = await readDirtyResolvedAttachmentBytes(attachment); + if (dirtyBuf) { + buf = dirtyBuf; + } else { + const fileSize = resolvedAttachmentDiskSize(attachment); + if (fileSize == null) continue; + if (fileSize > MAX_INLINE_BYTES) { + blocks.push({ + type: "text", + text: `[File: ${attachment.path} omitted: size ${fileSize} bytes]`, + }); + continue; + } + buf = readFileWithinRootSecure(attachment._rootPath, attachment._resolvedPath); + } + } if (attachment.type === "image") { blocks.push({ diff --git a/apps/desktop/src/main/services/files/fileService.ts b/apps/desktop/src/main/services/files/fileService.ts index ee4b600ca..7ad5c69d3 100644 --- a/apps/desktop/src/main/services/files/fileService.ts +++ b/apps/desktop/src/main/services/files/fileService.ts @@ -404,7 +404,7 @@ export function createFileService({ gitStatusCache.set(rootPath, { fetchedAt: 0, snapshot: previous.snapshot, - inFlight: previous.inFlight, + inFlight: null, }); }; @@ -526,6 +526,9 @@ export function createFileService({ .catch(() => emptyGitStatusSnapshot) .then((snapshot) => { const current = gitStatusCache.get(rootPath); + if (!opts.forceFresh && current && current.inFlight !== inFlight) { + return current.snapshot; + } if (!opts.forceFresh && current && current.fetchedAt > startedAt) { return current.snapshot; } diff --git a/apps/desktop/src/renderer/components/files/FilesPage.tsx b/apps/desktop/src/renderer/components/files/FilesPage.tsx index 9d286d40f..b014fb751 100644 --- a/apps/desktop/src/renderer/components/files/FilesPage.tsx +++ b/apps/desktop/src/renderer/components/files/FilesPage.tsx @@ -176,6 +176,7 @@ const MAX_FILES_TREE_CACHED_WORKSPACES = 32; const MAX_CACHED_CLEAN_TAB_CHARS = 256 * 1024; const MAX_QUEUED_TREE_PARENT_REFRESHES = 24; const FILES_WATCH_START_DELAY_MS = import.meta.env.MODE === "test" || (window as any).__adeBrowserMock ? 0 : 2_000; +const FILES_GIT_DECORATION_REFRESH_DELAY_MS = 2_500; function filesSessionKey(projectRoot: string, laneId: string | null): string { return `${projectRoot}::${laneId ?? "__primary__"}`; @@ -1504,6 +1505,10 @@ export function FilesPage({ treeRefreshStateRef.current.queuedParents.clear(); setLoadingDirectories(new Set()); void refreshTree(); + const decorationRefreshTimer = window.setTimeout(() => { + void refreshTree(); + }, FILES_GIT_DECORATION_REFRESH_DELAY_MS); + return () => window.clearTimeout(decorationRefreshTimer); }, [active, projectRootPath, workspaceId, refreshTree]); useEffect(() => { From 8df5c04a70b86b598ff8aaf86c2007361c80d6a1 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 29 May 2026 00:30:55 -0400 Subject: [PATCH 5/5] ship: iteration 2 fix shard test isolation --- apps/desktop/src/main/services/chat/agentChatService.test.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/main/services/chat/agentChatService.test.ts b/apps/desktop/src/main/services/chat/agentChatService.test.ts index 693464e36..4c854d418 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.test.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.test.ts @@ -11650,7 +11650,6 @@ describe("createAgentChatService", () => { it("reports Codex /goal slash timeouts without tearing down the runtime", async () => { mockState.delayedCodexMethods.add("thread/goal/set"); - const processKillSpy = vi.spyOn(process, "kill").mockImplementation(() => true as any); vi.useFakeTimers(); try { const events: AgentChatEventEnvelope[] = []; @@ -11680,7 +11679,6 @@ describe("createAgentChatService", () => { event.event.type === "system_notice" && event.event.message.includes("timed out") )).toBe(true); - expect(processKillSpy).not.toHaveBeenCalled(); mockState.delayedCodexMethods.clear(); mockState.codexRequestPayloads = []; @@ -11689,9 +11687,10 @@ describe("createAgentChatService", () => { text: "Continue after the slash timeout.", }, { awaitDispatch: true }); expect(mockState.codexRequestPayloads.some((payload) => payload.method === "turn/start")).toBe(true); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/start")).toBe(false); + expect(mockState.codexRequestPayloads.some((payload) => payload.method === "thread/resume")).toBe(false); } finally { vi.useRealTimers(); - processKillSpy.mockRestore(); } });