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..d2f619dd8 100644 --- a/apps/desktop/src/main/services/ai/tools/readFileRange.ts +++ b/apps/desktop/src/main/services/ai/tools/readFileRange.ts @@ -2,13 +2,22 @@ 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, + 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 +49,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..4c854d418 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({ @@ -11225,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; @@ -11436,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({ @@ -11476,11 +11641,92 @@ 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("reports Codex /goal slash timeouts without tearing down the runtime", async () => { + mockState.delayedCodexMethods.add("thread/goal/set"); + 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); + + 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); + 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(); + } + }); + + 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 () => { @@ -14577,6 +14823,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"); + }); }); // -------------------------------------------------------------------------- @@ -14660,11 +14939,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", @@ -14700,6 +14979,7 @@ describe("createAgentChatService", () => { isCliWrapped: false, harnessProfile: "verified", } as any, + getDirtyFileTextForPath: () => "remember unsaved edits", logger: createLogger() as any, }); @@ -14708,6 +14988,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 67de1141c..8ea038b36 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, @@ -470,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"; @@ -545,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; @@ -1808,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 @@ -2175,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 @@ -2194,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(); @@ -3105,15 +3193,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 +3220,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 +3274,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 +3302,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 +4818,43 @@ 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, + }); + 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."); } @@ -4777,8 +4915,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 +8559,7 @@ export function createAgentChatService(args: { managed: ManagedChatSession, ): UniversalToolSetOptions => ({ permissionMode: toHarnessPermissionMode(managed.session.permissionMode), + getDirtyFileTextForPath, getTodoItems: () => managed.todoItems, onTodoUpdate: (items) => { emitChatEvent(managed, { @@ -8713,7 +8852,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, @@ -9237,9 +9379,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 ?? []; @@ -9257,6 +9396,259 @@ 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", + ); + 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, { @@ -9312,7 +9704,12 @@ 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); return { ok: false }; @@ -9391,7 +9788,6 @@ export function createAgentChatService(args: { return; } - const slashText = args.promptText.trim(); let effectivePromptText = args.promptText; const planSlashCommand = /^\/plan(?:\s|$)/i.test(slashText); @@ -9499,60 +9895,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> = []; @@ -9601,7 +9943,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 +10387,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(); @@ -12980,6 +13323,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, @@ -14469,9 +14863,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, @@ -14484,8 +14878,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) => { @@ -14563,10 +14996,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, @@ -14594,10 +15024,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, { @@ -16622,9 +17049,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, { @@ -16682,7 +17115,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); @@ -17404,34 +17851,44 @@ 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; + 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") { - 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 +17905,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 +18415,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 +18757,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 +19572,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) @@ -19585,7 +20042,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, @@ -19834,6 +20294,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 }; @@ -19859,7 +20323,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 +20522,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; @@ -20276,14 +20741,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, @@ -20294,7 +20771,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; @@ -21798,7 +22291,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); @@ -21816,7 +22309,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, @@ -21834,7 +22335,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/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/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..7ad5c69d3 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: null, + }); }; 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,61 @@ 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.inFlight !== inFlight) { + return current.snapshot; + } + 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 +681,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 d9b3bb1fa..cba941536 100644 --- a/apps/desktop/src/main/services/shared/utils.ts +++ b/apps/desktop/src/main/services/shared/utils.ts @@ -460,6 +460,41 @@ 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 = 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, absPath); +} + export function readFileWithinRootSecure(root: string, candidate: string): Buffer { let expectedPath: string; try { 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/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", 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..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__"}`; @@ -216,6 +217,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 +639,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 +660,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 +786,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 +1048,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 +1105,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 +1126,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,10 +1499,16 @@ 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(); + const decorationRefreshTimer = window.setTimeout(() => { + void refreshTree(); + }, FILES_GIT_DECORATION_REFRESH_DELAY_MS); + return () => window.clearTimeout(decorationRefreshTimer); }, [active, projectRootPath, workspaceId, refreshTree]); useEffect(() => { @@ -1946,9 +2009,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 +2047,7 @@ export function FilesPage({ compact={embedded} tree={tree} expanded={expanded} + loadingDirectories={loadingDirectories} selectedNodePath={selectedTreeNodePath} activeTabPath={activeTabPath} activeContextDir={activeContextDir} @@ -2295,6 +2375,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";