Archived
fix(sessions): surface extension command notifications
This commit is contained in:
@@ -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) => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user