From d72a0012c7f2921b86566f55020cd3d787101935 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 18 Jul 2026 20:49:41 +0200 Subject: [PATCH] fix(sessions): surface extension command notifications --- .../show-extension-command-notifications.md | 5 +++ .../piSessionService.lifecycle.test.ts | 40 +++++++++++++++++++ .../sessions/piSessionService.testSupport.ts | 38 +++++++++++++++++- src/server/sessions/piSessionService.ts | 28 ++++++++++++- 4 files changed, 108 insertions(+), 3 deletions(-) create mode 100644 .changeset/show-extension-command-notifications.md diff --git a/.changeset/show-extension-command-notifications.md b/.changeset/show-extension-command-notifications.md new file mode 100644 index 0000000..6c73940 --- /dev/null +++ b/.changeset/show-extension-command-notifications.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Show notifications emitted by Pi extension slash commands in the web chat. diff --git a/src/server/sessions/piSessionService.lifecycle.test.ts b/src/server/sessions/piSessionService.lifecycle.test.ts index bf33407..a987bc8 100644 --- a/src/server/sessions/piSessionService.lifecycle.test.ts +++ b/src/server/sessions/piSessionService.lifecycle.test.ts @@ -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; diff --git a/src/server/sessions/piSessionService.testSupport.ts b/src/server/sessions/piSessionService.testSupport.ts index 5ddefa3..cee3352 100644 --- a/src/server/sessions/piSessionService.testSupport.ts +++ b/src/server/sessions/piSessionService.testSupport.ts @@ -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 { 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 settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined }, modelRuntime: testModelRuntime, scopedModels: [], - extensionRunner: { getRegisteredCommands: () => [] }, + extensionRunner: { + getRegisteredCommands: () => [], + getUIContext: () => testExtensionUiContext, + }, promptTemplates: [], resourceLoader: { getSkills: () => ({ skills: [] }) }, subscribe: (listener: (event: unknown) => void) => { diff --git a/src/server/sessions/piSessionService.ts b/src/server/sessions/piSessionService.ts index 1e8d9cd..0a2a56d 100644 --- a/src/server/sessions/piSessionService.ts +++ b/src/server/sessions/piSessionService.ts @@ -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 { + 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);