Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions apps/desktop/electron/main/ipc/session-ipc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import type { Logger } from "../logger";
import type { PersistenceOutbox } from "../persistence-outbox";
import type { PluginRuntime } from "../plugin-runtime";
import { readSessionCollaboration } from "../services/session-collaboration";
import { searchSessionsAcrossSources } from "../services/session-search";
import { localNativeProjectPaths, searchSessionsAcrossSources } from "../services/session-search";
import type { IpcRegistrar } from "./types";
import { getActiveRemoteHostsBoot } from "../bootstrap/remote-hosts.js";

Expand Down Expand Up @@ -203,13 +203,16 @@ export function registerSessionIpc({
});
handle(IPC.invoke.sessionList, async () => {
if (!host) throw new Error("host unavailable");
const [result, native, { providers, defaults }] = await Promise.all([
const [projectPaths, result, { providers, defaults }] = await Promise.all([
localNativeProjectPaths(host),
host.call<{ sessions: RuntimeSession[] }>("session.list"),
sidecar
? sidecar.call<{ sessions: RuntimeSession[] }>("native.session.list").catch(() => ({ sessions: [] }))
: Promise.resolve({ sessions: [] }),
sessionCapabilityContext(),
]);
const native = sidecar
? await sidecar
.call<{ sessions: RuntimeSession[] }>("native.session.list", { projectPaths })
.catch(() => ({ sessions: [] as RuntimeSession[] }))
: { sessions: [] as RuntimeSession[] };
return {
...result,
sessions: [
Expand Down
10 changes: 6 additions & 4 deletions apps/desktop/electron/main/live-voice/live-work-backend-port.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import { DESKTOP_PRINCIPAL } from "../agent-host-bridge";
import type { BackendRouter, RemoteBackend } from "../remote/backend-router";
import type { RemoteHostsBoot } from "../bootstrap/remote-hosts";
import type { LiveWorkAdmission, LiveWorkPort, WorkSnapshot } from "@pi-desktop/host-runtime";
import { localNativeProjectPaths } from "../services/session-search";

export type LiveWorkSessionRecord = {
id: string;
Expand Down Expand Up @@ -55,13 +56,14 @@ export async function listLiveWorkSessions(input: {
if (!host) throw Object.assign(new Error("Local Host is unavailable"), { errorCode: "LIVE_WORK_NOT_READY" });
const sidecar = input.getSidecar();
const remoteHosts = input.getRemoteHosts();
const [desktopResult, nativeResult, remoteResult] = await Promise.all([
const [projectPaths, desktopResult, remoteResult] = await Promise.all([
localNativeProjectPaths(host),
host.call<{ sessions?: Array<Record<string, unknown>> }>("session.list"),
sidecar
? sidecar.call<{ sessions?: Array<Record<string, unknown>> }>("native.session.list")
: Promise.resolve({ sessions: [] as Array<Record<string, unknown>> }),
remoteHosts ? remoteHosts.listSessions() : Promise.resolve([]),
]);
const nativeResult = sidecar
? await sidecar.call<{ sessions?: Array<Record<string, unknown>> }>("native.session.list", { projectPaths })
: { sessions: [] as Array<Record<string, unknown>> };
const desktop: LiveWorkSessionRecord[] = (desktopResult.sessions ?? []).flatMap((session) => {
if (typeof session.id !== "string" || !session.id.trim() || (session.source !== undefined && session.source !== "desktop")) return [];
return [{
Expand Down
30 changes: 30 additions & 0 deletions apps/desktop/electron/main/services/session-search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ type RpcClient = {
};

const SEARCH_PAGE_SIZE = 30;
const MAX_NATIVE_PROJECT_PATHS = 500;

type NativeProjectHost = {
call(method: string, params?: unknown): Promise<unknown>;
};

/** Local project paths whose Pi `sessionDir` native discovery must honor. */
export async function localNativeProjectPaths(host: NativeProjectHost): Promise<string[]> {
try {
const listed = (await host.call("projects.list")) as { projects?: Array<{ path?: unknown }> } | null;
const projects =
listed && typeof listed === "object" && Array.isArray(listed.projects) ? listed.projects : [];
const paths: string[] = [];
const seen = new Set<string>();
for (const project of projects) {
if (!project || typeof project.path !== "string") continue;
const path = project.path.trim();
if (!path || path.startsWith("remote-project:") || seen.has(path)) continue;
seen.add(path);
paths.push(path);
if (paths.length >= MAX_NATIVE_PROJECT_PATHS) break;
}
return paths;
} catch (error) {
console.warn("[session-search] projects.list failed; native sessionDir discovery skipped", error);
return [];
}
}

function compareHits(
left: SessionSearchPage["hits"][number],
Expand Down Expand Up @@ -41,10 +69,12 @@ export async function searchSessionsAcrossSources(
const offset = Number.isInteger(rawOffset) ? Math.max(0, rawOffset) : 0;
const requiredHostRows = offset + SEARCH_PAGE_SIZE;

const projectPaths = await localNativeProjectPaths(host);
const nativeSearch = sidecar
? sidecar
.call<SessionSearchPage>("native.session.search", {
query: Reflect.get(request, "query"),
projectPaths,
})
.catch(() => ({ hits: [], nextOffset: null }))
: Promise.resolve<SessionSearchPage>({ hits: [], nextOffset: null });
Expand Down
66 changes: 64 additions & 2 deletions apps/desktop/test/live-work-scope.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -283,11 +283,28 @@ test("fresh live work catalog resolves Composer targets from all backends and re
});
t.after(() => server.close());
const { listLiveWorkSessions, requireLiveWorkSession } = await server.ssrLoadModule("/electron/main/live-voice/live-work-backend-port.ts");
const sidecarCalls = [];
const sessions = await listLiveWorkSessions({
getHost: () => ({ call: async () => ({ sessions: [{ id: "desktop-1", source: "desktop", title: "Desktop", projectId: "project-1", projectPath: "/work/demo" }] }) }),
getSidecar: () => ({ call: async () => ({ sessions: [{ id: "native-pi:1", title: "Native", capabilities: { canPrompt: true } }] }) }),
getHost: () => ({
call: async (method) => {
if (method === "projects.list") {
return { projects: [{ path: "/work/demo" }, { path: " " }, { path: "remote-project:office" }] };
}
return { sessions: [{ id: "desktop-1", source: "desktop", title: "Desktop", projectId: "project-1", projectPath: "/work/demo" }] };
},
}),
getSidecar: () => ({
call: async (method, input) => {
sidecarCalls.push({ method, input });
return { sessions: [{ id: "native-pi:1", title: "Native", capabilities: { canPrompt: true } }] };
},
}),
getRemoteHosts: () => ({ listSessions: async () => [{ id: "remote-1", title: "Remote", hostLabel: "Office", workspaceLabel: "repo", mode: "agent" }] }),
});
assert.deepEqual(sidecarCalls, [{
method: "native.session.list",
input: { projectPaths: ["/work/demo"] },
}]);

assert.equal(requireLiveWorkSession(sessions, "desktop-1").source, "desktop");
assert.equal(requireLiveWorkSession(sessions, "desktop-1").projectId, "project-1");
Expand All @@ -300,3 +317,48 @@ test("fresh live work catalog resolves Composer targets from all backends and re
});
assert.throws(() => requireLiveWorkSession(sessions, "missing"), { errorCode: "LIVE_WORK_SESSION_UNAVAILABLE" });
});

test("live work attaches the desktop list before projects.list settles (#1732)", async (t) => {
const server = await createServer({
root: fileURLToPath(new URL("..", import.meta.url)),
configFile: false,
server: { middlewareMode: true, hmr: false, ws: false },
appType: "custom",
optimizeDeps: { noDiscovery: true, include: [] },
});
t.after(() => server.close());
const { listLiveWorkSessions } = await server.ssrLoadModule("/electron/main/live-voice/live-work-backend-port.ts");
let releaseProjects;
const projectsGate = new Promise((resolve) => {
releaseProjects = resolve;
});
const rejections = [];
const onRejection = (error) => {
rejections.push(error);
};
process.on("unhandledRejection", onRejection);
try {
const pending = listLiveWorkSessions({
getHost: () => ({
call: async (method) => {
if (method === "projects.list") return projectsGate;
if (method === "session.list") throw new Error("session list failed");
return {};
},
}),
getSidecar: () => ({ call: async () => ({ sessions: [] }) }),
getRemoteHosts: () => ({ listSessions: async () => [] }),
}).then(
() => "resolved",
(error) => error,
);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.deepEqual(rejections.map((error) => error.message), []);
releaseProjects({ projects: [] });
const outcome = await pending;
assert.equal(outcome instanceof Error, true);
assert.match(outcome.message, /session list failed/);
} finally {
process.off("unhandledRejection", onRejection);
}
});
138 changes: 132 additions & 6 deletions apps/desktop/test/native-pi-sessions.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ const { register } = await import("node:module");
register(new URL("./helpers/ts-import-hooks.mjs", import.meta.url));
const { IPC, isImageGenerationModel, imageGenerationBindings } = await import("@pi-desktop/shared");
const { registerAgentIpc } = await import("../electron/main/ipc/agent-ipc.ts");
const { searchSessionsAcrossSources } = await import("../electron/main/services/session-search.ts");
const { localNativeProjectPaths, searchSessionsAcrossSources } = await import("../electron/main/services/session-search.ts");
const { createEventsSlice } = await import("../src/stores/slices/events-slice.ts");
const { createTranscriptSlice } = await import("../src/stores/slices/transcript-slice.ts");
const { api } = await import("../src/lib/api.ts");
Expand Down Expand Up @@ -226,7 +226,7 @@ function forkHarness({ host, sidecar, activeTurns = new Map() }) {
"@pi-desktop/shared": sharedForIpc,
"../importers": { convertSession() {}, scanAllSources() {}, scanModelConfigs() {} },
"../services/session-collaboration": { readSessionCollaboration() {} },
"../services/session-search": { searchSessionsAcrossSources },
"../services/session-search": { localNativeProjectPaths, searchSessionsAcrossSources },
"../bootstrap/remote-hosts.js": { getActiveRemoteHostsBoot: () => null },
});
registerSessionIpc({
Expand All @@ -246,6 +246,7 @@ function forkHarness({ host, sidecar, activeTurns = new Map() }) {
});
return {
handle: handlers.get(IPC.invoke.sessionFork),
list: handlers.get(IPC.invoke.sessionList),
search: handlers.get(IPC.invoke.sessionSearch),
hostCalls,
sidecarCalls,
Expand All @@ -267,6 +268,9 @@ test("global session search merges native sidecar hits with Desktop results", as
host: (calls) => ({
call: async (method, input) => {
calls.push({ method, input });
if (method === "projects.list") {
return { projects: [{ path: "/work/demo" }, { path: "remote-project:office" }] };
}
return { hits: [desktopHit], nextOffset: null };
},
}),
Expand All @@ -279,11 +283,133 @@ test("global session search merges native sidecar hits with Desktop results", as
});
const result = await search({ query: "side chat", offset: 0 });
assert.deepEqual(result.hits.map((hit) => hit.session.id), ["native-pi:child", "desktop-session"]);
assert.deepEqual(sidecarCalls, [{ method: "native.session.search", input: { query: "side chat" } }]);
assert.deepEqual(hostCalls, [{
method: "search.sessions",
input: { query: "side chat", offset: 0, limit: 30 },
assert.deepEqual(sidecarCalls, [{
method: "native.session.search",
input: { query: "side chat", projectPaths: ["/work/demo"] },
}]);
assert.deepEqual(hostCalls, [
{ method: "projects.list", input: undefined },
{ method: "search.sessions", input: { query: "side chat", offset: 0, limit: 30 } },
]);
});

test("session list forwards local project paths to native discovery (#1732)", async () => {
const { list, hostCalls, sidecarCalls } = forkHarness({
host: (calls) => ({
call: async (method, input) => {
calls.push({ method, input });
if (method === "projects.list") {
return {
projects: [
{ path: "/work/demo" },
{ path: "/work/demo" },
{ path: "remote-project:office" },
{ path: " " },
],
};
}
if (method === "session.list") {
return { sessions: [{ id: "desktop-1", updatedAt: "2026-09-14T00:00:01.000Z" }] };
}
return {};
},
}),
sidecar: (calls) => ({
call: async (method, input) => {
calls.push({ method, input });
return {
sessions: [{ id: "native-pi:1", updatedAt: "2026-09-14T00:00:02.000Z", source: "pi-native" }],
};
},
}),
});
const result = await list();
assert.deepEqual(result.sessions.map((session) => session.id), ["native-pi:1", "desktop-1"]);
assert.deepEqual(sidecarCalls, [{
method: "native.session.list",
input: { projectPaths: ["/work/demo"] },
}]);
assert.deepEqual(hostCalls.map((call) => call.method), ["projects.list", "session.list"]);
});

test("a failed projects.list keeps Desktop sessions and logs the skip (#1732)", async () => {
const warnings = [];
const original = console.warn;
console.warn = (...args) => {
warnings.push(args);
};
try {
const { list, sidecarCalls } = forkHarness({
host: () => ({
call: async (method) => {
if (method === "projects.list") throw new Error("index down");
if (method === "session.list") {
return { sessions: [{ id: "desktop-1", updatedAt: "2026-09-14T00:00:01.000Z" }] };
}
return {};
},
}),
sidecar: (calls) => ({
call: async (method, input) => {
calls.push({ method, input });
return { sessions: [] };
},
}),
});
const result = await list();
assert.deepEqual(result.sessions.map((session) => session.id), ["desktop-1"]);
assert.deepEqual(sidecarCalls, [{
method: "native.session.list",
input: { projectPaths: [] },
}]);
assert.ok(
warnings.some((args) => String(args[0]).includes("projects.list failed")),
"expected a projects.list warning",
);
} finally {
console.warn = original;
}
});

test("session list attaches host handlers before projects.list settles (#1732)", async () => {
let releaseProjects;
const projectsGate = new Promise((resolve) => {
releaseProjects = resolve;
});
const rejections = [];
const onRejection = (error) => {
rejections.push(error);
};
process.on("unhandledRejection", onRejection);
try {
const { list } = forkHarness({
host: () => ({
call: async (method) => {
if (method === "projects.list") return projectsGate;
if (method === "session.list") throw new Error("session list failed");
return {};
},
}),
sidecar: (calls) => ({
call: async (method, input) => {
calls.push({ method, input });
return { sessions: [] };
},
}),
});
const pending = list().then(
() => "resolved",
(error) => error,
);
await new Promise((resolve) => setTimeout(resolve, 30));
assert.deepEqual(rejections.map((error) => error.message), []);
releaseProjects({ projects: [] });
const outcome = await pending;
assert.equal(outcome instanceof Error, true);
assert.match(outcome.message, /session list failed/);
} finally {
process.off("unhandledRejection", onRejection);
}
});

test("native fork routes to the sidecar and never to the Desktop host", async () => {
Expand Down
Loading
Loading