feat: add session runtime reload command

This commit is contained in:
Federico Jaramillo Martinez
2026-07-01 19:32:52 +02:00
parent 8ade238228
commit 889672ff53
19 changed files with 129 additions and 31 deletions
+1 -1
View File
@@ -20,7 +20,7 @@ export const BUILTIN_COMMANDS: ClientCommand[] = [
{ name: "new", description: "Start a new session", source: "builtin" },
{ name: "compact", description: "Manually compact session context", source: "builtin" },
{ name: "resume", description: "Resume a different session", source: "builtin" },
{ name: "reload", description: "Reload keybindings, extensions, skills, prompts, and themes", source: "builtin" },
{ name: "reload", description: "Reload Pi runtime resources for this session", source: "builtin" },
{ name: "quit", description: "Quit pi", source: "builtin" },
];
+28 -1
View File
@@ -63,7 +63,7 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
const customMessageCalls: { message: { customType: string; content: string; display: boolean; details?: unknown }; options: unknown }[] = [];
const bindExtensionCalls: unknown[] = [];
const listeners: ((event: unknown) => void)[] = [];
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, sendCustomMessage: customMessageCalls };
const calls = { abort: 0, bindExtensions: bindExtensionCalls, clearQueue: 0, dispose: 0, prompt: promptCalls, reload: 0, sendCustomMessage: customMessageCalls };
const session: TestSession = {
sessionId,
sessionFile: `/tmp/${sessionId}.jsonl`,
@@ -94,6 +94,10 @@ function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession> = {})
},
getSessionStats: () => ({ sessionId, totalMessages: 0, userMessages: 0, assistantMessages: 0, toolCalls: 0, tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, cost: 0 }),
getContextUsage: () => undefined,
reload: () => {
calls.reload += 1;
return Promise.resolve();
},
prompt: (text: string, options: unknown) => {
calls.prompt.push({ text, options });
return Promise.resolve();
@@ -738,6 +742,29 @@ describe("PiSessionService", () => {
await service.dispose();
});
it("runs /reload by refreshing the active runtime resources in place", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("runtime-reload-session");
const service = new PiSessionService(hub, {
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([sessionRecord("runtime-reload-session")]),
heartbeatIntervalMs: 60_000,
});
await expect(service.runCommand(sessionRef("runtime-reload-session"), "/reload")).resolves.toEqual({
type: "done",
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
});
expect(fake.calls.reload).toBe(1);
expect(fake.calls.abort).toBe(0);
expect(fake.calls.dispose).toBe(0);
expect(hub.globalEvents.some((event) => event.type === "activity.update" && event.activity.sessionId === "runtime-reload-session" && event.activity.label === "resources reloaded")).toBe(true);
expect(hub.globalEvents.some((event) => event.type === "status.update" && event.status.sessionId === "runtime-reload-session")).toBe(true);
await service.dispose();
});
it("reloads a session by closing the active runtime and re-opening it from disk", async () => {
const first = fakeRuntime("reload-session");
const second = fakeRuntime("reload-session");
+18
View File
@@ -217,6 +217,7 @@ export interface PiAgentSession {
compact(instructions?: string): Promise<{ summary: string; tokensBefore: number }>;
getUserMessagesForForking(): readonly { entryId: string; text: string }[];
getSessionStats(): { sessionId: string; totalMessages: number; userMessages: number; assistantMessages: number; toolCalls: number; tokens: ClientSessionStatus["tokens"]; cost: number };
reload(): Promise<void>;
getContextUsage(): ClientSessionStatus["contextUsage"] | undefined;
prompt(text: string, options?: { streamingBehavior?: "steer" | "followUp"; images?: ImageContent[] }): Promise<void>;
sendCustomMessage(message: { customType: string; content: string; display: boolean; details?: unknown }, options?: { triggerTurn?: boolean; deliverAs?: "steer" | "followUp" | "nextTurn" }): Promise<void>;
@@ -423,6 +424,7 @@ export class PiSessionService {
this.publishActivity(session, result === "success" ? "compaction complete" : "compaction failed", result === "success" ? "idle" : "error", detail);
this.publishStatus(session);
},
reloadSession: (session) => this.reloadSessionRuntime(session),
},
{ listSessionNames: (cwd) => this.listSessionNames(cwd) },
);
@@ -1118,6 +1120,22 @@ export class PiSessionService {
return this.commandService.respond(active.runtime.session.sessionId, requestId, value);
}
private async reloadSessionRuntime(session: PiAgentSession): Promise<void> {
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before reloading");
this.publishActivity(session, "reloading resources", "active");
try {
await session.reload();
this.publishActivity(session, "resources reloaded", "idle");
this.publishStatus(session);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
this.publishActivity(session, "reload failed", "error", message);
this.events.publish(session.sessionId, { type: "session.error", message });
this.publishStatus(session);
throw error;
}
}
async archive(ref: PiSessionLookup): Promise<void> {
const session = await this.getOrOpen(ref);
if (this.hasActiveWork(session)) throw new Error("Stop current session activity before archiving");
@@ -106,6 +106,30 @@ describe("SessionCommandService", () => {
expect(active.runtime.session.compact).toHaveBeenCalledWith("focus on tests");
});
it("reloads runtime resources through the injected lifecycle callback", async () => {
const active = activeSession();
const reloadSession = vi.fn(async () => { await Promise.resolve(); });
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { reloadSession });
await expect(service.run("s1", "/reload")).resolves.toEqual({
type: "done",
message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes.",
});
expect(reloadSession).toHaveBeenCalledWith(active.runtime.session);
});
it("rejects runtime reload while the session has active work", async () => {
const active = activeSession({ isBashRunning: true });
const reloadSession = vi.fn(async () => { await Promise.resolve(); });
const service = new SessionCommandService(() => getActive(active), vi.fn(), eventPublisher(), { reloadSession });
await expect(service.run("s1", "/reload")).resolves.toEqual({
type: "unsupported",
message: "Cannot reload while the session is active. Stop current activity before reloading.",
});
expect(reloadSession).not.toHaveBeenCalled();
});
it("creates fork selection requests from newest message to oldest and responds with selected entry", async () => {
const active = activeSession({
getUserMessagesForForking: vi.fn(() => [
+21 -4
View File
@@ -47,6 +47,12 @@ export interface CommandEventPublisher {
publishGlobal?(event: Extract<SessionUiEvent, { type: "session.name" }>): void;
}
export interface SessionCommandLifecycle<TSession extends CommandSession = CommandSession> {
onCompactionStart?: (session: TSession) => void;
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
reloadSession?: (session: TSession) => Promise<void>;
}
export interface SessionCommandNaming {
listSessionNames?: (cwd: string) => Promise<readonly string[]>;
}
@@ -65,10 +71,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
private readonly getActive: GetCommandActiveSession<TSession>,
private readonly prompt: (sessionId: string, text: string) => Promise<void>,
private readonly events: CommandEventPublisher,
private readonly lifecycle: {
onCompactionStart?: (session: TSession) => void;
onCompactionEnd?: (session: TSession, result: "success" | "error", detail?: string) => void;
} = {},
private readonly lifecycle: SessionCommandLifecycle<TSession> = {},
private readonly naming: SessionCommandNaming = {},
) {}
@@ -93,6 +96,7 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
if (name === "session") return { type: "done", message: formatSessionStats(session) };
if (name === "name") return this.nameSession(active, rest);
if (name === "compact") return this.compact(session, rest);
if (name === "reload") return this.reload(session);
if (name === "clone") return this.clone(active);
if (name === "fork") return this.fork(active);
@@ -140,6 +144,19 @@ export class SessionCommandService<TSession extends CommandSession = CommandSess
return { type: "done", message: "Compaction started…" };
}
private async reload(session: TSession): Promise<ClientCommandResult> {
if (sessionHasActiveWork(session)) return { type: "unsupported", message: "Cannot reload while the session is active. Stop current activity before reloading." };
if (this.lifecycle.reloadSession === undefined) return { type: "unsupported", message: "/reload is not available for this session runtime." };
try {
await this.lifecycle.reloadSession(session);
} catch (error: unknown) {
const message = error instanceof Error ? error.message : String(error);
return { type: "unsupported", message: `Reload failed: ${message}` };
}
return { type: "done", message: "Session runtime resources reloaded. Extensions, skills, prompt templates, themes, and context/system prompt files are refreshed for this session. Reload the browser page separately for PI WEB browser plugin changes." };
}
private async clone(active: CommandActiveSession<TSession>): Promise<ClientCommandResult> {
if (sessionHasActiveWork(active.runtime.session)) return forkActiveUnsupported("clone");
const leafId = active.runtime.session.sessionManager.getLeafId();