diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index 45ee3d2e1..b554a478a 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -601,6 +601,7 @@ import type { createWorkerTaskSessionService } from "../cto/workerTaskSessionSer import type { createLinearCredentialService } from "../cto/linearCredentialService"; import { createLinearOAuthService, type LinearOAuthService } from "../cto/linearOAuthService"; import type { LocalRuntimeConnectionPool } from "../localRuntime/localRuntimeConnectionPool"; +import type { RemoteRuntimeActionRequest } from "../../../shared/types/remoteRuntime"; import { registerRuntimeBridge } from "./runtimeBridge"; import type { createFlowPolicyService } from "../cto/flowPolicyService"; import type { createLinearRoutingService } from "../cto/linearRoutingService"; @@ -1350,6 +1351,16 @@ export function registerIpc({ return await action(localRuntimeConnectionPool, rootPath); }; + const tryLocalRuntimeAction = async ( + event: { sender: Electron.WebContents }, + request: RemoteRuntimeActionRequest, + ): Promise => { + const response = await tryLocalRuntimeSync(event, (pool, rootPath) => + pool.callActionForRoot(rootPath, request), + ); + return response ? (response.result as T) : null; + }; + // Backend services use Error.code for known failures (e.g. // "github_not_connected", "remote_already_exists"). Electron IPC strips // custom properties from thrown errors, so we re-throw with the code @@ -5482,9 +5493,17 @@ export function registerIpc({ return ctx; }; - ipcMain.handle(IPC.sessionsList, async (_event, arg: ListSessionsArgs): Promise => { - const ctx = ensureSessionContext(); - const ptyService = requirePtyService(); + ipcMain.handle(IPC.sessionsList, async (event, arg: ListSessionsArgs): Promise => { + const ctx = getCtx(); + if (!ctx.sessionService || !ctx.ptyService) { + const runtime = await tryLocalRuntimeAction(event, { + domain: "session", + action: "list", + args: arg ?? {}, + }); + return runtime ?? []; + } + const ptyService = ctx.ptyService; return await withIpcTiming( ctx, "sessions.list", @@ -5538,16 +5557,25 @@ export function registerIpc({ ); }); - ipcMain.handle(IPC.sessionsGet, async (_event, arg: { sessionId: string }): Promise => { - const ctx = ensureSessionContext(); - const ptyService = requirePtyService(); - let session = ctx.sessionService.get(arg.sessionId); + ipcMain.handle(IPC.sessionsGet, async (event, arg: { sessionId: string }): Promise => { + const ctx = getCtx(); + const sessionId = typeof arg?.sessionId === "string" ? arg.sessionId.trim() : ""; + if (!sessionId) return null; + if (!ctx.sessionService || !ctx.ptyService) { + return await tryLocalRuntimeAction(event, { + domain: "session", + action: "get", + arg: sessionId, + }); + } + const ptyService = ctx.ptyService; + let session = ctx.sessionService.get(sessionId); if (!session) return null; if (sessionNeedsResumeTargetHydration(session)) { const sessionId = session.id; try { await ptyService.ensureResumeTargets([sessionId]); - const hydratedSession = ctx.sessionService.get(arg.sessionId); + const hydratedSession = ctx.sessionService.get(sessionId); if (hydratedSession) session = hydratedSession; } catch (err) { ctx.logger.warn("sessions.resume_target_hydration_failed", { diff --git a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts index 23a6e7d65..48f86a276 100644 --- a/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts +++ b/apps/desktop/src/main/services/ipc/runtimeBridge.test.ts @@ -840,6 +840,94 @@ describe("registerIpc sync bridge", () => { }); }); + it("routes sessions.list through the local runtime when in-process session services are null", async () => { + const sessions = [{ id: "session-1", toolType: "shell", status: "running" }]; + const localRuntimeConnectionPool = { + callActionForRoot: vi.fn(async () => ({ + domain: "session", + action: "list", + result: sessions, + statusHints: {}, + })), + }; + registerIpc({ + getCtx: () => ({ + sessionService: null, + ptyService: null, + }) as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo" } as any, + binding: localBinding("/repo"), + }), + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + }); + + await expect( + ipcHandlers.get(IPC.sessionsList)?.( + eventForSender(), + { laneId: "lane-1" }, + ), + ).resolves.toBe(sessions); + + expect(localRuntimeConnectionPool.callActionForRoot).toHaveBeenCalledWith( + "/repo", + { + domain: "session", + action: "list", + args: { laneId: "lane-1" }, + }, + ); + }); + + it("routes sessions.get through the local runtime when in-process session services are null", async () => { + const session = { id: "session-1", toolType: "shell", status: "running", ptyId: "pty-1" }; + const localRuntimeConnectionPool = { + callActionForRoot: vi.fn(async () => ({ + domain: "session", + action: "get", + result: session, + statusHints: {}, + })), + }; + registerIpc({ + getCtx: () => ({ + sessionService: null, + ptyService: null, + }) as any, + getWindowSession: () => ({ + windowId: 7, + project: { rootPath: "/repo", displayName: "Repo" } as any, + binding: localBinding("/repo"), + }), + switchProjectFromDialog: vi.fn(), + closeCurrentProject: vi.fn(), + closeProjectByPath: vi.fn(), + globalStatePath: "/tmp/ade-state.json", + localRuntimeConnectionPool: localRuntimeConnectionPool as any, + }); + + await expect( + ipcHandlers.get(IPC.sessionsGet)?.( + eventForSender(), + { sessionId: "session-1" }, + ), + ).resolves.toBe(session); + + expect(localRuntimeConnectionPool.callActionForRoot).toHaveBeenCalledWith( + "/repo", + { + domain: "session", + action: "get", + arg: "session-1", + }, + ); + }); + it("returns an empty agent chat list when the service is unavailable", async () => { registerIpc({ getCtx: () => ({