diff --git a/apps/ade-cli/src/cli.ts b/apps/ade-cli/src/cli.ts index 394ef6dd9..17a50b844 100644 --- a/apps/ade-cli/src/cli.ts +++ b/apps/ade-cli/src/cli.ts @@ -10643,6 +10643,11 @@ function formatDiagnosticError(error: unknown): string { } function installRuntimeProcessErrorBoundary(label: string): () => void { + const rejectionWindowMs = 60_000; + const rejectionLogLimit = 5; + let rejectionWindowStart = 0; + let rejectionCount = 0; + let suppressedRejectionCount = 0; const write = (kind: string, error: unknown): void => { try { process.stderr.write(`${label} contained ${kind}: ${formatDiagnosticError(error)}\n`); @@ -10651,8 +10656,28 @@ function installRuntimeProcessErrorBoundary(label: string): () => void { } }; const onUnhandledRejection = (reason: unknown): void => { - write("fatal unhandled rejection", reason); - process.exit(1); + const now = Date.now(); + if (now - rejectionWindowStart > rejectionWindowMs) { + if (suppressedRejectionCount > 0) { + write("unhandled rejection summary", `${suppressedRejectionCount} additional rejection(s) suppressed`); + } + rejectionWindowStart = now; + rejectionCount = 0; + suppressedRejectionCount = 0; + } + rejectionCount += 1; + // A single late async rejection must not tear down the project runtime: + // this process owns active Work chats, PTYs, and managed processes. + // JSON-RPC dispatch already returns per-request errors; anything that + // still reaches here is logged for diagnosis while the runtime stays up. + if (rejectionCount <= rejectionLogLimit) { + write("unhandled rejection", reason); + return; + } + suppressedRejectionCount += 1; + if (rejectionCount === rejectionLogLimit + 1) { + write("unhandled rejection rate limit", `suppressing additional rejections for ${rejectionWindowMs}ms`); + } }; const onUncaughtException = (error: Error): void => { write("fatal uncaught exception", error); diff --git a/apps/desktop/src/main/services/adeActions/registry.test.ts b/apps/desktop/src/main/services/adeActions/registry.test.ts index ee41e09b6..54447351a 100644 --- a/apps/desktop/src/main/services/adeActions/registry.test.ts +++ b/apps/desktop/src/main/services/adeActions/registry.test.ts @@ -207,8 +207,9 @@ describe("ADE_ACTION_ALLOWLIST shape", () => { expect(actions).toContain("getDelta"); }); - it("exposes computer_use_artifacts.readArtifactPreview for runtime-backed proof previews", () => { + it("exposes computer-use backend status and artifact preview reads for runtime-backed proof flows", () => { const actions = ADE_ACTION_ALLOWLIST.computer_use_artifacts ?? []; + expect(actions).toContain("getBackendStatus"); expect(actions).toContain("readArtifactPreview"); }); @@ -687,9 +688,13 @@ describe("runtime session actions", () => { }); describe("runtime computer-use artifact actions", () => { - it("exposes artifact preview reads from the broker", async () => { + it("exposes backend status and artifact preview reads from the broker", async () => { + const backendStatus = { + backends: [], + localFallback: { available: true, detail: "available", supportedKinds: ["screenshot"] }, + }; const broker = { - getBackendStatus: vi.fn(), + getBackendStatus: vi.fn(() => backendStatus), ingest: vi.fn(), listArtifacts: vi.fn(), readArtifactPreview: vi.fn(async () => "data:image/png;base64,AAAA"), @@ -700,11 +705,15 @@ describe("runtime computer-use artifact actions", () => { computerUseArtifactBrokerService: broker, } as unknown as Parameters[0]; const artifactService = getAdeActionDomainServices(runtime).computer_use_artifacts as { + getBackendStatus: () => unknown; readArtifactPreview: (args: { uri: string }) => Promise; } & Record; + expect(listAllowedAdeActionNames("computer_use_artifacts", artifactService)).toContain("getBackendStatus"); expect(listAllowedAdeActionNames("computer_use_artifacts", artifactService)).toContain("readArtifactPreview"); + expect(artifactService.getBackendStatus()).toBe(backendStatus); await expect(artifactService.readArtifactPreview({ uri: ".ade/artifacts/a.png" })).resolves.toBe("data:image/png;base64,AAAA"); + expect(broker.getBackendStatus).toHaveBeenCalledTimes(1); expect(broker.readArtifactPreview).toHaveBeenCalledWith({ uri: ".ade/artifacts/a.png" }); }); }); diff --git a/apps/desktop/src/main/services/adeActions/registry.ts b/apps/desktop/src/main/services/adeActions/registry.ts index b1d78113d..327d162ed 100644 --- a/apps/desktop/src/main/services/adeActions/registry.ts +++ b/apps/desktop/src/main/services/adeActions/registry.ts @@ -677,7 +677,7 @@ export const ADE_ACTION_ALLOWLIST: Partial { expect(result).not.toBeNull(); expect(result).toContain("Computer Use"); expect(result).toContain("get_computer_use_backend_status"); + expect(result).toContain("If it is not exposed, do not stall"); + expect(result).toContain("Respect the backend the user requested"); }); it("includes Ghost OS section when Ghost OS backend is available", () => { @@ -11876,6 +11878,191 @@ describe("createAgentChatService", () => { expect((await service.getSessionSummary(session.id))?.codexGoal).toBeNull(); }); + it("does not emit a visible Codex goal-clear event when no goal was known", async () => { + 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: "Start a normal turn.", + }, { awaitDispatch: true }); + events.length = 0; + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "thread/goal/cleared", + params: { threadId: "thread-1" }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(events.some((event) => event.event.type === "codex_goal_cleared")).toBe(false); + expect((await service.getSessionSummary(session.id))?.codexGoal).toBeNull(); + }); + + it("emits a Codex goal-clear event when a known goal is cleared by app-server", async () => { + 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.setCodexGoal({ + sessionId: session.id, + objective: "Ship CLI parity", + }); + events.length = 0; + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "thread/goal/cleared", + params: { threadId: "thread-1" }, + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "codex_goal_cleared", + ); + expect((await service.getSessionSummary(session.id))?.codexGoal).toBeNull(); + }); + + it("deduplicates repeated Codex goal updates while retaining latest usage state", async () => { + 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: "Start working.", + }, { awaitDispatch: true }); + events.length = 0; + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "thread/goal/updated", + params: { + threadId: "thread-1", + turnId: "turn-1", + goal: { + objective: "Ship CLI parity", + status: "active", + tokenBudget: null, + tokensUsed: 25, + updatedAt: 1_760_000_001, + }, + }, + }); + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "codex_goal_updated" + && event.event.goal?.objective === "Ship CLI parity", + ); + events.length = 0; + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "thread/goal/updated", + params: { + threadId: "thread-1", + turnId: "turn-1", + goal: { + objective: "Ship CLI parity", + status: "active", + tokenBudget: null, + tokensUsed: 50, + timeUsedSeconds: 12, + updatedAt: 1_760_000_002, + }, + }, + }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(events.some((event) => event.event.type === "codex_goal_updated")).toBe(false); + expect((await service.getSessionSummary(session.id))?.codexGoal).toMatchObject({ + objective: "Ship CLI parity", + status: "active", + tokenBudget: null, + tokensUsed: 50, + timeUsedSeconds: 12, + }); + + mockState.emitCodexPayload({ + jsonrpc: "2.0", + method: "thread/goal/updated", + params: { + threadId: "thread-1", + turnId: "turn-1", + goal: { + objective: "Ship CLI parity", + status: "paused", + tokenBudget: null, + tokensUsed: 51, + updatedAt: 1_760_000_003, + }, + }, + }); + + await waitForEvent( + events, + (event): event is AgentChatEventEnvelope => + event.event.type === "codex_goal_updated" + && event.event.goal?.status === "paused", + ); + }); + + it("refreshes a missing Codex goal without emitting a misleading goal-update chip", async () => { + mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { + const params = payload.params as Record; + return { + goal: { + objective: params.objective, + status: "active", + tokenBudget: null, + }, + }; + }); + mockState.codexResponseOverrides.set("thread/goal/get", () => ({ + goal: 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.setCodexGoal({ + sessionId: session.id, + objective: "Ship CLI parity", + }); + events.length = 0; + + await expect(service.getCodexGoal({ sessionId: session.id })).resolves.toBeNull(); + + expect(events.some((event) => event.event.type === "codex_goal_updated")).toBe(false); + expect(events.some((event) => event.event.type === "codex_goal_cleared")).toBe(false); + expect((await service.getSessionSummary(session.id))?.codexGoal).toBeNull(); + }); + it("clears persisted Codex goals after restart by resuming the thread first", async () => { mockState.codexResponseOverrides.set("thread/goal/set", (payload) => { const params = payload.params as Record; diff --git a/apps/desktop/src/main/services/chat/agentChatService.ts b/apps/desktop/src/main/services/chat/agentChatService.ts index f1b91a91a..727297b11 100644 --- a/apps/desktop/src/main/services/chat/agentChatService.ts +++ b/apps/desktop/src/main/services/chat/agentChatService.ts @@ -2358,6 +2358,30 @@ function normalizeCodexGoalObjectiveText(value: string | null | undefined): stri return String(value ?? "").replace(/\s+/g, " ").trim(); } +type CodexGoalVisibleState = { + objective: string; + status: CodexThreadGoal["status"] | null; + tokenBudget: number | null; +}; + +function codexGoalVisibleState(goal: CodexThreadGoal | null): CodexGoalVisibleState | null { + const normalized = normalizeAdeCodexGoal(goal); + if (!normalized) return null; + return { + objective: normalizeCodexGoalObjectiveText(normalized.objective), + status: normalized.status ?? null, + tokenBudget: normalized.tokenBudget ?? null, + }; +} + +function codexGoalVisibleStatesEqual(left: CodexGoalVisibleState | null, right: CodexGoalVisibleState | null): boolean { + if (left === right) return true; + if (!left || !right) return false; + return left.objective === right.objective + && left.status === right.status + && left.tokenBudget === right.tokenBudget; +} + function validateCodexGoalObjectiveText(value: string | null | undefined): string { const objective = normalizeCodexGoalObjectiveText(value); if (!objective) { @@ -3620,7 +3644,8 @@ export function buildComputerUseDirective( "When the user asks for proof, capture visual proof first. Console logs and text files are supporting diagnostics only; do not use them as the only proof unless the user explicitly asks for logs or visual capture fails and you say so.", "ADE will automatically capture screenshots and other visual artifacts from your computer-use tool calls into the proof drawer — you do not need to manually call ingest_computer_use_artifacts for normal captures.", "", - "Call `get_computer_use_backend_status` to check available backends before attempting computer use.", + "If `get_computer_use_backend_status` is exposed in your current tool list, call it to check available backends before attempting computer use. If it is not exposed, do not stall; use the available computer-use, browser, app-control, or ADE CLI status tools and clearly report any missing backend-status visibility.", + "Respect the backend the user requested. If that backend is unavailable or hangs, stop and report the block instead of silently switching to a different backend.", "When the user asks you to send proof, register the resulting artifact with ADE via `ade proof ...` or `ingest_computer_use_artifacts` so it appears in the active proof drawer.", ].join("\n"), ); @@ -8652,6 +8677,29 @@ export function createAgentChatService(args: { commitChatEventWithCanonical(managed, normalizedEvent); }; + const setCodexGoalAndMaybeEmitUpdate = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + goal: CodexThreadGoal | null, + updateKind: CodexThreadGoalUpdateKind = "sync", + turnId?: string, + ): CodexThreadGoal | null => { + const previousVisible = codexGoalVisibleState(managed.session.codexGoal ?? null); + const sanitizedGoal = normalizeAdeCodexGoal(goal); + managed.session.codexGoal = sanitizedGoal; + const nextVisible = codexGoalVisibleState(sanitizedGoal); + if (nextVisible && !codexGoalVisibleStatesEqual(previousVisible, nextVisible)) { + const resolvedTurnId = turnId ?? runtime.activeTurnId ?? undefined; + emitChatEvent(managed, { + type: "codex_goal_updated", + goal: sanitizedGoal, + updateKind, + ...(resolvedTurnId ? { turnId: resolvedTurnId } : {}), + }); + } + return sanitizedGoal; + }; + const maybeClearCodexGoalBudget = ( managed: ManagedChatSession, runtime: CodexRuntime, @@ -8684,13 +8732,7 @@ export function createAgentChatService(args: { tokenBudget: null, ...(goal.status === "budget_limited" ? { status: "active" as const } : {}), }; - managed.session.codexGoal = updatedGoal; - emitChatEvent(managed, { - type: "codex_goal_updated", - goal: updatedGoal, - updateKind: "budget", - ...(turnId ? { turnId } : {}), - }); + setCodexGoalAndMaybeEmitUpdate(managed, runtime, updatedGoal, "budget", turnId); emitChatEvent(managed, { type: "system_notice", noticeKind: "info", @@ -8728,17 +8770,27 @@ export function createAgentChatService(args: { ): CodexThreadGoal | null => { const reportedBudgetLimit = codexGoalPayloadRequiresBudgetClear(value); const goal = normalizeCodexGoalPayload(value); - managed.session.codexGoal = goal; - emitChatEvent(managed, { - type: "codex_goal_updated", - goal, - updateKind, - ...(turnId ? { turnId } : {}), - }); + setCodexGoalAndMaybeEmitUpdate(managed, runtime, goal, updateKind, turnId); maybeClearCodexGoalBudget(managed, runtime, goal, turnId, reportedBudgetLimit); return goal; }; + const clearKnownCodexGoal = ( + managed: ManagedChatSession, + runtime: CodexRuntime, + turnId?: string, + ): boolean => { + const hadGoal = managed.session.codexGoal != null; + managed.session.codexGoal = null; + if (!hadGoal) return false; + const resolvedTurnId = turnId ?? runtime.activeTurnId ?? undefined; + emitChatEvent(managed, { + type: "codex_goal_cleared", + ...(resolvedTurnId ? { turnId: resolvedTurnId } : {}), + }); + return true; + }; + const emitPendingInputRequest = ( managed: ManagedChatSession, request: PendingInputRequest, @@ -9790,14 +9842,7 @@ export function createAgentChatService(args: { return applyCodexGoalUpdate(managed, runtime, value, undefined, updateKind); } const sanitizedFallback = normalizeAdeCodexGoal(fallback); - managed.session.codexGoal = sanitizedFallback; - emitChatEvent(managed, { - type: "codex_goal_updated", - goal: sanitizedFallback, - updateKind, - ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), - }); - return sanitizedFallback; + return setCodexGoalAndMaybeEmitUpdate(managed, runtime, sanitizedFallback, updateKind); }; const setCodexGoalObjective = async (objective: string): Promise => { const normalizedObjective = validateCodexGoalObjectiveText(objective); @@ -9829,11 +9874,7 @@ export function createAgentChatService(args: { threadId: managed.session.threadId, }, "Goal update failed"); if (!response) return false; - managed.session.codexGoal = null; - emitChatEvent(managed, { - type: "codex_goal_cleared", - ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), - }); + clearKnownCodexGoal(managed, runtime); return true; }; const startCodexGoalObjectiveTurn = async (objective: string, dispatched?: () => void): Promise => { @@ -14967,11 +15008,7 @@ export function createAgentChatService(args: { } if (method === "thread/goal/cleared") { - managed.session.codexGoal = null; - emitChatEvent(managed, { - type: "codex_goal_cleared", - turnId: turnIdFromParams ?? runtime.activeTurnId ?? undefined, - }); + clearKnownCodexGoal(managed, runtime, turnIdFromParams ?? runtime.activeTurnId ?? undefined); persistChatState(managed); return; } @@ -25152,14 +25189,7 @@ export function createAgentChatService(args: { return applyCodexGoalUpdate(managed, runtime, value, runtime.activeTurnId ?? undefined, updateKind); } const sanitizedFallback = normalizeAdeCodexGoal(fallback); - managed.session.codexGoal = sanitizedFallback; - emitChatEvent(managed, { - type: "codex_goal_updated", - goal: sanitizedFallback, - updateKind, - ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), - }); - return sanitizedFallback; + return setCodexGoalAndMaybeEmitUpdate(managed, runtime, sanitizedFallback, updateKind); }; const getCodexGoal = async ({ @@ -25235,11 +25265,7 @@ export function createAgentChatService(args: { timeoutMs: CODEX_INLINE_COMMAND_TIMEOUT_MS, }); } - managed.session.codexGoal = null; - emitChatEvent(managed, { - type: "codex_goal_cleared", - ...(runtime.activeTurnId ? { turnId: runtime.activeTurnId } : {}), - }); + clearKnownCodexGoal(managed, runtime); persistChatState(managed); return null; }; diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 84427f389..9e032e90c 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -536,6 +536,57 @@ describe("local runtime connection pool", () => { expect(staleClient.close).toHaveBeenCalledTimes(1); }); + it("keeps ownership when reconnecting to an app-owned runtime kept alive after timeout", async () => { + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never, { disableSync: true }); + const child = { + pid: 1234, + kill: vi.fn(), + once: vi.fn(), + }; + const client = { call: vi.fn(), close: vi.fn(), isClosed: vi.fn(() => false) }; + (pool as unknown as { ownedRuntimeChild: unknown }).ownedRuntimeChild = child; + (pool as unknown as { preserveOwnedRuntimeChildOnNextConnect: boolean }).preserveOwnedRuntimeChildOnNextConnect = true; + (pool as unknown as { connectClient: (socketPath: string) => Promise }).connectClient = vi.fn(async () => client); + + const entry = await (pool as unknown as { + tryConnect: (socketPath: string) => Promise<{ client: unknown; child: unknown; socketPath: string } | null>; + }).tryConnect("/tmp/ade.sock"); + + expect(entry?.client).toBe(client); + expect(entry?.child).toBe(child); + expect((pool as unknown as { ownedRuntimeChild: unknown }).ownedRuntimeChild).toBe(child); + }); + + it("does not attach a stale owned child when connecting to an external runtime", async () => { + const pool = new LocalRuntimeConnectionPool("1.2.3", { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + } as never, { disableSync: true }); + const child = { + pid: 1234, + kill: vi.fn(), + once: vi.fn(), + }; + const client = { call: vi.fn(), close: vi.fn(), isClosed: vi.fn(() => false) }; + (pool as unknown as { ownedRuntimeChild: unknown }).ownedRuntimeChild = child; + (pool as unknown as { connectClient: (socketPath: string) => Promise }).connectClient = vi.fn(async () => client); + + const entry = await (pool as unknown as { + tryConnect: (socketPath: string) => Promise<{ client: unknown; child: unknown; socketPath: string } | null>; + }).tryConnect("/tmp/ade.sock"); + + expect(entry?.client).toBe(client); + expect(entry?.child).toBeNull(); + expect((pool as unknown as { ownedRuntimeChild: unknown }).ownedRuntimeChild).toBeNull(); + }); + it("normalizes local action registry entries from runtime action names", async () => { const pool = new LocalRuntimeConnectionPool("1.2.3", { debug: vi.fn(), @@ -1511,7 +1562,7 @@ describe("local runtime connection pool", () => { ); }); - it("bounds non-file action calls and drops a timed-out runtime connection", async () => { + it("bounds non-file action calls and drops a timed-out client without killing the runtime", 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(); @@ -1520,12 +1571,13 @@ describe("local runtime connection pool", () => { kill: vi.fn(), once: vi.fn(), }; - const pool = new LocalRuntimeConnectionPool("1.2.3", { + const logger = { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn(), - } as never); + }; + const pool = new LocalRuntimeConnectionPool("1.2.3", logger as never); const rootPath = path.resolve("/repo"); (pool as unknown as { projectsByRoot: Map }).projectsByRoot.set(rootPath, { projectId: "project-1", @@ -1566,9 +1618,14 @@ describe("local runtime connection pool", () => { { timeoutMs: 30_000 }, ); expect(close).toHaveBeenCalled(); - expect(child.kill).toHaveBeenCalledWith("SIGTERM"); + expect(child.kill).not.toHaveBeenCalled(); expect((pool as unknown as { connection: unknown }).connection).toBeNull(); - expect((pool as unknown as { ownedRuntimeChild: unknown }).ownedRuntimeChild).toBeNull(); + expect((pool as unknown as { ownedRuntimeChild: unknown }).ownedRuntimeChild).toBe(child); + expect(logger.warn).toHaveBeenCalledWith("local_runtime.action_timeout_drop_client", expect.objectContaining({ + domain: "chat", + action: "deleteSession", + socketPath: "/tmp/ade.sock", + })); }); it("routes local sync calls through the project-scoped runtime RPC", async () => { diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts index 6efb8c861..a9539a22e 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.ts @@ -495,6 +495,7 @@ export class LocalRuntimeConnectionPool { private activeConnection: LocalRuntimeConnection | null = null; private activeClient: RuntimeRpcClient | null = null; private ownedRuntimeChild: ChildProcess | null = null; + private preserveOwnedRuntimeChildOnNextConnect = false; private readonly coalescedActionCalls = new Map>(); private readonly projectsByRoot = new Map(); private serviceInstallStatus: LocalRuntimeStatus["serviceInstall"] = { @@ -841,7 +842,7 @@ export class LocalRuntimeConnectionPool { }); } if (callError && isRuntimeActionCallTimeout(callError)) { - this.logger.warn("local_runtime.action_timeout_reset_connection", { + this.logger.warn("local_runtime.action_timeout_drop_client", { domain: request.domain, action: request.action, socketPath: entry.socketPath, @@ -998,6 +999,7 @@ export class LocalRuntimeConnectionPool { this.activeConnection = null; this.activeClient = null; this.ownedRuntimeChild = null; + this.preserveOwnedRuntimeChildOnNextConnect = false; this.projectsByRoot.clear(); void pending?.then((entry) => { try { entry.client.close(); } catch {} @@ -1039,11 +1041,11 @@ export class LocalRuntimeConnectionPool { private resetConnectionAfterActionTimeout(entry: LocalRuntimeConnection): void { this.clearConnectionIfCurrent(entry); - if (entry.child && this.ownedRuntimeChild === entry.child) { - this.ownedRuntimeChild = null; - } + this.preserveOwnedRuntimeChildOnNextConnect = entry.child != null && this.ownedRuntimeChild === entry.child; + // An action timeout proves that this client request waited too long; it + // does not prove the runtime process is dead. Killing the owned runtime + // here tears down every project-scoped PTY and agent chat hosted by it. closeRuntimeClient(entry.client); - disposeOwnedRuntimeChild(entry.child, entry.socketPath, { unlinkSocket: true }); } // Drop a stale/closed cached connection so the next connect() reconnects. @@ -1074,10 +1076,16 @@ export class LocalRuntimeConnectionPool { } private async tryConnect(socketPath: string): Promise { + const shouldPreserveOwnedChild = this.preserveOwnedRuntimeChildOnNextConnect; + this.preserveOwnedRuntimeChildOnNextConnect = false; try { const client = await this.connectClient(socketPath); - this.ownedRuntimeChild = null; - return { client, child: null, socketPath }; + const child = shouldPreserveOwnedChild ? this.ownedRuntimeChild : null; + if (!child) this.ownedRuntimeChild = null; + // If we deliberately kept an app-owned runtime alive after a timed-out + // action, reconnecting to its socket must not drop ownership; shutdown + // still needs to dispose that child. + return { client, child, socketPath }; } catch (error) { if (error instanceof LocalRuntimeCompatibilityError) { return await this.startIsolatedRuntime(socketPath, error); diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx index c89008795..3f90945b0 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.test.tsx @@ -1247,6 +1247,79 @@ describe("AgentChatMessageList transcript rendering", () => { } }); + it("does not reuse virtualized row heights after row identities change", async () => { + const originalResizeObserver = globalThis.ResizeObserver; + const originalOffsetHeight = Object.getOwnPropertyDescriptor(HTMLElement.prototype, "offsetHeight"); + class ResizeObserverStub { + observe() {} + disconnect() {} + } + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: ResizeObserverStub, + }); + Object.defineProperty(HTMLElement.prototype, "offsetHeight", { + configurable: true, + get() { + if (!(this instanceof HTMLElement) || this.dataset.chatVirtualizedRow !== "true") return 0; + return (this.textContent ?? "").includes("Tall message") ? 220 : 40; + }, + }); + + const makeEvents = (prefix: string): AgentChatEventEnvelope[] => ( + Array.from({ length: 65 }, (_, index): AgentChatEventEnvelope => ({ + sessionId: "session-1", + timestamp: `2026-03-17T10:${String(index).padStart(2, "0")}:00.000Z`, + event: { + type: "user_message", + text: `${prefix} message ${index}`, + messageId: `${prefix.toLowerCase()}-${index}`, + turnId: `turn-${index}`, + }, + })) + ); + const virtualSizerHeight = (container: HTMLElement): number => { + const virtualSizer = Array.from(container.querySelectorAll("div")) + .find((el) => el.style.position === "relative" && el.style.height); + return Number.parseFloat(virtualSizer?.style.height ?? "0"); + }; + + try { + const rendered = renderMessageList(makeEvents("Tall")); + + let tallHeight = 0; + await waitFor(() => { + tallHeight = virtualSizerHeight(rendered.container); + expect(tallHeight).toBeGreaterThan(0); + }); + + rendered.rerender( + + + + , + ); + + await waitFor(() => { + expect(virtualSizerHeight(rendered.container)).toBeLessThan(tallHeight); + }); + } finally { + if (originalOffsetHeight) { + Object.defineProperty(HTMLElement.prototype, "offsetHeight", originalOffsetHeight); + } else { + delete (HTMLElement.prototype as any).offsetHeight; + } + if (originalResizeObserver === undefined) { + delete (globalThis as any).ResizeObserver; + } else { + Object.defineProperty(globalThis, "ResizeObserver", { + configurable: true, + value: originalResizeObserver, + }); + } + } + }); + it("keeps the current viewport anchored when rows above it grow", () => { const adjusted = reconcileMeasuredScrollTop({ index: 2, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx index 3cfff255b..385a0dd54 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatMessageList.tsx @@ -4111,8 +4111,10 @@ function AgentChatMessageListMain({ const [scrollTop, setScrollTop] = useState(0); const [containerHeight, setContainerHeight] = useState(0); const [measurementTick, setMeasurementTick] = useState(0); - // Map of row index → measured height (filled in lazily as rows render) - const measuredHeights = useRef>(new Map()); + // Map of row key → measured height (filled in lazily as rows render). + // Keeping this keyed by row identity prevents stale measurements from a + // previous row at the same index from creating phantom scroll space. + const measuredHeights = useRef>(new Map()); // Track previous events identity to clear stale measurements on session switch const prevEventsRef = useRef(events); if (prevEventsRef.current !== events && events.length > 0 && (events[0] !== prevEventsRef.current[0])) { @@ -4135,6 +4137,15 @@ function AgentChatMessageListMain({ return nextRows; }, [events]); const groupedRows = useMemo(() => groupConsecutiveWorkLogRows(rows), [rows]); + const groupedRowKeys = useMemo(() => groupedRows.map((row) => row.key), [groupedRows]); + const prevGroupedRowKeysRef = useRef(null); + if (prevGroupedRowKeysRef.current !== groupedRowKeys) { + const liveKeys = new Set(groupedRowKeys); + for (const key of measuredHeights.current.keys()) { + if (!liveKeys.has(key)) measuredHeights.current.delete(key); + } + prevGroupedRowKeysRef.current = groupedRowKeys; + } const latestActivity = useMemo(() => (showStreamingIndicator ? deriveLatestActivity(events) : null), [events, showStreamingIndicator]); const activeTurnId = useMemo(() => (showStreamingIndicator ? deriveActiveTurnId(events) : null), [events, showStreamingIndicator]); @@ -4334,8 +4345,9 @@ function AgentChatMessageListMain({ /** Returns the best-known height for a given row index. */ const rowHeight = useCallback((index: number) => { - return measuredHeights.current.get(index) ?? ESTIMATED_ROW_HEIGHT; - }, []); + const key = groupedRowKeys[index]; + return key ? (measuredHeights.current.get(key) ?? ESTIMATED_ROW_HEIGHT) : ESTIMATED_ROW_HEIGHT; + }, [groupedRowKeys]); /** Callback from MeasuredEventRow when it measures its real DOM height. */ const measureFlushTimer = useRef | null>(null); @@ -4346,9 +4358,11 @@ function AgentChatMessageListMain({ } }, []); const handleMeasure = useCallback((index: number, height: number) => { - const prev = measuredHeights.current.get(index); + const key = groupedRowKeys[index]; + if (!key) return; + const prev = measuredHeights.current.get(key); if (prev !== height) { - measuredHeights.current.set(index, height); + measuredHeights.current.set(key, height); const scrollEl = scrollRef.current; if (scrollEl && shouldVirtualize && !stickToBottomRef.current) { const adjustedScrollTop = reconcileMeasuredScrollTop({ @@ -4378,7 +4392,7 @@ function AgentChatMessageListMain({ if (isFollowingBottom) scrollToBottomSoon(2); }, isFollowingBottom ? 16 : 80); } - }, [rowHeight, scrollToBottomSoon, shouldVirtualize, timelineRowGapPx]); + }, [groupedRowKeys, rowHeight, scrollToBottomSoon, shouldVirtualize, timelineRowGapPx]); // Compute the visible window of rows when virtualization is active. // measurementTick forces recomputation when row heights are measured so