fix(sessions): surface extension command notifications

This commit is contained in:
Federico Jaramillo Martinez
2026-07-18 20:49:41 +02:00
parent d2e2d512cd
commit d72a0012c7
4 changed files with 108 additions and 3 deletions
@@ -0,0 +1,5 @@
---
"@jmfederico/pi-web": patch
---
Show notifications emitted by Pi extension slash commands in the web chat.
@@ -319,6 +319,46 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
await service.dispose();
});
it("surfaces notifications when an extension command shares a bare name with a skill", async () => {
const hub = new CapturingSessionEventHub();
const fake = fakeRuntime("extension-command-session", {
resourceLoader: { getSkills: () => ({ skills: [{ name: "ctx-stats" }] }) },
});
let extensionNotify: ((message: string, type?: "info" | "warning" | "error") => void) | undefined;
let extensionMode: string | undefined;
fake.session.extensionRunner.getRegisteredCommands = () => [{ invocationName: "ctx-stats" }];
fake.session.bindExtensions = (bindings) => {
const uiContext = bindings.uiContext;
extensionNotify = uiContext === undefined
? undefined
: (message, type) => { uiContext.notify(message, type); };
extensionMode = bindings.mode;
return Promise.resolve();
};
fake.session.prompt = (text) => {
if (text === "/ctx-stats") extensionNotify?.("context-mode stats", "info");
return Promise.resolve();
};
const service = new PiSessionService(hub, {
agentDir: TEST_AGENT_DIR,
modelRuntime: testModelRuntime,
createAgentRuntime: runtimeCreator(fake.runtime),
sessionManager: sessionGateway([]),
heartbeatIntervalMs: 60_000,
});
await service.start("/workspace");
await expect(service.runCommand(sessionRef("extension-command-session"), "/ctx-stats")).resolves.toEqual({ type: "done" });
expect(extensionMode).toBe("rpc");
expect(hub.sessionEvents).toContainEqual({
sessionId: "extension-command-session",
event: { type: "command.output", level: "info", message: "context-mode stats" },
});
await service.dispose();
});
it("clears stale active activity once a previously active session becomes idle", async () => {
vi.useFakeTimers();
let service: PiSessionService | undefined;
@@ -1,4 +1,4 @@
import { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { ModelRuntime, type ExtensionUIContext } from "@earendil-works/pi-coding-agent";
import { InMemoryCredentialStore, type Credential, type CredentialStore } from "@earendil-works/pi-ai";
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
import { SessionEventHub } from "../realtime/sessionEventHub.js";
@@ -89,6 +89,37 @@ export function createTestModelRuntime(credentials: CredentialStore = new InMemo
*/
export const testModelRuntime = await createTestModelRuntime();
const testExtensionUiContext: ExtensionUIContext = {
select: () => Promise.resolve(undefined),
confirm: () => Promise.resolve(false),
input: () => Promise.resolve(undefined),
notify() { /* no-op */ },
onTerminalInput: () => () => undefined,
setStatus() { /* no-op */ },
setWorkingMessage() { /* no-op */ },
setWorkingVisible() { /* no-op */ },
setWorkingIndicator() { /* no-op */ },
setHiddenThinkingLabel() { /* no-op */ },
setWidget() { /* no-op */ },
setFooter() { /* no-op */ },
setHeader() { /* no-op */ },
setTitle() { /* no-op */ },
custom: () => Promise.reject(new Error("Custom extension UI is unavailable in tests")),
pasteToEditor() { /* no-op */ },
setEditorText() { /* no-op */ },
getEditorText: () => "",
editor: () => Promise.resolve(undefined),
addAutocompleteProvider() { /* no-op */ },
setEditorComponent() { /* no-op */ },
getEditorComponent: () => undefined,
get theme(): ExtensionUIContext["theme"] { throw new Error("Extension UI theme is unavailable in tests"); },
getAllThemes: () => [],
getTheme: () => undefined,
setTheme: () => ({ success: false, error: "Extension UI is unavailable in tests" }),
getToolsExpanded: () => false,
setToolsExpanded() { /* no-op */ },
};
export function testModel(): NonNullable<PiAgentSession["model"]> {
const model = testModelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
if (model === undefined) throw new Error("test model not found");
@@ -117,7 +148,10 @@ export function fakeRuntime(sessionId = "session-1", patch: Partial<TestSession>
settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined },
modelRuntime: testModelRuntime,
scopedModels: [],
extensionRunner: { getRegisteredCommands: () => [] },
extensionRunner: {
getRegisteredCommands: () => [],
getUIContext: () => testExtensionUiContext,
},
promptTemplates: [],
resourceLoader: { getSkills: () => ({ skills: [] }) },
subscribe: (listener: (event: unknown) => void) => {
+27 -1
View File
@@ -15,6 +15,7 @@ import {
type AgentSessionServices,
type CreateAgentSessionRuntimeFactory,
type EditToolDetails,
type ExtensionUIContext,
type ModelRuntime,
type ResourceDiagnostic,
} from "@earendil-works/pi-coding-agent";
@@ -204,6 +205,8 @@ interface PiExtensionError {
}
interface PiExtensionBindings {
uiContext?: ExtensionUIContext;
mode?: "rpc";
onError?: (error: PiExtensionError) => void;
}
@@ -239,7 +242,10 @@ export interface PiAgentSession {
isCompacting: boolean;
isBashRunning: boolean;
pendingMessageCount: number;
extensionRunner: { getRegisteredCommands(): readonly { invocationName: string; description?: string }[] };
extensionRunner: {
getRegisteredCommands(): readonly { invocationName: string; description?: string }[];
getUIContext(): ExtensionUIContext;
};
promptTemplates: readonly { name: string; description?: string }[];
resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } };
subscribe(listener: (event: unknown) => void): () => void;
@@ -1903,7 +1909,27 @@ export class PiSessionService implements SessionRouteService {
}
private async bindSessionExtensions(session: PiAgentSession): Promise<void> {
const baseUiContext = session.extensionRunner.getUIContext();
const notify: ExtensionUIContext["notify"] = (message, type) => {
this.events.publish(session.sessionId, {
type: "command.output",
level: type === "error" ? "error" : "info",
message,
});
};
// PI WEB is a remote UI host, but currently only extension notifications
// cross this boundary. Delegate every other UI method to Pi's headless
// defaults so unsupported dialogs cancel safely instead of hanging.
const uiContext = new Proxy(baseUiContext, {
get(target, property, receiver): unknown {
if (property === "notify") return notify;
const value: unknown = Reflect.get(target, property, receiver);
return value;
},
});
await session.bindExtensions({
uiContext,
mode: "rpc",
onError: (error) => {
const message = `${error.extensionPath}: ${error.error}`;
this.publishActivity(session, "extension error", "error", message);