fix: preserve legacy session route lookups

This commit is contained in:
Federico Jaramillo Martinez
2026-06-10 22:42:55 +02:00
parent 71510444c4
commit b99143f757
13 changed files with 350 additions and 129 deletions
+25 -1
View File
@@ -1,7 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import type { TerminalCommandRun, Workspace } from "../../../shared/apiTypes";
import { machinesApi, piWebApi, terminalsApi, workspacesApi } from "./clients";
import { machinesApi, piWebApi, sessionsApi, terminalsApi, workspacesApi } from "./clients";
const workspace: Workspace = {
id: "w/1",
@@ -60,6 +60,30 @@ describe("machine-scoped runtime API", () => {
});
});
describe("session API compatibility", () => {
it("keeps legacy session-id calls free of cwd context", async () => {
const fetchMock = stubJsonFetch({ accepted: true });
await sessionsApi.prompt("s 1", "hello", "followUp", "remote a");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt");
expect(JSON.parse(requestBody(init))).toEqual({ text: "hello", streamingBehavior: "followUp" });
});
it("adds cwd context when session refs include a workspace", async () => {
const fetchMock = stubJsonFetch({ accepted: true });
await sessionsApi.prompt({ id: "s 1", cwd: "/repo" }, "hello", undefined, "remote a");
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("/api/machines/remote%20a/sessions/s%201/prompt");
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" });
});
});
describe("machine-scoped terminal command-run API", () => {
it("deletes workspaces through the selected machine scope", async () => {
const fetchMock = stubJsonFetch(commandRun);
+49 -26
View File
@@ -42,17 +42,40 @@ import { machineGitDiffUrl, messageUrl } from "./urls";
const machinePrefix = (machineId = "local") => `/api/machines/${encodeURIComponent(machineId)}`;
function sessionBaseUrl(session: SessionRef, machineId = "local"): string {
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(session.id)}`;
type SessionLookup = SessionRef | string;
function sessionId(session: SessionLookup): string {
return typeof session === "string" ? session : session.id;
}
function sessionUrl(session: SessionRef, endpoint: string, machineId = "local"): string {
function sessionCwd(session: SessionLookup): string | undefined {
return typeof session === "string" ? undefined : session.cwd;
}
function sessionBaseUrl(session: SessionLookup, machineId = "local"): string {
return `${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId(session))}`;
}
function sessionUrl(session: SessionLookup, endpoint: string, machineId = "local"): string {
return `${sessionBaseUrl(session, machineId)}/${endpoint}`;
}
function sessionQueryUrl(session: SessionRef, endpoint: string, machineId = "local"): string {
const query = new URLSearchParams({ cwd: session.cwd }).toString();
return `${sessionUrl(session, endpoint, machineId)}?${query}`;
function sessionQueryUrl(session: SessionLookup, endpoint: string, machineId = "local"): string {
return `${sessionUrl(session, endpoint, machineId)}${sessionQuery(session)}`;
}
function sessionBaseQueryUrl(session: SessionLookup, machineId = "local"): string {
return `${sessionBaseUrl(session, machineId)}${sessionQuery(session)}`;
}
function sessionQuery(session: SessionLookup): string {
const cwd = sessionCwd(session);
return cwd === undefined || cwd === "" ? "" : `?${new URLSearchParams({ cwd }).toString()}`;
}
function sessionBody(session: SessionLookup, fields: Record<string, unknown> = {}): string {
const cwd = sessionCwd(session);
return JSON.stringify(cwd === undefined || cwd === "" ? fields : { cwd, ...fields });
}
export const piWebApi = {
@@ -98,26 +121,26 @@ export const workspacesApi = {
export const sessionsApi = {
sessions: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions?cwd=${encodeURIComponent(cwd)}`, arrayOf(parseSessionInfo)),
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
messages: (session: SessionRef, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage),
status: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus),
models: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse),
setModel: (session: SessionRef, provider: string, modelId: string, machineId = "local") => request(sessionUrl(session, "model", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd, provider, modelId }) }),
cycleModel: (session: SessionRef, direction: "forward" | "backward", machineId = "local") => request(sessionUrl(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd, direction }) }),
thinkingLevels: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
setThinkingLevel: (session: SessionRef, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd, level }) }),
cycleThinkingLevel: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
commands: (session: SessionRef, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)),
prompt: (session: SessionRef, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: JSON.stringify(streamingBehavior === undefined ? { cwd: session.cwd, text } : { cwd: session.cwd, text, streamingBehavior }) }),
shell: (session: SessionRef, text: string, machineId = "local") => request(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: JSON.stringify({ cwd: session.cwd, text }) }),
runCommand: (session: SessionRef, text: string, machineId = "local") => request(sessionUrl(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: JSON.stringify({ cwd: session.cwd, text }) }),
respondToCommand: (session: SessionRef, requestId: string, value: string, machineId = "local") => request(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: JSON.stringify({ cwd: session.cwd, requestId, value }) }),
abort: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "abort", machineId), parseAborted, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
stop: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "stop", machineId), parseStopped, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
archive: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "archive", machineId), parseArchived, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
archiveWithDescendants: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "archive-tree", machineId), parseArchived, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
restore: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
deleteArchived: (session: SessionRef, machineId = "local") => request(`${sessionBaseUrl(session, machineId)}?${new URLSearchParams({ cwd: session.cwd }).toString()}`, parseDeleted, { method: "DELETE" }),
detachParent: (session: SessionRef, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: JSON.stringify({ cwd: session.cwd }) }),
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messageUrl(session, options, machineId), parseMessagePage),
status: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "status", machineId), parseSessionStatus),
models: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "models", machineId), parseModelSelectionResponse),
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionUrl(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionUrl(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
thinkingLevels: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "thinking-levels", machineId), parseThinkingLevelsResponse),
setThinkingLevel: (session: SessionLookup, level: "off" | "minimal" | "low" | "medium" | "high" | "xhigh", machineId = "local") => request(sessionUrl(session, "thinking-level", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { level }) }),
cycleThinkingLevel: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "thinking-level/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
commands: (session: SessionLookup, machineId = "local") => request(sessionQueryUrl(session, "commands", machineId), arrayOf(parseSlashCommand)),
prompt: (session: SessionLookup, text: string, streamingBehavior?: "steer" | "followUp", machineId = "local") => request(sessionUrl(session, "prompt", machineId), parseAccepted, { method: "POST", body: sessionBody(session, streamingBehavior === undefined ? { text } : { text, streamingBehavior }) }),
shell: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "shell", machineId), parseAccepted, { method: "POST", body: sessionBody(session, { text }) }),
runCommand: (session: SessionLookup, text: string, machineId = "local") => request(sessionUrl(session, "commands/run", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { text }) }),
respondToCommand: (session: SessionLookup, requestId: string, value: string, machineId = "local") => request(sessionUrl(session, "commands/respond", machineId), parseCommandResult, { method: "POST", body: sessionBody(session, { requestId, value }) }),
abort: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "abort", machineId), parseAborted, { method: "POST", body: sessionBody(session) }),
stop: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "stop", machineId), parseStopped, { method: "POST", body: sessionBody(session) }),
archive: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "archive", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
archiveWithDescendants: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "archive-tree", machineId), parseArchived, { method: "POST", body: sessionBody(session) }),
restore: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "restore", machineId), parseRestored, { method: "POST", body: sessionBody(session) }),
deleteArchived: (session: SessionLookup, machineId = "local") => request(sessionBaseQueryUrl(session, machineId), parseDeleted, { method: "DELETE" }),
detachParent: (session: SessionLookup, machineId = "local") => request(sessionUrl(session, "detach-parent", machineId), parseDetached, { method: "POST", body: sessionBody(session) }),
authProviders: (options?: { mode?: "login" | "logout"; authType?: "oauth" | "api_key"; machineId?: string }) => {
const params = new URLSearchParams();
if (options?.mode !== undefined) params.set("mode", options.mode);
+8
View File
@@ -30,6 +30,14 @@ describe("machine-scoped socket urls", () => {
]);
});
it("keeps legacy session socket urls usable without cwd", () => {
sessionEvents("s1");
expect(webSocketUrls).toEqual([
"wss://pi.example.test/api/machines/local/sessions/s1/events",
]);
});
it("uses the requested machine scope for terminal sockets", () => {
terminalSocket("p 1", "w/1", "t?1", { cols: 120, rows: 40 }, "remote-a");
+7 -3
View File
@@ -1,8 +1,12 @@
import type { SessionRef } from "../../../shared/apiTypes";
export function sessionEvents(session: SessionRef, machineId = "local"): WebSocket {
const query = new URLSearchParams({ cwd: session.cwd }).toString();
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${encodeURIComponent(session.id)}/events?${query}`);
type SessionLookup = SessionRef | string;
export function sessionEvents(session: SessionLookup, machineId = "local"): WebSocket {
const cwd = typeof session === "string" ? undefined : session.cwd;
const query = cwd === undefined || cwd === "" ? "" : `?${new URLSearchParams({ cwd }).toString()}`;
const sessionId = typeof session === "string" ? session : session.id;
return new WebSocket(`${webSocketBaseUrl()}${machinePrefix(machineId)}/sessions/${encodeURIComponent(sessionId)}/events${query}`);
}
export function globalSessionEvents(machineId = "local"): WebSocket {
+16 -3
View File
@@ -1,5 +1,15 @@
import type { SessionRef } from "../../../shared/apiTypes";
type SessionLookup = SessionRef | string;
function sessionId(session: SessionLookup): string {
return typeof session === "string" ? session : session.id;
}
function sessionCwd(session: SessionLookup): string | undefined {
return typeof session === "string" ? undefined : session.cwd;
}
export function gitDiffUrl(projectId: string, workspaceId: string, options?: { path?: string; staged?: boolean }): string {
const params = new URLSearchParams();
if (options?.path !== undefined) params.set("path", options.path);
@@ -16,11 +26,14 @@ export function machineGitDiffUrl(machineId: string, projectId: string, workspac
return `/api/machines/${encodeURIComponent(machineId)}/projects/${encodeURIComponent(projectId)}/workspaces/${encodeURIComponent(workspaceId)}/git/diff${query ? `?${query}` : ""}`;
}
export function messageUrl(session: SessionRef, options?: { limit?: number; before?: number }, machineId = "local"): string {
const params = new URLSearchParams({ cwd: session.cwd });
export function messageUrl(session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local"): string {
const params = new URLSearchParams();
const cwd = sessionCwd(session);
if (cwd !== undefined && cwd !== "") params.set("cwd", cwd);
if (options?.limit !== undefined) params.set("limit", String(options.limit));
if (options?.before !== undefined) params.set("before", String(options.before));
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(session.id)}/messages?${params.toString()}`;
const query = params.toString();
return `/api/machines/${encodeURIComponent(machineId)}/sessions/${encodeURIComponent(sessionId(session))}/messages${query === "" ? "" : `?${query}`}`;
}
export function workspaceImagePreviewUrl(projectId: string, workspaceId: string, path: string, options?: { modifiedAt?: string; machineId?: string }): string {
@@ -181,10 +181,10 @@ describe("SessionController", () => {
...defaultApi,
startSession: () => Promise.resolve(replacementSession),
messages: (session) => {
if (session.id === oldSession.id) return Promise.reject(new Error("Session not found"));
if (sessionLookupId(session) === oldSession.id) return Promise.reject(new Error("Session not found"));
return Promise.resolve(emptyPage);
},
status: (session) => Promise.resolve(status(session.id)),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
@@ -221,7 +221,7 @@ describe("SessionController", () => {
...defaultApi,
respondToCommand: () => Promise.resolve({ type: "done", message: "Session forked", session: replacementSession, promptDraft: "fork me" }),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(session.id)),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
@@ -244,7 +244,7 @@ describe("SessionController", () => {
...defaultApi,
archive: () => Promise.resolve({ archived: true }),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(session.id)),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
@@ -273,7 +273,7 @@ describe("SessionController", () => {
...defaultApi,
archiveWithDescendants: () => Promise.resolve({ archived: true, sessionIds: [oldSession.id, childSession.id], archivedCount: 2, skippedAlreadyArchivedCount: 0 }),
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(session.id)),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
@@ -299,11 +299,11 @@ describe("SessionController", () => {
const api: typeof defaultApi = {
...defaultApi,
archive: (session) => {
archivedIds.push(session.id);
archivedIds.push(sessionLookupId(session));
return Promise.resolve({ archived: true });
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(session.id)),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
@@ -336,11 +336,11 @@ describe("SessionController", () => {
const api: typeof defaultApi = {
...defaultApi,
deleteArchived: (session) => {
deletedIds.push(session.id);
deletedIds.push(sessionLookupId(session));
return Promise.resolve({ deleted: true });
},
messages: () => Promise.resolve(emptyPage),
status: (session) => Promise.resolve(status(session.id)),
status: (session) => Promise.resolve(status(sessionLookupId(session))),
};
const controller = new SessionController(
() => state,
@@ -364,7 +364,7 @@ describe("SessionController", () => {
const api: typeof defaultApi = {
...defaultApi,
deleteArchived: (session) => {
deletedIds.push(session.id);
deletedIds.push(sessionLookupId(session));
return Promise.resolve({ deleted: true });
},
};
@@ -413,3 +413,7 @@ describe("SessionController", () => {
function sessionKey(sessionId: string): string {
return machineSessionKey("local", sessionId);
}
function sessionLookupId(session: string | SessionRef): string {
return typeof session === "string" ? session : session.id;
}