Archived
Merge remote-tracking branch 'origin/main' into feat/model-questions-ux
# Conflicts: # src/server/sessiond.ts # src/server/sessions/sessionRoutes.test.ts
This commit is contained in:
@@ -253,6 +253,22 @@ describe("session API compatibility", () => {
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ sessions: [{ id: "s 1", cwd: "/repo" }] });
|
||||
});
|
||||
|
||||
it("carries a create's correlation token in the start request body when one is supplied", async () => {
|
||||
const fetchMock = stubSequenceFetch([
|
||||
jsonResponse(sessionInfoResponse("s 1")),
|
||||
jsonResponse(sessionInfoResponse("s 2")),
|
||||
]);
|
||||
|
||||
await sessionsApi.startSession("/repo", "remote a", "pending-session-3-k2x9");
|
||||
await sessionsApi.startSession("/repo", "remote a");
|
||||
|
||||
expect(fetchCall(fetchMock, 0)[0]).toBe("https://pi.example.test/api/machines/remote%20a/sessions");
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 0)[1]))).toEqual({ cwd: "/repo", startupToken: "pending-session-3-k2x9" });
|
||||
// The token is optional, so a caller with no row to label sends none rather
|
||||
// than an empty one.
|
||||
expect(JSON.parse(requestBody(fetchCall(fetchMock, 1)[1]))).toEqual({ cwd: "/repo" });
|
||||
});
|
||||
|
||||
it("keeps legacy session-id calls free of cwd context", async () => {
|
||||
const fetchMock = stubJsonFetch({ accepted: true });
|
||||
|
||||
@@ -572,6 +588,10 @@ function requestBody(init: RequestInit | undefined): string {
|
||||
return init.body;
|
||||
}
|
||||
|
||||
function sessionInfoResponse(id: string) {
|
||||
return { id, path: `/tmp/${id}.jsonl`, cwd: "/repo", created: "now", modified: "now", messageCount: 0, firstMessage: "" };
|
||||
}
|
||||
|
||||
function piWebConfigResponse(config: PiWebConfigValues) {
|
||||
return {
|
||||
path: "/tmp/pi-web/config.json",
|
||||
|
||||
@@ -215,7 +215,7 @@ export const sessionsApi = {
|
||||
notificationInbox: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "notifications", machineId), parseSessionNotificationInboxSnapshot),
|
||||
dismissNotification: (session: SessionLookup, daemonInstanceId: string, notificationId: string, machineId = "local") => request(sessionPath(session, "notifications/dismiss", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, notificationId }) }),
|
||||
dismissAllNotifications: (session: SessionLookup, daemonInstanceId: string, through: SessionNotificationDismissThrough, machineId = "local") => request(sessionPath(session, "notifications/dismiss-all", machineId), parseSessionNotificationInboxSnapshot, { method: "POST", body: sessionBody(session, { daemonInstanceId, throughOrder: through.order, throughOverflowWatermark: through.overflowWatermark }) }),
|
||||
startSession: (cwd: string, machineId = "local") => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify({ cwd }) }),
|
||||
startSession: (cwd: string, machineId = "local", startupToken?: string) => request(`${machinePrefix(machineId)}/sessions`, parseSessionInfo, { method: "POST", body: JSON.stringify(startupToken === undefined ? { cwd } : { cwd, startupToken }) }),
|
||||
cleanupPreview: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup/preview`, parseSessionCleanupPreviewResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||
cleanup: (input: SessionCleanupRequest, machineId = "local") => request(`${machinePrefix(machineId)}/sessions/cleanup`, parseSessionCleanupExecuteResponse, { method: "POST", body: JSON.stringify(input) }),
|
||||
archiveMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/archive`, parseSessionBulkArchiveResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
|
||||
|
||||
@@ -289,34 +289,45 @@ describe("API parsers", () => {
|
||||
})).toThrow("positive safe integer");
|
||||
});
|
||||
|
||||
it("parses session startup progress with and without a wait detail", () => {
|
||||
it("parses session startup progress with and without a correlation token", () => {
|
||||
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
|
||||
|
||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity })).toEqual({
|
||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", startupToken: "pending-session-1-abc", activity })).toEqual({
|
||||
type: "session.startup",
|
||||
cwd: "/repo",
|
||||
startupToken: "pending-session-1-abc",
|
||||
activity,
|
||||
});
|
||||
// An open carries no token: the activity's own session id is the only route.
|
||||
const idle = { sessionId: "session-1", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
|
||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: idle })).toEqual({
|
||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", activity: idle })).toEqual({
|
||||
type: "session.startup",
|
||||
cwd: "/repo",
|
||||
activity: idle,
|
||||
});
|
||||
});
|
||||
|
||||
it("carries the startup marker so an opening session is not mistaken for a working one", () => {
|
||||
const activity = { sessionId: "session-1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z", startup: true };
|
||||
|
||||
expect(parseSessionStartupProgressEvent({ type: "session.startup", activity })).toEqual({ type: "session.startup", activity });
|
||||
// A malformed marker is dropped like any other malformed field rather than
|
||||
// being coerced into "this is startup" or "this is work".
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, startup: "yes" } })).toThrow("Expected optional boolean field: startup");
|
||||
});
|
||||
|
||||
it("rejects session startup progress that cannot be routed or rendered honestly", () => {
|
||||
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", at: "2026-07-20T00:00:01.000Z" };
|
||||
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "activity.update", cwd: "/repo", activity })).toThrow("Invalid session startup event type");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity })).toThrow("Expected string field: cwd");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "", activity })).toThrow("Expected non-empty string field: cwd");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo" })).toThrow("Expected object response");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, phase: "waiting" } })).toThrow("Expected session activity phase field: phase");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, label: 7 } })).toThrow("Expected string field: label");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, label: "" } })).toThrow("Expected non-empty string field: label");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, detail: 7 } })).toThrow("Expected optional string field: detail");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, sessionId: "" } })).toThrow("Expected non-empty string field: sessionId");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "activity.update", activity })).toThrow("Invalid session startup event type");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup" })).toThrow("Expected object response");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", startupToken: 7, activity })).toThrow("Expected optional string field: startupToken");
|
||||
// An empty token would match nothing but must still be rejected rather than
|
||||
// silently carried, so a malformed frame never reaches the routing at all.
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", startupToken: "", activity })).toThrow("Expected non-empty string field: startupToken");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, phase: "waiting" } })).toThrow("Expected session activity phase field: phase");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, label: 7 } })).toThrow("Expected string field: label");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, label: "" } })).toThrow("Expected non-empty string field: label");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, detail: 7 } })).toThrow("Expected optional string field: detail");
|
||||
expect(() => parseSessionStartupProgressEvent({ type: "session.startup", activity: { ...activity, sessionId: "" } })).toThrow("Expected non-empty string field: sessionId");
|
||||
});
|
||||
|
||||
it("parses session cleanup preview and execute responses", () => {
|
||||
|
||||
@@ -402,15 +402,19 @@ export function parseSessionUnreadEvent(value: unknown): SessionUnreadEvent {
|
||||
/**
|
||||
* Validate a startup progress frame. The browser substitutes its own wording
|
||||
* from this event, so a malformed frame must be dropped rather than rendered:
|
||||
* `cwd` is the routing key, and an activity missing its phase or label could
|
||||
* otherwise blank out or freeze the text a user is reading while they wait.
|
||||
* `startupToken` is the routing key when present, and an activity missing its
|
||||
* phase or label could otherwise blank out or freeze the text a user is reading
|
||||
* while they wait. An absent token is valid — an open routes by session id — but
|
||||
* a present empty one is not, since it could match no row honestly.
|
||||
*/
|
||||
export function parseSessionStartupProgressEvent(value: unknown): SessionStartupProgressEvent {
|
||||
const record = requireRecord(value);
|
||||
if (record["type"] !== "session.startup") throw new Error("Invalid session startup event type");
|
||||
const startupToken = optionalString(record, "startupToken");
|
||||
if (startupToken === "") throw new Error("Expected non-empty string field: startupToken");
|
||||
return {
|
||||
type: "session.startup",
|
||||
cwd: requireNonEmptyString(record, "cwd"),
|
||||
...optionalField("startupToken", startupToken),
|
||||
activity: parseSessionActivity(record["activity"]),
|
||||
};
|
||||
}
|
||||
@@ -423,9 +427,22 @@ function parseSessionActivity(value: unknown): SessionActivity {
|
||||
label: requireNonEmptyString(record, "label"),
|
||||
...optionalField("detail", optionalString(record, "detail")),
|
||||
at: requireNonEmptyString(record, "at"),
|
||||
...optionalField("startup", optionalActivityStartupMarker(record)),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The startup marker says the activity is a session opening rather than work in
|
||||
* progress, which decides whether "Stop Active Work" is offered and whether a
|
||||
* reload is blocked. A malformed marker is rejected rather than guessed at.
|
||||
*/
|
||||
function optionalActivityStartupMarker(record: Record<string, unknown>): boolean | undefined {
|
||||
const value = record["startup"];
|
||||
if (value === undefined) return undefined;
|
||||
if (typeof value !== "boolean") throw new Error("Expected optional boolean field: startup");
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireSessionActivityPhase(record: Record<string, unknown>, key: string): SessionActivity["phase"] {
|
||||
const value = requireString(record, key);
|
||||
if (value !== "active" && value !== "idle" && value !== "error") throw new Error(`Expected session activity phase field: ${key}`);
|
||||
|
||||
@@ -435,6 +435,7 @@ export class PiWebApp extends LitElement {
|
||||
this.sessions.refreshSelectedSession(),
|
||||
this.refreshMachineActivities(),
|
||||
this.refreshWorkspaceDeletionRuns(),
|
||||
this.workspaces.refreshSelectedProjectTopology(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -492,6 +493,7 @@ export class PiWebApp extends LitElement {
|
||||
this.loadClientConfig(),
|
||||
this.refreshWorkspaceDeletionRuns(),
|
||||
this.refreshCurrentWorkspaceSurface(),
|
||||
this.workspaces.refreshSelectedProjectTopology(),
|
||||
]);
|
||||
this.schedulePiWebStatusRefresh();
|
||||
} finally {
|
||||
@@ -1789,6 +1791,8 @@ export class PiWebApp extends LitElement {
|
||||
configureAuth: () => this.auth.openLogin(),
|
||||
logoutAuth: () => this.auth.openLogout(),
|
||||
openThemePicker: () => { this.openThemeDialog(); },
|
||||
openModelPicker: () => this.openModelDialog(),
|
||||
openThinkingLevelPicker: () => this.openThinkingDialog(),
|
||||
selectMainView: (view) => { this.selectMainView(view); },
|
||||
selectWorkspaceTool: (tool) => { this.openWorkspaceTool(tool); },
|
||||
openTerminal: (options) => { this.openTerminal(options); },
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { WorkspaceController } from "../controllers/workspaceController";
|
||||
import { PiWebApp } from "./PiWebApp";
|
||||
|
||||
type RefreshCallback = () => void | Promise<void>;
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("PiWebApp workspace topology refresh wiring", () => {
|
||||
it("re-lists the selected project's workspaces on the browser-resume refresh", async () => {
|
||||
const app = createApp();
|
||||
stubBackgroundRefreshes(app);
|
||||
const refreshTopology = spyOnTopologyRefresh(app);
|
||||
|
||||
await browserResumeRefresh(app)();
|
||||
|
||||
expect(refreshTopology).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("re-lists the selected project's workspaces on the plugin-facing app-data refresh", async () => {
|
||||
const app = createApp();
|
||||
stubBackgroundRefreshes(app);
|
||||
const refreshTopology = spyOnTopologyRefresh(app);
|
||||
|
||||
await refreshAppData(app);
|
||||
|
||||
expect(refreshTopology).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it("still re-lists workspaces when a sibling refresh in the same resume batch fails", async () => {
|
||||
const app = createApp();
|
||||
stubBackgroundRefreshes(app);
|
||||
failBackgroundRefresh(app, "refreshMachineActivities", new Error("machine activity unavailable"));
|
||||
const refreshTopology = spyOnTopologyRefresh(app);
|
||||
|
||||
await expect(browserResumeRefresh(app)()).rejects.toThrow("machine activity unavailable");
|
||||
expect(refreshTopology).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
|
||||
function createApp(): PiWebApp {
|
||||
const storage = {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
removeItem: () => undefined,
|
||||
};
|
||||
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
|
||||
return new PiWebApp();
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the sibling refreshes that already have their own coverage so this test
|
||||
* observes only whether the resume/app-data paths include workspace topology.
|
||||
*/
|
||||
function stubBackgroundRefreshes(app: PiWebApp): void {
|
||||
const result = () => Promise.resolve();
|
||||
for (const name of [
|
||||
"renegotiateUnreadMachines",
|
||||
"refreshMachineActivities",
|
||||
"refreshWorkspaceDeletionRuns",
|
||||
"loadClientConfig",
|
||||
"refreshCurrentWorkspaceSurface",
|
||||
"schedulePiWebStatusRefresh",
|
||||
]) {
|
||||
if (!Reflect.set(app, name, result)) throw new Error(`Could not replace PiWebApp.${name}`);
|
||||
}
|
||||
const sessions: unknown = Reflect.get(app, "sessions");
|
||||
if (typeof sessions !== "object" || sessions === null || !Reflect.set(sessions, "refreshSelectedSession", result)) {
|
||||
throw new Error("Could not replace the selected-session refresh");
|
||||
}
|
||||
}
|
||||
|
||||
function failBackgroundRefresh(app: PiWebApp, name: string, error: Error): void {
|
||||
if (!Reflect.set(app, name, () => Promise.reject(error))) throw new Error(`Could not fail PiWebApp.${name}`);
|
||||
}
|
||||
|
||||
function spyOnTopologyRefresh(app: PiWebApp) {
|
||||
const controller: unknown = Reflect.get(app, "workspaces");
|
||||
if (!(controller instanceof WorkspaceController)) throw new Error("PiWebApp WorkspaceController was unavailable");
|
||||
return vi.spyOn(controller, "refreshSelectedProjectTopology").mockResolvedValue(undefined);
|
||||
}
|
||||
|
||||
/** The exact callback `BrowserResumeController` invokes after a focus/visibility signal. */
|
||||
function browserResumeRefresh(app: PiWebApp): RefreshCallback {
|
||||
const resume: unknown = Reflect.get(app, "browserResume");
|
||||
if (typeof resume !== "object" || resume === null) throw new Error("PiWebApp BrowserResumeController was unavailable");
|
||||
const callbacks: unknown = Reflect.get(resume, "callbacks");
|
||||
if (typeof callbacks !== "object" || callbacks === null) throw new Error("Browser resume callbacks were unavailable");
|
||||
const refresh: unknown = Reflect.get(callbacks, "refreshAfterResume");
|
||||
if (!isRefreshCallback(refresh)) throw new Error("The browser resume refresh callback was unavailable");
|
||||
return refresh;
|
||||
}
|
||||
|
||||
async function refreshAppData(app: PiWebApp): Promise<void> {
|
||||
const refresh: unknown = Reflect.get(app, "refreshAppData");
|
||||
if (!isRefreshCallback(refresh)) throw new Error("PiWebApp.refreshAppData is not callable");
|
||||
await refresh.call(app);
|
||||
}
|
||||
|
||||
function isRefreshCallback(value: unknown): value is RefreshCallback {
|
||||
return typeof value === "function";
|
||||
}
|
||||
@@ -16,6 +16,14 @@ describe("sessionRowActivityKind", () => {
|
||||
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false)).toBe("session");
|
||||
});
|
||||
|
||||
it("shows no active-work indicator for a session that is only starting up", () => {
|
||||
const startup = { sessionId: "s", phase: "active" as const, label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
|
||||
|
||||
expect(sessionRowActivityKind(session("s"), idle, startup, false)).toBeUndefined();
|
||||
// Ordinary activity is work and keeps its indicator.
|
||||
expect(sessionRowActivityKind(session("s"), idle, { sessionId: "s", phase: "active", label: "running tool", at: "now" }, false)).toBe("session");
|
||||
});
|
||||
|
||||
it("reports unread only while the session is idle", () => {
|
||||
expect(sessionRowActivityKind(session("s"), idle, undefined, false, true)).toBe("unread");
|
||||
expect(sessionRowActivityKind(session("s"), { ...idle, isStreaming: true }, undefined, false, true)).toBe("session");
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSessionActive } from "../../../shared/activity";
|
||||
import { initialAppState } from "../appState";
|
||||
import { SessionController } from "./sessionController";
|
||||
import { defaultApi, deferred, emptyPage, FakeSocket, oldSession, runPendingAnimationFrames, sessionLookupId, status, workspace, type AppState, type SessionActivity, type SessionInfo } from "./sessionController.testSupport";
|
||||
|
||||
const REMOTE_MACHINE = { id: "remote", name: "Remote", kind: "remote" as const, createdAt: "now", updatedAt: "now" };
|
||||
|
||||
function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity {
|
||||
return {
|
||||
sessionId: "backend-session",
|
||||
@@ -12,6 +11,7 @@ function startupActivity(patch: Partial<SessionActivity> = {}): SessionActivity
|
||||
label: "Creating session",
|
||||
detail: "Starting the Pi session",
|
||||
at: "2026-07-20T00:00:01.000Z",
|
||||
startup: true,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
@@ -21,8 +21,15 @@ function idleStartupActivity(): SessionActivity {
|
||||
return { sessionId: "backend-session", phase: "idle", label: "idle", at: "2026-07-20T00:00:02.000Z" };
|
||||
}
|
||||
|
||||
interface StartCall {
|
||||
cwd: string;
|
||||
machineId: string | undefined;
|
||||
startupToken: string | undefined;
|
||||
}
|
||||
|
||||
function pendingStartController(state: { current: AppState }, api: Partial<typeof defaultApi> = {}) {
|
||||
const startRequest = deferred<SessionInfo>();
|
||||
const startCalls: StartCall[] = [];
|
||||
const controller = new SessionController(
|
||||
() => state.current,
|
||||
(patch) => { state.current = { ...state.current, ...patch }; },
|
||||
@@ -31,7 +38,10 @@ function pendingStartController(state: { current: AppState }, api: Partial<typeo
|
||||
{
|
||||
api: {
|
||||
...defaultApi,
|
||||
startSession: () => startRequest.promise,
|
||||
startSession: (cwd: string, machineId?: string, startupToken?: string) => {
|
||||
startCalls.push({ cwd, machineId, startupToken });
|
||||
return startRequest.promise;
|
||||
},
|
||||
messages: () => Promise.resolve(emptyPage),
|
||||
status: (session) => Promise.resolve(status(sessionLookupId(session))),
|
||||
...api,
|
||||
@@ -39,7 +49,7 @@ function pendingStartController(state: { current: AppState }, api: Partial<typeo
|
||||
socket: new FakeSocket(),
|
||||
},
|
||||
);
|
||||
return { controller, startRequest };
|
||||
return { controller, startRequest, startCalls };
|
||||
}
|
||||
|
||||
describe("SessionController session startup progress", () => {
|
||||
@@ -51,7 +61,7 @@ describe("SessionController session startup progress", () => {
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
// The label changes while the user is waiting, before the start resolves,
|
||||
@@ -59,7 +69,7 @@ describe("SessionController session startup progress", () => {
|
||||
expect(state.current.activity).toMatchObject({ sessionId: temporaryId, phase: "active", label: "Creating session", detail: "Starting the Pi session" });
|
||||
expect(state.current.sessionActivities[temporaryId]).toMatchObject({ detail: "Starting the Pi session" });
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity({ detail: "Loading session extensions" }) });
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity({ detail: "Loading session extensions" }) });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity?.detail).toBe("Loading session extensions");
|
||||
@@ -68,6 +78,28 @@ describe("SessionController session startup progress", () => {
|
||||
await start;
|
||||
});
|
||||
|
||||
it("keeps a pending create row's own appearance while it borrows the daemon's phase text", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest } = pendingStartController(state);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
const beforeStartupProgress = isSessionActive(state.current.status, state.current.activity);
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
// This row is the browser's own pending create, not a session being opened.
|
||||
// Substituting the daemon's phase text into it must not silently change what
|
||||
// the row reports about itself, or its indicator would blink off mid-create.
|
||||
expect(state.current.activity?.phase).toBe("active");
|
||||
expect(isSessionActive(state.current.status, state.current.activity)).toBe(beforeStartupProgress);
|
||||
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await start;
|
||||
});
|
||||
|
||||
it("restores the generic wording when the daemon has nothing left to attribute", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest } = pendingStartController(state);
|
||||
@@ -75,10 +107,10 @@ describe("SessionController session startup progress", () => {
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: startupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() });
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: idleStartupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity).toMatchObject({
|
||||
@@ -98,7 +130,9 @@ describe("SessionController session startup progress", () => {
|
||||
|
||||
const start = controller.startSession();
|
||||
await controller.send("queued while starting");
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: idleStartupActivity() });
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: temporaryId, activity: idleStartupActivity() });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity?.detail).toBe("1 queued message will send when the backend session is ready");
|
||||
@@ -119,7 +153,6 @@ describe("SessionController session startup progress", () => {
|
||||
|
||||
controller.applyGlobalEvent({
|
||||
type: "session.startup",
|
||||
cwd: oldSession.cwd,
|
||||
activity: startupActivity({ sessionId: oldSession.id, label: "Opening session" }),
|
||||
});
|
||||
runPendingAnimationFrames();
|
||||
@@ -127,6 +160,29 @@ describe("SessionController session startup progress", () => {
|
||||
expect(state.activity).toMatchObject({ sessionId: oldSession.id, label: "Opening session", detail: "Starting the Pi session" });
|
||||
});
|
||||
|
||||
it("shows an archived session's opening progress without reporting it as active work", () => {
|
||||
const archived = { ...oldSession, id: "archived-session", archived: true, archivedAt: "2026-05-16T00:00:00.000Z" };
|
||||
let state: AppState = { ...initialAppState(), selectedSession: archived, sessions: [archived] };
|
||||
const controller = new SessionController(
|
||||
() => state,
|
||||
(patch) => { state = { ...state, ...patch }; },
|
||||
() => undefined,
|
||||
undefined,
|
||||
{ socket: new FakeSocket() },
|
||||
);
|
||||
|
||||
controller.applyGlobalEvent({
|
||||
type: "session.startup",
|
||||
activity: startupActivity({ sessionId: archived.id, label: "Opening session" }),
|
||||
});
|
||||
runPendingAnimationFrames();
|
||||
|
||||
// A read-only session cannot be worked on, so opening one must not make it
|
||||
// look busy — while the text a waiting user reads still arrives.
|
||||
expect(state.activity).toMatchObject({ sessionId: archived.id, label: "Opening session", detail: "Starting the Pi session" });
|
||||
expect(isSessionActive(state.status, state.activity)).toBe(false);
|
||||
});
|
||||
|
||||
it("gives an existing session's startup its own row rather than a pending start in the same workspace", async () => {
|
||||
const existing = { ...oldSession, id: "existing-session", cwd: workspace.path };
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [existing] } };
|
||||
@@ -136,12 +192,11 @@ describe("SessionController session startup progress", () => {
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
|
||||
// Opening an existing session in the same workspace publishes the same cwd as
|
||||
// the pending create. The known id is the proof of which row it belongs to, so
|
||||
// the pending row must keep its own wording instead of the other row's phase.
|
||||
// Opening an existing session in the same workspace carries no create token,
|
||||
// so the known id is the only proof of which row it belongs to and the pending
|
||||
// row must keep its own wording instead of the other row's phase.
|
||||
controller.applyGlobalEvent({
|
||||
type: "session.startup",
|
||||
cwd: workspace.path,
|
||||
activity: startupActivity({ sessionId: existing.id, label: "Opening session" }),
|
||||
});
|
||||
runPendingAnimationFrames();
|
||||
@@ -153,7 +208,7 @@ describe("SessionController session startup progress", () => {
|
||||
await start;
|
||||
});
|
||||
|
||||
it("keeps the generic wording when the startup progress cannot be attributed to one row", async () => {
|
||||
it("keeps the generic wording when no pending row's token matches the startup progress", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest } = pendingStartController(state);
|
||||
|
||||
@@ -161,29 +216,54 @@ describe("SessionController session startup progress", () => {
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
if (temporaryId === undefined) throw new Error("Expected temporary session id");
|
||||
|
||||
// Another workspace's startup.
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: "/elsewhere", activity: startupActivity() });
|
||||
// The selected machine's socket is the only feed for these events, so a cwd
|
||||
// that matches while another machine is selected belongs to a different row.
|
||||
state.current = { ...state.current, selectedMachine: REMOTE_MACHINE };
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
||||
state.current = { ...state.current, selectedMachine: undefined };
|
||||
// Another browser tab's create, or another workspace's: its token is one this
|
||||
// browser never minted, so there is no row here that it belongs to.
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: "pending-session-9-other-tab", activity: startupActivity() });
|
||||
// A session this browser has not been told about — an agent's spawned
|
||||
// subsession, say, whose `session.created` a pending create suppresses — is
|
||||
// opened rather than created, so it carries no token at all.
|
||||
controller.applyGlobalEvent({ type: "session.startup", activity: startupActivity({ sessionId: "foreign-session", label: "Opening session", detail: "Loading session extensions" }) });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
expect(state.current.activity?.detail).toBe("Waiting for the backend session to be ready");
|
||||
expect(state.current.activity?.label).toBe("Creating session");
|
||||
|
||||
// A second concurrent start in the same workspace makes the target ambiguous,
|
||||
// so neither row is given a phase that might belong to the other.
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await start;
|
||||
});
|
||||
|
||||
it("gives each of two concurrent creates only the progress its own token carries", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest } = pendingStartController(state);
|
||||
|
||||
const start = controller.startSession();
|
||||
const firstId = state.current.selectedSession?.id;
|
||||
const secondStart = controller.startSession();
|
||||
controller.applyGlobalEvent({ type: "session.startup", cwd: workspace.path, activity: startupActivity() });
|
||||
const secondId = state.current.selectedSession?.id;
|
||||
if (firstId === undefined || secondId === undefined || firstId === secondId) throw new Error("Expected two distinct temporary session ids");
|
||||
|
||||
// Two creates in the same workspace are indistinguishable by workspace path;
|
||||
// the token each request carried is what tells them apart.
|
||||
controller.applyGlobalEvent({ type: "session.startup", startupToken: secondId, activity: startupActivity({ detail: "Loading session extensions" }) });
|
||||
runPendingAnimationFrames();
|
||||
|
||||
const secondTemporaryId = state.current.selectedSession?.id;
|
||||
expect(secondTemporaryId).not.toBe(temporaryId);
|
||||
expect(state.current.sessionActivities[temporaryId]?.detail).toBe("Waiting for the backend session to be ready");
|
||||
expect(state.current.sessionActivities[secondTemporaryId ?? ""]?.detail).toBe("Waiting for the backend session to be ready");
|
||||
expect(state.current.sessionActivities[secondId]).toMatchObject({ sessionId: secondId, detail: "Loading session extensions" });
|
||||
expect(state.current.sessionActivities[firstId]?.detail).toBe("Waiting for the backend session to be ready");
|
||||
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await Promise.all([start, secondStart]);
|
||||
});
|
||||
|
||||
it("sends the pending row's own id as the create request's correlation token", async () => {
|
||||
const state = { current: { ...initialAppState(), selectedWorkspace: workspace, sessions: [] } };
|
||||
const { controller, startRequest, startCalls } = pendingStartController(state);
|
||||
|
||||
const start = controller.startSession();
|
||||
const temporaryId = state.current.selectedSession?.id;
|
||||
|
||||
expect(startCalls).toEqual([{ cwd: workspace.path, machineId: "local", startupToken: temporaryId }]);
|
||||
|
||||
startRequest.resolve({ ...oldSession, id: "backend-session", path: "/tmp/backend-session.jsonl" });
|
||||
await start;
|
||||
});
|
||||
});
|
||||
|
||||
@@ -186,7 +186,7 @@ export class SessionController {
|
||||
this.pendingSessionStarts.set(pending.tempId, pending);
|
||||
this.insertAndSelectPendingSession(pending.session);
|
||||
try {
|
||||
const session = await this.api.startSession(workspace.path, machineId);
|
||||
const session = await this.api.startSession(workspace.path, machineId, pending.tempId);
|
||||
await this.resolvePendingSessionStart(pending.tempId, session);
|
||||
} catch (error) {
|
||||
this.failPendingSessionStart(pending.tempId, error);
|
||||
@@ -1387,38 +1387,26 @@ export class SessionController {
|
||||
}
|
||||
|
||||
// Session startup progress arrives while the daemon is still constructing the
|
||||
// session, so the target row is resolved by session id when the browser knows
|
||||
// it and by workspace path when it does not: a pending start knows its cwd but
|
||||
// not the session id the daemon is creating. Once the row is resolved the
|
||||
// progress goes through the normal activity buffer, so it renders exactly like
|
||||
// any other activity and stays batched per frame.
|
||||
// session, so the target row is resolved by exact identity only: a session id
|
||||
// the browser already knows (an open), else the correlation token this browser
|
||||
// minted for its own create and the daemon echoed back. Matching neither means
|
||||
// the row is not one this browser shows — an agent's or another tab's session is
|
||||
// *deliberately* absent while a create is pending — so it is ignored rather than
|
||||
// guessed at. A resolved row goes through the normal activity buffer, rendering
|
||||
// like any other activity and staying batched per frame.
|
||||
private queueStartupProgress(event: SessionStartupProgressEvent): void {
|
||||
// A known session id is the strongest possible proof of the target, so it is
|
||||
// checked first: while a create is pending in a workspace, an *existing*
|
||||
// session in that same workspace can be opened too (another row selected,
|
||||
// another tab, a subsession), and that open publishes the same cwd. Matching
|
||||
// on cwd first would paint the pending row with another session's phase.
|
||||
if (this.getState().sessions.some((session) => session.id === event.activity.sessionId)) {
|
||||
this.queueActivityUpdate(event.activity);
|
||||
return;
|
||||
}
|
||||
// The id is unknown, so this can only be a create whose id the browser has
|
||||
// not been told yet. Route it by workspace path, the one key both sides share.
|
||||
const pending = this.startupProgressPendingStart(event.cwd);
|
||||
if (pending === undefined) return;
|
||||
const pending = event.startupToken === undefined ? undefined : this.pendingSessionStarts.get(event.startupToken);
|
||||
if (pending === undefined || pending.discarded) return;
|
||||
// An idle startup phase means the daemon has nothing left to attribute, so
|
||||
// restore this row's own generic wording rather than clearing the text of a
|
||||
// creation request that has not returned yet.
|
||||
this.queueActivityUpdate(event.activity.phase === "idle"
|
||||
? creatingPendingSessionActivity(pending.tempId, pending.queuedSends.length)
|
||||
: { ...event.activity, sessionId: pending.tempId });
|
||||
}
|
||||
|
||||
private startupProgressPendingStart(cwd: string): PendingSessionStart | undefined {
|
||||
const machineId = selectedMachineId(this.getState());
|
||||
const matches = Array.from(this.pendingSessionStarts.values())
|
||||
.filter((pending) => pending.cwd === cwd && pending.machineId === machineId && !pending.discarded);
|
||||
return matches.length === 1 ? matches[0] : undefined;
|
||||
: pendingStartActivity(event.activity, pending.tempId));
|
||||
}
|
||||
|
||||
private schedulePendingFlush(): void {
|
||||
@@ -1519,6 +1507,24 @@ function isClientPendingStartSessionInfo(session: SessionInfo | undefined): sess
|
||||
return session !== undefined && "clientPendingStart" in session && session.clientPendingStart === true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-label a startup report onto the browser's own pending create row.
|
||||
*
|
||||
* The daemon's startup marker is dropped: this row stands for a create the user
|
||||
* asked for and is waiting on, which the browser has always reported as active
|
||||
* work in progress, rather than for a session the daemon is opening. Only the
|
||||
* phase text is borrowed, so the row keeps its own `creating · ` appearance.
|
||||
*/
|
||||
function pendingStartActivity(activity: SessionActivity, sessionId: string): SessionActivity {
|
||||
return {
|
||||
sessionId,
|
||||
phase: activity.phase,
|
||||
label: activity.label,
|
||||
...(activity.detail === undefined ? {} : { detail: activity.detail }),
|
||||
at: activity.at,
|
||||
};
|
||||
}
|
||||
|
||||
function creatingPendingSessionActivity(sessionId: string, queuedCount = 0): SessionActivity {
|
||||
return {
|
||||
sessionId,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { AppState } from "../appState";
|
||||
import { initialAppState } from "../appState";
|
||||
import type { Machine, Project, SessionInfo, Workspace } from "../api";
|
||||
import type { SessionController } from "./sessionController";
|
||||
import { WorkspaceController } from "./workspaceController";
|
||||
|
||||
function machine(id: string): Machine {
|
||||
return { id, name: id, kind: id === "local" ? "local" : "remote", createdAt: "now", updatedAt: "now" };
|
||||
}
|
||||
|
||||
function project(id: string, path: string): Project {
|
||||
return { id, name: id, path, createdAt: "now" };
|
||||
}
|
||||
|
||||
function workspace(projectId: string, path: string, options: { isMain?: boolean } = {}): Workspace {
|
||||
return { id: path, projectId, path, label: path, isMain: options.isMain ?? false, isGitRepo: true, isGitWorktree: true };
|
||||
}
|
||||
|
||||
function session(cwd: string, id = "s1"): SessionInfo {
|
||||
return { id, cwd, path: `${cwd}/.sessions/${id}`, created: "now", modified: "now", messageCount: 1, firstMessage: "hello" };
|
||||
}
|
||||
|
||||
type LoadWorkspaces = (projectId: string, machineId?: string) => Promise<Workspace[]>;
|
||||
|
||||
interface Harness {
|
||||
controller: WorkspaceController;
|
||||
state: () => AppState;
|
||||
clearActiveSession: ReturnType<typeof vi.fn>;
|
||||
updateUrl: ReturnType<typeof vi.fn>;
|
||||
backgroundErrors: { message: string; error: unknown }[];
|
||||
setState: (patch: Partial<AppState>) => void;
|
||||
}
|
||||
|
||||
function harness(initial: Partial<AppState>, loadWorkspaces: LoadWorkspaces): Harness {
|
||||
let state: AppState = { ...initialAppState(), ...initial };
|
||||
const setState = (patch: Partial<AppState>) => { state = { ...state, ...patch }; };
|
||||
const clearActiveSession = vi.fn();
|
||||
const sessions: Pick<SessionController, "clearActiveSession" | "preferredSession" | "selectSession"> = {
|
||||
clearActiveSession,
|
||||
preferredSession: vi.fn(),
|
||||
selectSession: vi.fn(),
|
||||
};
|
||||
const updateUrl = vi.fn();
|
||||
const backgroundErrors: { message: string; error: unknown }[] = [];
|
||||
const controller = new WorkspaceController(
|
||||
() => state,
|
||||
setState,
|
||||
updateUrl,
|
||||
sessions,
|
||||
undefined,
|
||||
{
|
||||
api: { workspaces: loadWorkspaces, sessions: vi.fn<(path: string, machineId?: string) => Promise<SessionInfo[]>>().mockResolvedValue([]) },
|
||||
onBackgroundError: (message, error) => { backgroundErrors.push({ message, error }); },
|
||||
},
|
||||
);
|
||||
return { controller, state: () => state, clearActiveSession, updateUrl, backgroundErrors, setState };
|
||||
}
|
||||
|
||||
describe("WorkspaceController.refreshSelectedProjectTopology", () => {
|
||||
it("surfaces a worktree created outside PI WEB in both the selected list and the per-project cache", async () => {
|
||||
const repo = project("p1", "/repo");
|
||||
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||
const created = workspace(repo.id, "/repo-feature");
|
||||
const loadWorkspaces = vi.fn().mockResolvedValue([main, created]);
|
||||
const test = harness(
|
||||
{
|
||||
selectedMachine: machine("local"),
|
||||
projects: [repo],
|
||||
selectedProject: repo,
|
||||
selectedWorkspace: main,
|
||||
workspaces: [main],
|
||||
workspacesByProjectId: { [repo.id]: [main] },
|
||||
},
|
||||
loadWorkspaces,
|
||||
);
|
||||
|
||||
await test.controller.refreshSelectedProjectTopology();
|
||||
|
||||
expect(loadWorkspaces).toHaveBeenCalledWith(repo.id, "local");
|
||||
expect(test.state().workspaces).toEqual([main, created]);
|
||||
expect(test.state().workspacesByProjectId[repo.id]).toEqual([main, created]);
|
||||
});
|
||||
|
||||
it("preserves the selection and workspace-scoped state when the selected workspace still exists", async () => {
|
||||
const repo = project("p1", "/repo");
|
||||
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||
const selected = workspace(repo.id, "/repo-feature");
|
||||
const loadWorkspaces = vi.fn().mockResolvedValue([main, selected, workspace(repo.id, "/repo-other")]);
|
||||
const test = harness(
|
||||
{
|
||||
selectedMachine: machine("local"),
|
||||
projects: [repo],
|
||||
selectedProject: repo,
|
||||
selectedWorkspace: selected,
|
||||
workspaces: [main, selected],
|
||||
workspacesByProjectId: { [repo.id]: [main, selected] },
|
||||
selectedSession: session(selected.path),
|
||||
sessions: [session(selected.path)],
|
||||
selectedFilePath: "src/index.ts",
|
||||
expandedDirs: { src: [] },
|
||||
selectedTerminalId: "t1",
|
||||
},
|
||||
loadWorkspaces,
|
||||
);
|
||||
const before = test.state();
|
||||
|
||||
await test.controller.refreshSelectedProjectTopology();
|
||||
|
||||
const after = test.state();
|
||||
expect(after.selectedWorkspace).toBe(selected);
|
||||
expect(after.selectedSession).toBe(before.selectedSession);
|
||||
expect(after.sessions).toBe(before.sessions);
|
||||
expect(after.selectedFilePath).toBe("src/index.ts");
|
||||
expect(after.expandedDirs).toBe(before.expandedDirs);
|
||||
expect(after.selectedTerminalId).toBe("t1");
|
||||
expect(test.clearActiveSession).not.toHaveBeenCalled();
|
||||
expect(test.updateUrl).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the selection alone when the selected workspace disappeared", async () => {
|
||||
const repo = project("p1", "/repo");
|
||||
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||
const removed = workspace(repo.id, "/repo-gone");
|
||||
const loadWorkspaces = vi.fn().mockResolvedValue([main]);
|
||||
const test = harness(
|
||||
{
|
||||
selectedMachine: machine("local"),
|
||||
projects: [repo],
|
||||
selectedProject: repo,
|
||||
selectedWorkspace: removed,
|
||||
workspaces: [main, removed],
|
||||
workspacesByProjectId: { [repo.id]: [main, removed] },
|
||||
},
|
||||
loadWorkspaces,
|
||||
);
|
||||
|
||||
await test.controller.refreshSelectedProjectTopology();
|
||||
|
||||
expect(test.state().selectedWorkspace).toBe(removed);
|
||||
expect(test.state().workspaces).toEqual([main]);
|
||||
expect(test.clearActiveSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("discards a response for a project the user has since left", async () => {
|
||||
const repo = project("p1", "/repo");
|
||||
const other = project("p2", "/other");
|
||||
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||
const created = workspace(repo.id, "/repo-feature");
|
||||
let resolveWorkspaces: ((workspaces: Workspace[]) => void) | undefined;
|
||||
const loadWorkspaces = vi.fn().mockReturnValue(new Promise<Workspace[]>((resolve) => { resolveWorkspaces = resolve; }));
|
||||
const test = harness(
|
||||
{
|
||||
selectedMachine: machine("local"),
|
||||
projects: [repo, other],
|
||||
selectedProject: repo,
|
||||
selectedWorkspace: main,
|
||||
workspaces: [main],
|
||||
workspacesByProjectId: { [repo.id]: [main] },
|
||||
},
|
||||
loadWorkspaces,
|
||||
);
|
||||
|
||||
const pending = test.controller.refreshSelectedProjectTopology();
|
||||
test.setState({ selectedProject: other, selectedWorkspace: undefined, workspaces: [] });
|
||||
resolveWorkspaces?.([main, created]);
|
||||
await pending;
|
||||
|
||||
expect(test.state().workspaces).toEqual([]);
|
||||
expect(test.state().workspacesByProjectId[repo.id]).toEqual([main]);
|
||||
});
|
||||
|
||||
it("discards a response after the selected machine changed", async () => {
|
||||
const repo = project("p1", "/repo");
|
||||
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||
let resolveWorkspaces: ((workspaces: Workspace[]) => void) | undefined;
|
||||
const loadWorkspaces = vi.fn().mockReturnValue(new Promise<Workspace[]>((resolve) => { resolveWorkspaces = resolve; }));
|
||||
const test = harness(
|
||||
{
|
||||
selectedMachine: machine("local"),
|
||||
projects: [repo],
|
||||
selectedProject: repo,
|
||||
selectedWorkspace: main,
|
||||
workspaces: [main],
|
||||
workspacesByProjectId: { [repo.id]: [main] },
|
||||
},
|
||||
loadWorkspaces,
|
||||
);
|
||||
|
||||
const pending = test.controller.refreshSelectedProjectTopology();
|
||||
test.setState({ selectedMachine: machine("remote") });
|
||||
resolveWorkspaces?.([main, workspace(repo.id, "/repo-feature")]);
|
||||
await pending;
|
||||
|
||||
expect(test.state().workspaces).toEqual([main]);
|
||||
expect(test.state().workspacesByProjectId[repo.id]).toEqual([main]);
|
||||
});
|
||||
|
||||
it("reports a failed refresh to the background error sink without painting an error banner", async () => {
|
||||
const repo = project("p1", "/repo");
|
||||
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||
const failure = new Error("git worktree list failed");
|
||||
const loadWorkspaces = vi.fn().mockRejectedValue(failure);
|
||||
const test = harness(
|
||||
{
|
||||
selectedMachine: machine("local"),
|
||||
projects: [repo],
|
||||
selectedProject: repo,
|
||||
selectedWorkspace: main,
|
||||
workspaces: [main],
|
||||
workspacesByProjectId: { [repo.id]: [main] },
|
||||
},
|
||||
loadWorkspaces,
|
||||
);
|
||||
|
||||
await test.controller.refreshSelectedProjectTopology();
|
||||
|
||||
expect(test.state().error).toBe("");
|
||||
expect(test.state().workspaces).toEqual([main]);
|
||||
expect(test.backgroundErrors).toEqual([{ message: `Failed to refresh workspaces for project ${repo.id} on local`, error: failure }]);
|
||||
});
|
||||
|
||||
it("re-points the selected workspace at refreshed metadata when its branch changed outside PI WEB", async () => {
|
||||
const repo = project("p1", "/repo");
|
||||
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||
const selected = { ...workspace(repo.id, "/repo-feature"), label: "feature-a", branch: "feature-a" };
|
||||
const switched = { ...selected, label: "feature-b", branch: "feature-b" };
|
||||
const loadWorkspaces = vi.fn().mockResolvedValue([main, switched]);
|
||||
const test = harness(
|
||||
{
|
||||
selectedMachine: machine("local"),
|
||||
projects: [repo],
|
||||
selectedProject: repo,
|
||||
selectedWorkspace: selected,
|
||||
workspaces: [main, selected],
|
||||
workspacesByProjectId: { [repo.id]: [main, selected] },
|
||||
selectedSession: session(selected.path),
|
||||
},
|
||||
loadWorkspaces,
|
||||
);
|
||||
|
||||
await test.controller.refreshSelectedProjectTopology();
|
||||
|
||||
// Same workspace (same id/path), so the session must survive; only the stale label moves.
|
||||
expect(test.state().selectedWorkspace).toEqual(switched);
|
||||
expect(test.state().selectedWorkspace?.id).toBe(selected.id);
|
||||
expect(test.state().selectedSession).toBeDefined();
|
||||
expect(test.clearActiveSession).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("leaves the selected workspace object untouched when the refresh returns identical metadata", async () => {
|
||||
const repo = project("p1", "/repo");
|
||||
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||
const selected = workspace(repo.id, "/repo-feature");
|
||||
// A fresh, equal object, exactly what a real HTTP response produces every resume.
|
||||
const loadWorkspaces = vi.fn().mockResolvedValue([{ ...main }, { ...selected }]);
|
||||
const test = harness(
|
||||
{
|
||||
selectedMachine: machine("local"),
|
||||
projects: [repo],
|
||||
selectedProject: repo,
|
||||
selectedWorkspace: selected,
|
||||
workspaces: [main, selected],
|
||||
workspacesByProjectId: { [repo.id]: [main, selected] },
|
||||
},
|
||||
loadWorkspaces,
|
||||
);
|
||||
|
||||
await test.controller.refreshSelectedProjectTopology();
|
||||
|
||||
// Identity preserved: an unchanged resume must not churn selected-workspace identity
|
||||
// into state, or every focus would re-render surfaces keyed on this object.
|
||||
expect(test.state().selectedWorkspace).toBe(selected);
|
||||
});
|
||||
|
||||
it("serializes overlapping refreshes so an earlier response cannot overwrite a newer list", async () => {
|
||||
const repo = project("p1", "/repo");
|
||||
const main = workspace(repo.id, repo.path, { isMain: true });
|
||||
const created = workspace(repo.id, "/repo-feature");
|
||||
const gates: ((workspaces: Workspace[]) => void)[] = [];
|
||||
let inFlight = 0;
|
||||
let maxInFlight = 0;
|
||||
const loadWorkspaces = vi.fn(() => {
|
||||
inFlight += 1;
|
||||
maxInFlight = Math.max(maxInFlight, inFlight);
|
||||
return new Promise<Workspace[]>((resolve) => {
|
||||
gates.push((workspaces) => { inFlight -= 1; resolve(workspaces); });
|
||||
});
|
||||
});
|
||||
const test = harness(
|
||||
{
|
||||
selectedMachine: machine("local"),
|
||||
projects: [repo],
|
||||
selectedProject: repo,
|
||||
selectedWorkspace: main,
|
||||
workspaces: [main],
|
||||
workspacesByProjectId: { [repo.id]: [main] },
|
||||
},
|
||||
loadWorkspaces,
|
||||
);
|
||||
|
||||
const resumeRefresh = test.controller.refreshSelectedProjectTopology();
|
||||
await Promise.resolve();
|
||||
const appDataRefresh = test.controller.refreshSelectedProjectTopology();
|
||||
await Promise.resolve();
|
||||
|
||||
// The second caller does not open its own request while the first is in flight; it gets
|
||||
// one trailing pass afterwards. Without this, two responses race and the slower-but-older
|
||||
// one can land last, making a just-created worktree disappear again.
|
||||
expect(maxInFlight).toBe(1);
|
||||
gates[0]?.([main]);
|
||||
await vi.waitFor(() => { expect(gates).toHaveLength(2); });
|
||||
gates[1]?.([main, created]);
|
||||
await Promise.all([resumeRefresh, appDataRefresh]);
|
||||
|
||||
// The last response wins, so the newly created worktree stays visible.
|
||||
expect(test.state().workspaces).toEqual([main, created]);
|
||||
});
|
||||
|
||||
it("does not request anything when no project is selected", async () => {
|
||||
const loadWorkspaces = vi.fn();
|
||||
const test = harness({ selectedMachine: machine("local") }, loadWorkspaces);
|
||||
|
||||
await test.controller.refreshSelectedProjectTopology();
|
||||
|
||||
expect(loadWorkspaces).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,17 +1,21 @@
|
||||
import { api as defaultApi, type Project, type Workspace } from "../api";
|
||||
import { resetWorkspaceScopedState } from "../appState";
|
||||
import { resetWorkspaceScopedState, type AppState } from "../appState";
|
||||
import { mergeCachedNewSessions } from "../cachedNewSessions";
|
||||
import { machineProjectKey } from "../machineKeys";
|
||||
import { selectedMachineId, type GetState, type RouteTarget, type SetState, type UpdateUrl } from "./types";
|
||||
import type { SessionController } from "./sessionController";
|
||||
import { TrailingRefreshCoordinator } from "./trailingRefreshCoordinator";
|
||||
import { InMemoryWorkspaceSelectionMemory, selectPreferredWorkspace, type WorkspaceSelectionMemory } from "./workspaceSelection";
|
||||
|
||||
export interface WorkspaceControllerDependencies {
|
||||
api?: Pick<typeof defaultApi, "sessions" | "workspaces">;
|
||||
onBackgroundError?: (message: string, error: unknown) => void;
|
||||
}
|
||||
|
||||
export class WorkspaceController {
|
||||
private readonly api: Pick<typeof defaultApi, "sessions" | "workspaces">;
|
||||
private readonly onBackgroundError: (message: string, error: unknown) => void;
|
||||
private readonly topologyRefreshes = new TrailingRefreshCoordinator<string>();
|
||||
|
||||
constructor(
|
||||
private readonly getState: GetState,
|
||||
@@ -22,6 +26,7 @@ export class WorkspaceController {
|
||||
deps: WorkspaceControllerDependencies = {},
|
||||
) {
|
||||
this.api = deps.api ?? defaultApi;
|
||||
this.onBackgroundError = deps.onBackgroundError ?? ((message, error) => { console.warn(message, error); });
|
||||
}
|
||||
|
||||
clearSelection(options?: { updateUrl?: boolean | undefined }) {
|
||||
@@ -78,6 +83,40 @@ export class WorkspaceController {
|
||||
return workspaces;
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-lists the selected project's workspaces so worktrees created or removed outside
|
||||
* PI WEB become visible, without disturbing the current selection.
|
||||
*
|
||||
* Deliberately never routes through `selectWorkspace`: that has no already-selected
|
||||
* guard, so re-picking the same workspace would still call `clearActiveSession()` and
|
||||
* `resetWorkspaceScopedState()`, closing the session socket and blanking chat, file
|
||||
* tree, git status, and terminal selection. Callers run this on every browser resume,
|
||||
* so applying the list through `applyProjectWorkspaces` alone is the invariant.
|
||||
*
|
||||
* If the selected workspace disappeared, the selection is left alone: the user is
|
||||
* working there and the existing deletion path owns recovery.
|
||||
*/
|
||||
async refreshSelectedProjectTopology(): Promise<void> {
|
||||
const state = this.getState();
|
||||
const project = state.selectedProject;
|
||||
if (project === undefined) return;
|
||||
const machineId = selectedMachineId(state);
|
||||
// Callers are independent (browser resume and the plugin-facing app refresh), so two
|
||||
// refreshes for the same machine+project can overlap. Sharing one request keeps a slow
|
||||
// earlier response from landing last and overwriting a newer list, which would make a
|
||||
// just-created worktree disappear again.
|
||||
await this.topologyRefreshes.request(machineProjectKey(machineId, project.id), async () => {
|
||||
try {
|
||||
const workspaces = await this.api.workspaces(project.id, machineId);
|
||||
const current = this.getState();
|
||||
if (selectedMachineId(current) !== machineId || current.selectedProject?.id !== project.id) return;
|
||||
this.applyProjectWorkspaces(project.id, workspaces);
|
||||
} catch (error) {
|
||||
this.onBackgroundError(`Failed to refresh workspaces for project ${project.id} on ${machineId}`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async refreshAfterWorkspaceDeleted(projectId: string, workspaceId: string): Promise<void> {
|
||||
const workspaces = await this.refreshProjectWorkspaces(projectId);
|
||||
const state = this.getState();
|
||||
@@ -91,8 +130,26 @@ export class WorkspaceController {
|
||||
private applyProjectWorkspaces(projectId: string, workspaces: Workspace[]): void {
|
||||
const state = this.getState();
|
||||
const workspacesByProjectId = { ...state.workspacesByProjectId, [projectId]: workspaces };
|
||||
if (state.selectedProject?.id === projectId) this.setState({ workspaces, workspacesByProjectId });
|
||||
else this.setState({ workspacesByProjectId });
|
||||
if (state.selectedProject?.id !== projectId) {
|
||||
this.setState({ workspacesByProjectId });
|
||||
return;
|
||||
}
|
||||
this.setState({ workspaces, workspacesByProjectId, ...this.refreshedSelection(state.selectedWorkspace, workspaces) });
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-points `selectedWorkspace` at its refreshed entry when metadata changed outside PI WEB
|
||||
* (a branch switched in the worktree, say), so the workspace list and the surfaces that read
|
||||
* the selected workspace cannot disagree. Keyed by id, which is derived from the path, so
|
||||
* this never changes *which* workspace is selected and never triggers the session/terminal
|
||||
* teardown in `handleWorkspaceChange`. Returns nothing when the entry is gone or unchanged,
|
||||
* so an unchanged refresh does not churn object identity into state.
|
||||
*/
|
||||
private refreshedSelection(selected: Workspace | undefined, workspaces: Workspace[]): Pick<AppState, "selectedWorkspace"> | undefined {
|
||||
if (selected === undefined) return undefined;
|
||||
const refreshed = workspaces.find((candidate) => candidate.id === selected.id);
|
||||
if (refreshed === undefined || sameWorkspaceMetadata(selected, refreshed)) return undefined;
|
||||
return { selectedWorkspace: refreshed };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -104,3 +161,12 @@ function selectFallbackWorkspace(workspaces: Workspace[]): Workspace | undefined
|
||||
return workspaces.find((workspace) => workspace.isMain) ?? workspaces[0];
|
||||
}
|
||||
|
||||
function sameWorkspaceMetadata(left: Workspace, right: Workspace): boolean {
|
||||
return left.path === right.path
|
||||
&& left.label === right.label
|
||||
&& left.branch === right.branch
|
||||
&& left.isMain === right.isMain
|
||||
&& left.isGitRepo === right.isGitRepo
|
||||
&& left.isGitWorktree === right.isGitWorktree;
|
||||
}
|
||||
|
||||
|
||||
@@ -167,6 +167,22 @@ export function createCoreActions(): PluginAction[] {
|
||||
enabled: hasWorkspace,
|
||||
run: (context) => context.startSession(),
|
||||
},
|
||||
{
|
||||
id: "model.select",
|
||||
title: "Select Model",
|
||||
description: "Choose the model for the selected session",
|
||||
group: "Session",
|
||||
enabled: hasSelectableSession,
|
||||
run: (context) => context.openModelPicker(),
|
||||
},
|
||||
{
|
||||
id: "thinking.select",
|
||||
title: "Select Thinking Level",
|
||||
description: "Choose the thinking level for the selected session",
|
||||
group: "Session",
|
||||
enabled: hasSelectableSession,
|
||||
run: (context) => context.openThinkingLevelPicker(),
|
||||
},
|
||||
{
|
||||
id: "session.archive",
|
||||
title: "Archive Session",
|
||||
@@ -216,6 +232,11 @@ function hasDeletableWorkspace(context: { state: AppState }): boolean {
|
||||
return workspace !== undefined && workspace.isGitWorktree && !workspace.isMain && !isWorkspaceDeletionPending(context.state, workspace);
|
||||
}
|
||||
|
||||
function hasSelectableSession(context: { state: AppState }): boolean {
|
||||
const session = context.state.selectedSession;
|
||||
return session !== undefined && session.archived !== true;
|
||||
}
|
||||
|
||||
function hasArchivableSession(context: { state: AppState }): boolean {
|
||||
return isArchivableSessionInfo(context.state.selectedSession, context.state.status, sessionPersistenceOptions(context.state));
|
||||
}
|
||||
|
||||
@@ -38,6 +38,8 @@ function createContext(statePatch: Partial<AppState> = {}) {
|
||||
configureAuth: vi.fn(() => { calls.push("configureAuth"); }),
|
||||
logoutAuth: vi.fn(() => { calls.push("logoutAuth"); }),
|
||||
openThemePicker: vi.fn(() => { calls.push("openThemePicker"); }),
|
||||
openModelPicker: vi.fn(() => { calls.push("openModelPicker"); }),
|
||||
openThinkingLevelPicker: vi.fn(() => { calls.push("openThinkingLevelPicker"); }),
|
||||
selectMainView: vi.fn((view: AppState["mainView"]) => { calls.push(`selectMainView:${view}`); }),
|
||||
selectWorkspaceTool: vi.fn((tool: AppState["workspaceTool"]) => { calls.push(`selectWorkspaceTool:${tool}`); }),
|
||||
openTerminal: vi.fn((options?: { terminalId?: string | undefined }) => { calls.push(`openTerminal:${options?.terminalId ?? ""}`); }),
|
||||
@@ -249,6 +251,25 @@ describe("PluginRegistry", () => {
|
||||
expect(busy.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("treats a session that is only starting up as having no work to stop or block", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
const reloadRuntime = { local: { machineId: "local", ok: true as const, checkedAt: "now", capabilities: [PI_WEB_CAPABILITIES.sessionsReload, PI_WEB_CAPABILITIES.sessionsPersistedState] } };
|
||||
const startupActivity = { sessionId: "s1", phase: "active" as const, label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
|
||||
|
||||
const opening = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: true }), activity: startupActivity, machineRuntimes: reloadRuntime }).context);
|
||||
|
||||
// Nothing is being worked on, so there is nothing to stop and no reason to
|
||||
// block a reload with "Stop current session activity before reloading".
|
||||
expect(opening.find((action) => action.id === "core:session.stop")?.enabled).toBe(false);
|
||||
expect(opening.find((action) => action.id === "core:session.reload")?.enabled).toBe(true);
|
||||
|
||||
// Real work is still real work, whatever else the session is doing.
|
||||
const working = registry.getActions(createContext({ selectedSession: testSession({ persisted: true }), status: testStatus({ persisted: true, isStreaming: true }), activity: startupActivity, machineRuntimes: reloadRuntime }).context);
|
||||
expect(working.find((action) => action.id === "core:session.stop")?.enabled).toBe(true);
|
||||
expect(working.find((action) => action.id === "core:session.reload")?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it("routes session reload through the runtime context", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
@@ -271,6 +292,34 @@ describe("PluginRegistry", () => {
|
||||
expect(calls).toEqual(["deleteCachedNewSession"]);
|
||||
});
|
||||
|
||||
it("exposes model and thinking selectors as configurable actions for writable sessions", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
|
||||
const unavailable = registry.getActions(createContext().context);
|
||||
expect(unavailable.find((action) => action.id === "core:model.select")?.enabled).toBe(false);
|
||||
expect(unavailable.find((action) => action.id === "core:thinking.select")?.enabled).toBe(false);
|
||||
|
||||
const archivedSession = { ...testSession(), archived: true, archivedAt: "2026-05-20T00:00:00.000Z" };
|
||||
const archived = registry.getActions(createContext({ selectedSession: archivedSession }).context);
|
||||
expect(archived.find((action) => action.id === "core:model.select")?.enabled).toBe(false);
|
||||
expect(archived.find((action) => action.id === "core:thinking.select")?.enabled).toBe(false);
|
||||
|
||||
const { context, calls } = createContext({ selectedSession: testSession() });
|
||||
const actions = registry.getActions(context);
|
||||
const modelAction = actions.find((action) => action.id === "core:model.select");
|
||||
const thinkingAction = actions.find((action) => action.id === "core:thinking.select");
|
||||
expect(modelAction).toMatchObject({ title: "Select Model", enabled: true });
|
||||
expect(modelAction?.shortcut).toBeUndefined();
|
||||
expect(thinkingAction).toMatchObject({ title: "Select Thinking Level", enabled: true });
|
||||
expect(thinkingAction?.shortcut).toBeUndefined();
|
||||
|
||||
if (modelAction !== undefined) void modelAction.run();
|
||||
if (thinkingAction !== undefined) void thinkingAction.run();
|
||||
|
||||
expect(calls).toEqual(["openModelPicker", "openThinkingLevelPicker"]);
|
||||
});
|
||||
|
||||
it("routes refresh current to the active core workspace panel", () => {
|
||||
const registry = new PluginRegistry();
|
||||
registry.register({ id: "core", plugin: corePlugin });
|
||||
|
||||
@@ -106,6 +106,8 @@ export interface PluginRuntimeContext {
|
||||
configureAuth: () => void | Promise<void>;
|
||||
logoutAuth: () => void | Promise<void>;
|
||||
openThemePicker: () => void;
|
||||
openModelPicker: () => void | Promise<void>;
|
||||
openThinkingLevelPicker: () => void | Promise<void>;
|
||||
selectMainView: (view: AppState["mainView"]) => void;
|
||||
selectWorkspaceTool: (tool: QualifiedContributionId) => void;
|
||||
openTerminal: (options?: { terminalId?: string | undefined }) => void;
|
||||
|
||||
@@ -90,17 +90,28 @@ describe("notification socket guards", () => {
|
||||
})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("carries the startup marker through the socket boundary, marker and all", () => {
|
||||
const activity = { sessionId: "session-1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z", startup: true };
|
||||
|
||||
// The marker is what stops an opening session being treated as a working
|
||||
// one, so dropping it in transit would restore the defect for every frame,
|
||||
// including those relayed from a remote machine.
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", activity })).toMatchObject({ type: "session.startup", activity: { startup: true } });
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", activity: { ...activity, startup: 1 } })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts validated session startup progress and drops malformed frames", () => {
|
||||
const activity = { sessionId: "session-1", phase: "active", label: "Creating session", detail: "Starting the Pi session", at: "2026-07-20T00:00:01.000Z" };
|
||||
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo", activity }))
|
||||
.toMatchObject({ type: "session.startup", cwd: "/repo", activity });
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "", activity })).toBeUndefined();
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo" })).toBeUndefined();
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", cwd: "/repo", activity: { ...activity, phase: "waiting" } })).toBeUndefined();
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", startupToken: "pending-session-1-abc", activity }))
|
||||
.toMatchObject({ type: "session.startup", startupToken: "pending-session-1-abc", activity });
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", activity })).toMatchObject({ type: "session.startup", activity });
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", startupToken: "", activity })).toBeUndefined();
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup" })).toBeUndefined();
|
||||
expect(parseRealtimeSocketEvent({ type: "session.startup", activity: { ...activity, phase: "waiting" } })).toBeUndefined();
|
||||
// Startup progress is global-only, so it must not be accepted as a
|
||||
// per-session frame even when it is well formed.
|
||||
expect(parseSessionSocketEvent({ type: "session.startup", cwd: "/repo", activity })).toBeUndefined();
|
||||
expect(parseSessionSocketEvent({ type: "session.startup", activity })).toBeUndefined();
|
||||
});
|
||||
|
||||
it("accepts validated ask frames and drops malformed ones", () => {
|
||||
|
||||
@@ -32,6 +32,19 @@ describe("WorkspaceActivityService", () => {
|
||||
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false } });
|
||||
});
|
||||
|
||||
it("does not report a workspace active for a session that is only starting up", () => {
|
||||
const events: RealtimeEvent[] = [];
|
||||
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
|
||||
|
||||
// Startup progress names a phase the daemon is inside; it is not work, so the
|
||||
// workspace (and the project indicators and remote machines that read it)
|
||||
// must not be reported as busy because of it.
|
||||
service.applySessionActivity("/repo", { sessionId: "s1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true });
|
||||
|
||||
expect(service.snapshot().workspaces).toEqual([]);
|
||||
expect(events.at(-1)).toMatchObject({ type: "workspace.activity", activity: { cwd: "/repo", hasSessionActivity: false } });
|
||||
});
|
||||
|
||||
it("clears stale active activity when an idle status arrives", () => {
|
||||
const events: RealtimeEvent[] = [];
|
||||
const service = new WorkspaceActivityService({ publishRealtime: (event) => events.push(event) });
|
||||
|
||||
@@ -27,6 +27,7 @@ import { SESSIOND_RUNTIME_CAPABILITIES } from "../shared/capabilities.js";
|
||||
import { agentSessionDirEnvKeys, effectivePiWebConfig, maxUploadBytes, offlineModeEnabled } from "../config.js";
|
||||
import { createActiveAgentProfileDescriptor } from "../sessiond/activeAgentProfile.js";
|
||||
import { runSessionDaemonStartup } from "./sessiond/sessionDaemonStartup.js";
|
||||
import { sessionServiceDependencies } from "./sessiond/sessionServiceDependencies.js";
|
||||
|
||||
const daemonEnvironment: NodeJS.ProcessEnv = Object.freeze({ ...process.env });
|
||||
const { config } = effectivePiWebConfig({ env: daemonEnvironment });
|
||||
@@ -70,25 +71,23 @@ await runSessionDaemonStartup({
|
||||
const spawnTargets = config.spawnSessions
|
||||
? new ProjectScopedSpawnTargetResolver({ projects: new ProjectService(new ProjectStore()), workspaces: new WorkspaceService() })
|
||||
: undefined;
|
||||
const sessions = new PiSessionService(eventHub, {
|
||||
const sessions = new PiSessionService(eventHub, sessionServiceDependencies({
|
||||
modelRuntime: auth.runtime,
|
||||
agentDir: activeAgentProfile.dir,
|
||||
workspaceActivity,
|
||||
logger: app.log,
|
||||
...(spawnTargets === undefined ? {} : { spawnTargets }),
|
||||
subsessionsEnabled: spawnTargets !== undefined && config.subsessions,
|
||||
subsessionsEnabled: config.subsessions,
|
||||
askUserEnabled: config.askUser,
|
||||
notificationStore,
|
||||
unreadStore,
|
||||
// Read-only, so session startup can tell a waiting user that provider
|
||||
// model lists are refreshing at the same time.
|
||||
catalogRefreshStatus: catalogRefresher,
|
||||
sessionManager: createPiSessionManagerGateway({
|
||||
agentDir: activeAgentProfile.dir,
|
||||
env: daemonEnvironment,
|
||||
sessionDirEnvKeys: activeAgentProfile.sessionDirEnvKeys,
|
||||
}),
|
||||
});
|
||||
}));
|
||||
auth.subscribe((change) => { sessions.applyAuthChange(change); });
|
||||
const terminals = new TerminalService(eventHub, workspaceActivity);
|
||||
const runtimeComponent = Object.freeze({
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
import { SessionNotificationStore } from "../sessions/sessionNotificationStore.js";
|
||||
import { SessionUnreadStore } from "../sessions/sessionUnreadStore.js";
|
||||
import { PiSessionService, type PiSessionServiceDependencies } from "../sessions/piSessionService.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, sessionGateway, testModelRuntime } from "../sessions/piSessionService.testSupport.js";
|
||||
import { sessionServiceDependencies, type SessionServiceDependencyInput } from "./sessionServiceDependencies.js";
|
||||
|
||||
const AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
|
||||
/**
|
||||
* The collaborators sessiond hands the assembly, with only the process-facing
|
||||
* ones (agent dir, session store gateway) replaced. Everything the assembly
|
||||
* decides is left to the assembly.
|
||||
*/
|
||||
function daemonCollaborators(patch: Partial<SessionServiceDependencyInput> = {}): SessionServiceDependencyInput {
|
||||
return {
|
||||
agentDir: AGENT_DIR,
|
||||
modelRuntime: testModelRuntime,
|
||||
sessionManager: sessionGateway([]),
|
||||
workspaceActivity: new WorkspaceActivityService(),
|
||||
logger: { info() { /* no-op */ } },
|
||||
notificationStore: new SessionNotificationStore(),
|
||||
unreadStore: new SessionUnreadStore(),
|
||||
catalogRefreshStatus: { isRefreshInFlight: () => false },
|
||||
subsessionsEnabled: false,
|
||||
askUserEnabled: true,
|
||||
...patch,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Start a session through a service built by the real assembly and collect what
|
||||
* the user is told while waiting. Only test-local seams are patched onto the
|
||||
* assembled dependencies, never anything the assembly is responsible for
|
||||
* supplying, so a dependency the assembly stops passing cannot be masked here.
|
||||
*/
|
||||
async function startupDetails(deps: PiSessionServiceDependencies): Promise<string[]> {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime();
|
||||
const service = new PiSessionService(hub, {
|
||||
...deps,
|
||||
archiveStore: emptyArchiveStore(),
|
||||
createAgentRuntime: () => Promise.resolve(fake.runtime),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
try {
|
||||
await service.start("/workspace");
|
||||
} finally {
|
||||
await service.dispose();
|
||||
}
|
||||
return hub.globalEvents.flatMap((event) =>
|
||||
event.type === "session.startup" && event.activity.detail !== undefined ? [event.activity.detail] : [],
|
||||
);
|
||||
}
|
||||
|
||||
describe("sessiond session service dependency assembly", () => {
|
||||
it("reports a concurrent provider model list refresh to a waiting user", async () => {
|
||||
// The note is only reachable in the product because the assembly hands the
|
||||
// refresher to the session service. Asserting the note rather than the
|
||||
// property means dropping that line fails here instead of passing silently.
|
||||
const details = await startupDetails(sessionServiceDependencies(daemonCollaborators({
|
||||
catalogRefreshStatus: { isRefreshInFlight: () => true },
|
||||
})));
|
||||
|
||||
expect(details).toEqual([
|
||||
"Starting the Pi session · provider model lists are refreshing",
|
||||
"Loading session extensions · provider model lists are refreshing",
|
||||
]);
|
||||
});
|
||||
|
||||
it("states the startup phase alone when no refresh is running", async () => {
|
||||
const details = await startupDetails(sessionServiceDependencies(daemonCollaborators()));
|
||||
|
||||
// Pins the note to the refresher's answer, so the test above cannot pass on
|
||||
// wording that is always appended.
|
||||
expect(details).toEqual(["Starting the Pi session", "Loading session extensions"]);
|
||||
});
|
||||
|
||||
it("keeps tracked subsessions off unless spawning is configured as well", () => {
|
||||
const spawnTargets = { resolveSpawnTarget: () => Promise.reject(new Error("not used")) };
|
||||
|
||||
const withoutSpawnTargets = sessionServiceDependencies(daemonCollaborators({ subsessionsEnabled: true }));
|
||||
const withSpawnTargets = sessionServiceDependencies(daemonCollaborators({ subsessionsEnabled: true, spawnTargets }));
|
||||
|
||||
expect(withoutSpawnTargets.spawnTargets).toBeUndefined();
|
||||
expect(withoutSpawnTargets.subsessionsEnabled).toBe(false);
|
||||
expect(withSpawnTargets.spawnTargets).toBe(spawnTargets);
|
||||
expect(withSpawnTargets.subsessionsEnabled).toBe(true);
|
||||
});
|
||||
|
||||
it("passes the ask-user preference through to the session service", () => {
|
||||
expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: true })).askUserEnabled).toBe(true);
|
||||
expect(sessionServiceDependencies(daemonCollaborators({ askUserEnabled: false })).askUserEnabled).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { PiSessionServiceDependencies } from "../sessions/piSessionService.js";
|
||||
|
||||
/**
|
||||
* The collaborators sessiond constructs, in the shape the assembly needs them.
|
||||
*
|
||||
* Every field is required unless the capability itself is optional, so a
|
||||
* collaborator the daemon stops constructing cannot silently vanish from the
|
||||
* session service.
|
||||
*/
|
||||
export interface SessionServiceDependencyInput {
|
||||
agentDir: string;
|
||||
sessionManager: PiSessionServiceDependencies["sessionManager"];
|
||||
modelRuntime: PiSessionServiceDependencies["modelRuntime"];
|
||||
workspaceActivity: NonNullable<PiSessionServiceDependencies["workspaceActivity"]>;
|
||||
logger: NonNullable<PiSessionServiceDependencies["logger"]>;
|
||||
notificationStore: NonNullable<PiSessionServiceDependencies["notificationStore"]>;
|
||||
unreadStore: NonNullable<PiSessionServiceDependencies["unreadStore"]>;
|
||||
/** Read-only view of the background refresher; see the assembly below. */
|
||||
catalogRefreshStatus: NonNullable<PiSessionServiceDependencies["catalogRefreshStatus"]>;
|
||||
/** Omitted when the operator has not enabled session spawning. */
|
||||
spawnTargets?: NonNullable<PiSessionServiceDependencies["spawnTargets"]>;
|
||||
/** The operator's subsessions preference, which also requires spawning. */
|
||||
subsessionsEnabled: boolean;
|
||||
/** Whether agents may post structured question sets to the browser. */
|
||||
askUserEnabled: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Map sessiond's constructed collaborators onto the session service's
|
||||
* dependencies.
|
||||
*
|
||||
* Extracted from `sessiond.ts`, which starts a daemon as an import side effect
|
||||
* and so cannot be loaded by a test. This function performs no side effects and
|
||||
* constructs nothing, so lifting it changes no startup ordering; it exists only
|
||||
* so a test can build a service the way the daemon does and assert what a user
|
||||
* is actually told.
|
||||
*/
|
||||
export function sessionServiceDependencies(input: SessionServiceDependencyInput): PiSessionServiceDependencies {
|
||||
return {
|
||||
modelRuntime: input.modelRuntime,
|
||||
agentDir: input.agentDir,
|
||||
workspaceActivity: input.workspaceActivity,
|
||||
logger: input.logger,
|
||||
...(input.spawnTargets === undefined ? {} : { spawnTargets: input.spawnTargets }),
|
||||
// Tracked subsessions share the spawn capability's project-scope resolver,
|
||||
// so they stay off unless spawning is configured too.
|
||||
subsessionsEnabled: input.spawnTargets !== undefined && input.subsessionsEnabled,
|
||||
askUserEnabled: input.askUserEnabled,
|
||||
notificationStore: input.notificationStore,
|
||||
unreadStore: input.unreadStore,
|
||||
// Read-only, so session startup can tell a waiting user that provider
|
||||
// model lists are refreshing at the same time.
|
||||
catalogRefreshStatus: input.catalogRefreshStatus,
|
||||
sessionManager: input.sessionManager,
|
||||
};
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PiSessionService, type PiSessionRuntime } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, sessionGateway, sessionRecord, sessionRef, testModelRuntime } from "./piSessionService.testSupport.js";
|
||||
import { isSessionActive } from "../../shared/activity.js";
|
||||
import type { SessionActivity, SessionStartupProgressEvent } from "../../shared/apiTypes.js";
|
||||
|
||||
const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
@@ -77,7 +78,7 @@ describe("PiSessionService session startup progress", () => {
|
||||
// The proof that matters: the user is told what is being waited on before
|
||||
// the wait ends, not after it.
|
||||
expect(startupText(hub)).toEqual(["Creating session: Starting the Pi session"]);
|
||||
expect(startupEvents(hub).at(0)).toMatchObject({ cwd: "/workspace", activity: { sessionId: "session-1", phase: "active" } });
|
||||
expect(startupEvents(hub).at(0)).toMatchObject({ activity: { sessionId: "session-1", phase: "active" } });
|
||||
|
||||
runtimeResult.resolve(fake.runtime);
|
||||
await started;
|
||||
@@ -157,11 +158,42 @@ describe("PiSessionService session startup progress", () => {
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
expect(startupEvents(hub).at(-1)).toMatchObject({ cwd: "/workspace", activity: { sessionId: "session-1", phase: "idle", label: "idle" } });
|
||||
expect(startupEvents(hub).at(-1)).toMatchObject({ activity: { sessionId: "session-1", phase: "idle", label: "idle" } });
|
||||
expect(startupEvents(hub).at(-1)?.activity.detail).toBeUndefined();
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("echoes a create's correlation token on every startup report of that construction", async () => {
|
||||
const { hub, service } = startupService();
|
||||
|
||||
await service.start("/workspace", { startupToken: "pending-session-3-k2x9" });
|
||||
|
||||
// The token labels the browser row that is waiting, so it must ride every
|
||||
// report of this construction, the closing idle one included.
|
||||
expect(startupEvents(hub).map((event) => event.startupToken)).toEqual([
|
||||
"pending-session-3-k2x9",
|
||||
"pending-session-3-k2x9",
|
||||
"pending-session-3-k2x9",
|
||||
]);
|
||||
// The token is an opaque throwaway label, never the session's identity.
|
||||
expect(startupEvents(hub).map((event) => event.activity.sessionId)).toEqual(["session-1", "session-1", "session-1"]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("publishes no correlation token when a create supplies none, and none for an open", async () => {
|
||||
const created = startupService();
|
||||
await created.service.start("/workspace");
|
||||
const opened = startupService({ sessionRecords: [sessionRecord("session-1")] });
|
||||
await opened.service.status(sessionRef("session-1"));
|
||||
|
||||
for (const hub of [created.hub, opened.hub]) {
|
||||
expect(startupEvents(hub).length).toBeGreaterThan(0);
|
||||
expect(startupEvents(hub).every((event) => event.startupToken === undefined)).toBe(true);
|
||||
}
|
||||
await created.service.dispose();
|
||||
await opened.service.dispose();
|
||||
});
|
||||
|
||||
it("ends the startup window when the runtime construction itself fails", async () => {
|
||||
const failure = new Error("runtime unavailable");
|
||||
const { hub, service } = startupService({ createAgentRuntime: () => Promise.reject(failure) });
|
||||
@@ -204,6 +236,34 @@ describe("PiSessionService session startup progress", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reports startup progress as starting rather than as work in progress", async () => {
|
||||
const { hub, service } = startupService();
|
||||
|
||||
await service.start("/workspace");
|
||||
|
||||
// Startup phases are published with an "active" phase so the waiting user
|
||||
// sees them, but opening a session is not work: nothing that decides whether
|
||||
// work is in progress may count them.
|
||||
const phases = startupEvents(hub).filter((event) => event.activity.phase === "active");
|
||||
expect(phases).toHaveLength(2);
|
||||
expect(phases.map((event) => isSessionActive(undefined, event.activity))).toEqual([false, false]);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("still reports a real activity published during startup as work", async () => {
|
||||
const { hub, fake, service } = startupService();
|
||||
|
||||
await service.start("/workspace");
|
||||
fake.emit({ type: "tool_execution_start", toolName: "bash" });
|
||||
|
||||
// The marker belongs to the startup channel alone; an ordinary activity for
|
||||
// the same session still counts, or the fix would hide real work.
|
||||
const running = activityUpdates(hub).filter((activity) => activity.phase === "active");
|
||||
expect(running.length).toBeGreaterThan(0);
|
||||
expect(running.every((activity) => isSessionActive(undefined, activity))).toBe(true);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("keeps startup reporting event-only, writing no session or workspace activity state", async () => {
|
||||
const recorder = recordingWorkspaceActivity();
|
||||
const failure = new Error("runtime unavailable");
|
||||
|
||||
@@ -204,6 +204,11 @@ type SessionCreationProvenance = "tracked-subsession";
|
||||
interface StartSessionOptions {
|
||||
parentSession?: string;
|
||||
initialModel?: AgentModel;
|
||||
/**
|
||||
* Opaque label, echoed on this construction's startup progress so a browser
|
||||
* row with no session id yet can recognise its own.
|
||||
*/
|
||||
startupToken?: string;
|
||||
}
|
||||
|
||||
interface InternalStartSessionOptions extends StartSessionOptions {
|
||||
@@ -395,7 +400,7 @@ interface PendingSessionOpen {
|
||||
promise: Promise<ActiveSession<PiSessionRuntime>>;
|
||||
}
|
||||
|
||||
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance"> {
|
||||
interface CreateSessionRuntimeOptions extends Pick<InternalStartSessionOptions, "initialModel" | "creationProvenance" | "startupToken"> {
|
||||
notificationGeneration?: SessionNotificationGeneration;
|
||||
notifications?: "enabled" | "disabled";
|
||||
/**
|
||||
@@ -1016,6 +1021,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
cwd,
|
||||
{
|
||||
startupIntent: "create",
|
||||
...(options.startupToken === undefined ? {} : { startupToken: options.startupToken }),
|
||||
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
||||
...(options.creationProvenance === undefined ? {} : { creationProvenance: options.creationProvenance }),
|
||||
},
|
||||
@@ -2459,7 +2465,7 @@ export class PiSessionService implements SessionRouteService {
|
||||
cwd: string,
|
||||
options: CreateSessionRuntimeOptions = {},
|
||||
): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const startup = this.startupProgress(sessionManager, cwd, options.startupIntent ?? "open");
|
||||
const startup = this.startupProgress(sessionManager, options.startupIntent ?? "open", options.startupToken);
|
||||
try {
|
||||
return await this.createSessionRuntime(sessionManager, cwd, options, startup);
|
||||
} finally {
|
||||
@@ -3105,23 +3111,23 @@ export class PiSessionService implements SessionRouteService {
|
||||
/**
|
||||
* Build the reporter for one session construction.
|
||||
*
|
||||
* The session id and cwd are both known before any await — a `SessionManager`
|
||||
* has its id from construction — so the daemon can name what it is starting
|
||||
* even though the `PiAgentSession` that {@link publishActivity} needs does not
|
||||
* exist yet. When either is missing there is nothing honest to route on, so
|
||||
* the reporter stays silent and the browser keeps its own generic wording.
|
||||
* The session id is known before any await — a `SessionManager` has its id
|
||||
* from construction — so the daemon can name what it is starting even though
|
||||
* the `PiAgentSession` that {@link publishActivity} needs does not exist yet.
|
||||
* Without an id there is nothing to report against, so the reporter stays
|
||||
* silent and the browser keeps its own generic wording.
|
||||
*/
|
||||
private startupProgress(sessionManager: PiSessionManager, cwd: string, intent: "create" | "open"): SessionStartupProgressReporter {
|
||||
private startupProgress(sessionManager: PiSessionManager, intent: "create" | "open", startupToken: string | undefined): SessionStartupProgressReporter {
|
||||
const sessionId = sessionManager.getSessionId();
|
||||
if (sessionId === "" || cwd === "") return { report: noop, end: noop };
|
||||
if (sessionId === "") return { report: noop, end: noop };
|
||||
const label = intent === "create" ? "Creating session" : "Opening session";
|
||||
return {
|
||||
report: (phase) => { this.publishStartupProgress(sessionId, cwd, label, "active", this.startupDetail(phase)); },
|
||||
report: (phase) => { this.publishStartupProgress(sessionId, startupToken, label, "active", this.startupDetail(phase)); },
|
||||
end: () => {
|
||||
// A real activity published during the window (an extension error, say)
|
||||
// is the truth about this session and must survive the clear.
|
||||
if (this.activities.has(sessionId)) return;
|
||||
this.publishStartupProgress(sessionId, cwd, "idle", "idle", undefined);
|
||||
this.publishStartupProgress(sessionId, startupToken, "idle", "idle", undefined);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -3133,17 +3139,22 @@ export class PiSessionService implements SessionRouteService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Report startup progress on the global channel only, keyed by `cwd` so a
|
||||
* browser row that has no session id yet can find it.
|
||||
* Report startup progress on the global channel only, echoing the caller's
|
||||
* correlation token so a waiting browser row recognises its own construction.
|
||||
*
|
||||
* Unlike {@link publishActivity} this deliberately records nothing: no
|
||||
* `activities` entry, no workspace activity, no unread observation. There is
|
||||
* no session to own that state, and a failed creation would leave it stranded.
|
||||
*
|
||||
* Every report is marked `startup`, which is what keeps a session that is
|
||||
* merely opening from counting as one doing work. This is the only publisher
|
||||
* that sets the marker, and because it writes no `activities` entry no later
|
||||
* heartbeat re-publication can carry it.
|
||||
*/
|
||||
private publishStartupProgress(sessionId: string, cwd: string, label: string, phase: "active" | "idle", detail: string | undefined): void {
|
||||
private publishStartupProgress(sessionId: string, startupToken: string | undefined, label: string, phase: "active" | "idle", detail: string | undefined): void {
|
||||
const at = new Date().toISOString();
|
||||
const activity = detail === undefined ? { sessionId, phase, label, at } : { sessionId, phase, label, detail, at };
|
||||
this.events.publishGlobal({ type: "session.startup", cwd, activity });
|
||||
const activity = detail === undefined ? { sessionId, phase, label, at, startup: true } : { sessionId, phase, label, detail, at, startup: true };
|
||||
this.events.publishGlobal(startupToken === undefined ? { type: "session.startup", activity } : { type: "session.startup", startupToken, activity });
|
||||
}
|
||||
|
||||
private publishActivity(session: PiAgentSession, label: string, phase: "active" | "idle" | "error", detail?: string): void {
|
||||
|
||||
@@ -28,6 +28,7 @@ import { PiSessionService, type PiSessionManagerGateway } from "./piSessionServi
|
||||
import { testModelRuntime } from "./piSessionService.testSupport.js";
|
||||
import { SessionNotificationStore } from "./sessionNotificationStore.js";
|
||||
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
|
||||
import type { ClientSession } from "../types.js";
|
||||
import { registerSessionRoutes } from "./sessionRoutes.js";
|
||||
import type { NormalizedSessionCleanupRequest } from "./sessionCleanup.js";
|
||||
|
||||
@@ -762,6 +763,35 @@ describe("session routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards a create's optional correlation token alongside the normalized cwd", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService();
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const requestCwd = resolve("/repo");
|
||||
const withToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd, startupToken: "pending-session-3-k2x9" } });
|
||||
const withoutToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd } });
|
||||
// An older browser, or any non-browser caller, sends no token; and a
|
||||
// malformed one must not reach the service as a label it would echo.
|
||||
const malformedToken = await routeApp.inject({ method: "POST", url: "/sessions", payload: { cwd: requestCwd, startupToken: 7 } });
|
||||
|
||||
expect(withToken.statusCode).toBe(200);
|
||||
expect(withoutToken.statusCode).toBe(200);
|
||||
expect(malformedToken.statusCode).toBe(400);
|
||||
expect(malformedToken.json()).toEqual({ error: "startupToken field must be a string" });
|
||||
expect(routeService.startCalls).toEqual([
|
||||
{ cwd: requestCwd, startupToken: "pending-session-3-k2x9" },
|
||||
{ cwd: requestCwd, startupToken: undefined },
|
||||
]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("rejects malformed bulk mutation bodies before calling the service", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
@@ -804,6 +834,7 @@ class CapturingRouteSessionService implements SessionRouteService {
|
||||
readonly navigateTreeCalls: { lookup: SessionRouteLookup; request: SessionTreeNavigateRequest }[] = [];
|
||||
readonly submitAskCalls: { lookup: SessionRouteLookup; askId: string; submission: AskUserSubmission }[] = [];
|
||||
readonly cancelAskCalls: { lookup: SessionRouteLookup; askId: string }[] = [];
|
||||
readonly startCalls: { cwd: string; startupToken: string | undefined }[] = [];
|
||||
askError: Error | undefined;
|
||||
reloadError: Error | undefined;
|
||||
clearQueueError: Error | undefined;
|
||||
@@ -883,7 +914,11 @@ class CapturingRouteSessionService implements SessionRouteService {
|
||||
}
|
||||
|
||||
list(): never { throw unusedRouteMethod("list"); }
|
||||
start(): never { throw unusedRouteMethod("start"); }
|
||||
|
||||
start(cwd: string, options?: { startupToken?: string }): Promise<ClientSession> {
|
||||
this.startCalls.push({ cwd, startupToken: options?.startupToken });
|
||||
return Promise.resolve({ id: "session-1", path: "/tmp/session-1.jsonl", cwd, created: "2026-06-25T00:00:00.000Z", modified: "2026-06-25T00:00:00.000Z", messageCount: 0, firstMessage: "" });
|
||||
}
|
||||
|
||||
dismissWarning(lookup: SessionRouteLookup, dismissId: string): Promise<SessionStatus> {
|
||||
this.dismissWarningCalls.push({ lookup, dismissId });
|
||||
|
||||
@@ -45,10 +45,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => {
|
||||
app.post<{ Body: { cwd?: unknown; startupToken?: unknown } | undefined }>(`${prefix}/sessions`, async (request, reply) => {
|
||||
try {
|
||||
const body = requireRecord(request.body);
|
||||
return await sessions.start(normalizeRequestCwd(requireString(body, "cwd")));
|
||||
// An opaque label the caller uses to recognise its own construction's
|
||||
// startup reports. Optional: only a browser row waiting for a session id
|
||||
// has anything to correlate.
|
||||
const startupToken = body["startupToken"] === undefined ? undefined : requireNonEmptyString(body, "startupToken");
|
||||
return await sessions.start(normalizeRequestCwd(requireString(body, "cwd")), optionalField("startupToken", startupToken));
|
||||
} catch (error) {
|
||||
return reply.code(400).send({ error: errorMessage(error) });
|
||||
}
|
||||
|
||||
@@ -42,7 +42,12 @@ export type SessionRouteLookup = string | SessionRouteRef;
|
||||
*/
|
||||
export interface SessionRouteService {
|
||||
list(cwd: string): Promise<ClientSession[]>;
|
||||
start(cwd: string): Promise<ClientSession>;
|
||||
/**
|
||||
* Create a session. `startupToken` is an opaque label the caller supplies so
|
||||
* it can recognise this construction's startup progress reports; the service
|
||||
* echoes it and never interprets it.
|
||||
*/
|
||||
start(cwd: string, options?: { startupToken?: string }): Promise<ClientSession>;
|
||||
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
|
||||
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||
streamSnapshot(ref: SessionRouteLookup): Promise<SessionStreamSnapshot>;
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parseGitWorktreeList } from "./gitWorktreeDiscovery.js";
|
||||
|
||||
// Fixtures below are verbatim `git worktree list --porcelain` output captured from real git.
|
||||
const mainAndLinked = [
|
||||
"worktree /repo",
|
||||
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /repo-worktrees/feature",
|
||||
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
|
||||
"branch refs/heads/feat/thing",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
const removedAndLocked = [
|
||||
"worktree /repo",
|
||||
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
|
||||
"branch refs/heads/main",
|
||||
"",
|
||||
"worktree /repo-worktrees/gone",
|
||||
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
|
||||
"branch refs/heads/gone",
|
||||
"prunable gitdir file points to non-existent location",
|
||||
"",
|
||||
"worktree /repo-worktrees/kept",
|
||||
"HEAD ad580ab86e1eba35a121fa6b9e8be1126aaf18de",
|
||||
"branch refs/heads/kept",
|
||||
"locked keep me",
|
||||
"",
|
||||
].join("\n");
|
||||
|
||||
describe("parseGitWorktreeList", () => {
|
||||
it("reads paths and short branch names for the main and linked worktrees", () => {
|
||||
expect(parseGitWorktreeList(mainAndLinked)).toEqual([
|
||||
{ path: "/repo", branch: "main" },
|
||||
{ path: "/repo-worktrees/feature", branch: "feat/thing" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports prunable, and leaves a locked worktree looking like the usable checkout it is", () => {
|
||||
expect(parseGitWorktreeList(removedAndLocked)).toEqual([
|
||||
{ path: "/repo", branch: "main" },
|
||||
{ path: "/repo-worktrees/gone", branch: "gone", prunable: true },
|
||||
// `locked` is ignored: a locked worktree is a real checkout and stays a usable
|
||||
// workspace, so nothing downstream needs to distinguish it.
|
||||
{ path: "/repo-worktrees/kept", branch: "kept" },
|
||||
]);
|
||||
|
||||
const bareLocked = ["worktree /repo-worktrees/kept", "HEAD abc", "detached", "locked", ""].join("\n");
|
||||
expect(parseGitWorktreeList(bareLocked)).toEqual([{ path: "/repo-worktrees/kept", detached: true }]);
|
||||
});
|
||||
|
||||
it("reads bare repositories and ignores chunks without a worktree path", () => {
|
||||
const bare = ["worktree /repo.git", "bare", "", "HEAD abc", ""].join("\n");
|
||||
expect(parseGitWorktreeList(bare)).toEqual([{ path: "/repo.git", bare: true }]);
|
||||
});
|
||||
|
||||
it("returns nothing for empty output", () => {
|
||||
expect(parseGitWorktreeList("\n")).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,8 @@ export interface GitWorktreeInfo {
|
||||
branch?: string;
|
||||
bare?: boolean;
|
||||
detached?: boolean;
|
||||
/** Git reports a linked worktree as prunable when its checkout directory no longer exists. */
|
||||
prunable?: boolean;
|
||||
}
|
||||
|
||||
export async function isGitRepository(path: string): Promise<boolean> {
|
||||
@@ -22,6 +24,14 @@ export async function isGitRepository(path: string): Promise<boolean> {
|
||||
|
||||
export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]> {
|
||||
const { stdout } = await execFileAsync("git", ["-C", path, "worktree", "list", "--porcelain"], { env: sanitizedGitEnv() });
|
||||
return parseGitWorktreeList(stdout);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses `git worktree list --porcelain` output into facts only. Deciding which worktrees a
|
||||
* project should show (for example hiding prunable ones) is workspace policy, not parsing.
|
||||
*/
|
||||
export function parseGitWorktreeList(stdout: string): GitWorktreeInfo[] {
|
||||
const chunks = stdout.trim().split(/\n\s*\n/).filter(Boolean);
|
||||
|
||||
return chunks.map((chunk) => {
|
||||
@@ -33,6 +43,7 @@ export async function discoverGitWorktrees(path: string): Promise<GitWorktreeInf
|
||||
if (key === "branch") info.branch = value.replace(/^refs\/heads\//, "");
|
||||
if (key === "bare") info.bare = true;
|
||||
if (key === "detached") info.detached = true;
|
||||
if (key === "prunable") info.prunable = true;
|
||||
}
|
||||
return info;
|
||||
}).filter((w) => w.path);
|
||||
|
||||
@@ -0,0 +1,80 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Project } from "../types.js";
|
||||
import type { GitWorktreeInfo } from "./gitWorktreeDiscovery.js";
|
||||
import { WorkspaceService, type WorkspaceGitPort } from "./workspaceService.js";
|
||||
|
||||
const project: Project = {
|
||||
id: "p1",
|
||||
name: "Project",
|
||||
path: "/repo",
|
||||
createdAt: "2026-05-25T00:00:00.000Z",
|
||||
};
|
||||
|
||||
function serviceFor(worktrees: GitWorktreeInfo[], isGitRepo = true): WorkspaceService {
|
||||
const git: WorkspaceGitPort = {
|
||||
isGitRepository: () => Promise.resolve(isGitRepo),
|
||||
discoverGitWorktrees: () => Promise.resolve(worktrees),
|
||||
};
|
||||
return new WorkspaceService(git);
|
||||
}
|
||||
|
||||
describe("WorkspaceService.list", () => {
|
||||
it("hides a linked worktree whose checkout directory was removed outside PI WEB", async () => {
|
||||
const service = serviceFor([
|
||||
{ path: "/repo", branch: "main" },
|
||||
{ path: "/repo-worktrees/gone", branch: "gone", prunable: true },
|
||||
{ path: "/repo-worktrees/live", branch: "live" },
|
||||
]);
|
||||
|
||||
const workspaces = await service.list(project);
|
||||
|
||||
expect(workspaces.map((workspace) => workspace.path)).toEqual(["/repo", "/repo-worktrees/live"]);
|
||||
});
|
||||
|
||||
it("keeps a worktree that is present but not prunable, such as a locked one", async () => {
|
||||
const service = serviceFor([
|
||||
{ path: "/repo", branch: "main" },
|
||||
{ path: "/repo-worktrees/kept", branch: "kept" },
|
||||
]);
|
||||
|
||||
const workspaces = await service.list(project);
|
||||
|
||||
expect(workspaces.map((workspace) => workspace.path)).toEqual(["/repo", "/repo-worktrees/kept"]);
|
||||
});
|
||||
|
||||
it("keeps the project's own worktree even if git marks it prunable, so a project is never empty", async () => {
|
||||
const service = serviceFor([{ path: "/repo", branch: "main", prunable: true }]);
|
||||
|
||||
const workspaces = await service.list(project);
|
||||
|
||||
expect(workspaces).toEqual([expect.objectContaining({ path: "/repo", label: "main", isMain: true, isGitWorktree: true })]);
|
||||
});
|
||||
|
||||
it("falls back to the project itself when every linked worktree is filtered away", async () => {
|
||||
const service = serviceFor([{ path: "/repo-worktrees/gone", branch: "gone", prunable: true }]);
|
||||
|
||||
const workspaces = await service.list(project);
|
||||
|
||||
expect(workspaces).toEqual([expect.objectContaining({ path: "/repo", label: "Project", isMain: true, isGitRepo: true, isGitWorktree: false })]);
|
||||
});
|
||||
|
||||
it("labels detached and unnamed worktrees without inventing a branch", async () => {
|
||||
const service = serviceFor([
|
||||
{ path: "/repo", branch: "main" },
|
||||
{ path: "/repo-worktrees/detached", detached: true },
|
||||
]);
|
||||
|
||||
const workspaces = await service.list(project);
|
||||
|
||||
expect(workspaces.map((workspace) => ({ label: workspace.label, branch: workspace.branch }))).toEqual([
|
||||
{ label: "main", branch: "main" },
|
||||
{ label: "detached", branch: undefined },
|
||||
]);
|
||||
});
|
||||
|
||||
it("returns a single non-git workspace when the project is not a repository", async () => {
|
||||
const service = serviceFor([], false);
|
||||
|
||||
expect(await service.list(project)).toEqual([expect.objectContaining({ path: "/repo", isGitRepo: false, isGitWorktree: false })]);
|
||||
});
|
||||
});
|
||||
@@ -1,18 +1,28 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import type { Project } from "../types.js";
|
||||
import type { Workspace } from "../types.js";
|
||||
import { discoverGitWorktrees, isGitRepository } from "./gitWorktreeDiscovery.js";
|
||||
import { discoverGitWorktrees, isGitRepository, type GitWorktreeInfo } from "./gitWorktreeDiscovery.js";
|
||||
|
||||
const idFor = (value: string) => createHash("sha1").update(value).digest("hex").slice(0, 12);
|
||||
|
||||
/** The git facts this service needs, injectable so workspace policy is testable without a real repo. */
|
||||
export interface WorkspaceGitPort {
|
||||
isGitRepository(path: string): Promise<boolean>;
|
||||
discoverGitWorktrees(path: string): Promise<GitWorktreeInfo[]>;
|
||||
}
|
||||
|
||||
const realGit: WorkspaceGitPort = { isGitRepository, discoverGitWorktrees };
|
||||
|
||||
export class WorkspaceService {
|
||||
constructor(private readonly git: WorkspaceGitPort = realGit) {}
|
||||
|
||||
async list(project: Project): Promise<Workspace[]> {
|
||||
const isGitRepo = await isGitRepository(project.path);
|
||||
const isGitRepo = await this.git.isGitRepository(project.path);
|
||||
if (!isGitRepo) {
|
||||
return [this.single(project, false)];
|
||||
}
|
||||
|
||||
const worktrees = await discoverGitWorktrees(project.path);
|
||||
const worktrees = this.selectable(await this.git.discoverGitWorktrees(project.path), project);
|
||||
if (worktrees.length === 0) return [this.single(project, true)];
|
||||
|
||||
return worktrees.map((worktree) => {
|
||||
@@ -30,6 +40,16 @@ export class WorkspaceService {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Git keeps listing a linked worktree after its checkout directory is deleted outside PI WEB,
|
||||
* marking it `prunable`. Such an entry is not a usable workspace, so it is hidden rather than
|
||||
* offered as a selectable ghost. Listing stays read-only: we never run `git worktree prune`.
|
||||
* The project's own path is always kept so a project cannot end up with no workspace at all.
|
||||
*/
|
||||
private selectable(worktrees: GitWorktreeInfo[], project: Project): GitWorktreeInfo[] {
|
||||
return worktrees.filter((worktree) => worktree.prunable !== true || worktree.path === project.path);
|
||||
}
|
||||
|
||||
private single(project: Project, isGitRepo: boolean): Workspace {
|
||||
return {
|
||||
id: idFor(`${project.id}:${project.path}`),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSessionActive, isWorkspaceActivityActive } from "./activity";
|
||||
import type { SessionStatus, WorkspaceActivity } from "./apiTypes";
|
||||
import type { SessionActivity, SessionStatus, WorkspaceActivity } from "./apiTypes";
|
||||
|
||||
const idleStatus: SessionStatus = {
|
||||
sessionId: "s1",
|
||||
@@ -20,6 +20,29 @@ describe("activity helpers", () => {
|
||||
expect(isSessionActive({ ...idleStatus, pendingMessageCount: 2 })).toBe(true);
|
||||
});
|
||||
|
||||
it("does not count a session that is only starting up as doing work", () => {
|
||||
// Startup is reported on the activity channel with an "active" phase because
|
||||
// a phase really is in progress, but opening a session is not work a user
|
||||
// can stop, so the marker is what separates starting from working.
|
||||
const startup: SessionActivity = { sessionId: "s1", phase: "active", label: "Opening session", detail: "Starting the Pi session", at: "now", startup: true };
|
||||
|
||||
expect(isSessionActive(undefined, startup)).toBe(false);
|
||||
expect(isSessionActive(idleStatus, startup)).toBe(false);
|
||||
});
|
||||
|
||||
it("still reports genuine work happening while a session starts up", () => {
|
||||
const startup: SessionActivity = { sessionId: "s1", phase: "active", label: "Opening session", at: "now", startup: true };
|
||||
|
||||
// The marker only removes the activity-phase reason for being active, so
|
||||
// work the status proves is unaffected by it.
|
||||
expect(isSessionActive({ ...idleStatus, isStreaming: true }, startup)).toBe(true);
|
||||
expect(isSessionActive({ ...idleStatus, isBashRunning: true }, startup)).toBe(true);
|
||||
expect(isSessionActive({ ...idleStatus, isCompacting: true }, startup)).toBe(true);
|
||||
expect(isSessionActive({ ...idleStatus, pendingMessageCount: 1 }, startup)).toBe(true);
|
||||
// An unmarked active activity is ordinary work and keeps counting.
|
||||
expect(isSessionActive(idleStatus, { sessionId: "s1", phase: "active", label: "running tool", at: "now" })).toBe(true);
|
||||
});
|
||||
|
||||
it("detects workspace activity presence without exposing details", () => {
|
||||
const idle: WorkspaceActivity = { cwd: "/repo", hasSessionActivity: false, hasTerminalActivity: false, updatedAt: "now" };
|
||||
expect(isWorkspaceActivityActive(idle)).toBe(false);
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
import type { SessionActivity, SessionStatus, WorkspaceActivity } from "./apiTypes.js";
|
||||
|
||||
/**
|
||||
* Whether a session has work in progress a user could stop or must wait for.
|
||||
*
|
||||
* A startup activity is excluded: the session is being opened, not worked in.
|
||||
* The exclusion can only ever remove the activity-phase reason for being
|
||||
* active, so streaming, bash, compaction, and queued prompts still count even
|
||||
* while a startup report is the latest activity.
|
||||
*/
|
||||
export function isSessionActive(status?: SessionStatus, activity?: SessionActivity): boolean {
|
||||
return activity?.phase === "active"
|
||||
return (activity?.phase === "active" && activity.startup !== true)
|
||||
|| status?.isStreaming === true
|
||||
|| status?.isBashRunning === true
|
||||
|| status?.isCompacting === true
|
||||
|
||||
+14
-6
@@ -433,6 +433,14 @@ export interface SessionActivity {
|
||||
label: string;
|
||||
detail?: string;
|
||||
at: string;
|
||||
/**
|
||||
* Set only on the startup window's own reports. A startup phase is genuinely
|
||||
* in progress, so it is published as `active` and rendered like any other
|
||||
* activity, but *starting* a session is not *working* in it: there is nothing
|
||||
* to stop, nothing that blocks reloading from disk, and no workspace-level
|
||||
* work to report. `isSessionActive()` reads this to keep the two apart.
|
||||
*/
|
||||
startup?: boolean;
|
||||
}
|
||||
|
||||
export interface QueuedSessionMessage {
|
||||
@@ -570,18 +578,18 @@ export interface AskUserCloseResponse {
|
||||
* constructing the agent session and no `PiAgentSession` exists yet, so
|
||||
* `activity.update` cannot be published for it.
|
||||
*
|
||||
* `cwd` is the routing key for a browser row that is still waiting for a
|
||||
* session id: a client-invented pending start knows its workspace path but not
|
||||
* the daemon's session id. `activity.sessionId` carries the daemon's real id, so
|
||||
* the same event also serves the case where the browser already knows it (an
|
||||
* open of an existing session).
|
||||
* `startupToken` is the opaque label a create request supplied, echoed back so a
|
||||
* browser row still waiting for a session id recognises its own construction.
|
||||
* The daemon never interprets it and it never becomes the session id:
|
||||
* `activity.sessionId` always carries the real id, which is how an *open* of a
|
||||
* session the browser already knows is routed instead.
|
||||
*
|
||||
* `activity.phase === "idle"` means the startup window ended with nothing left
|
||||
* to report, so a browser that substituted its own text should restore it.
|
||||
*/
|
||||
export interface SessionStartupProgressEvent {
|
||||
type: "session.startup";
|
||||
cwd: string;
|
||||
startupToken?: string;
|
||||
activity: SessionActivity;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user