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();
|
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 () => {
|
it("clears stale active activity once a previously active session becomes idle", async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
let service: PiSessionService | undefined;
|
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 { InMemoryCredentialStore, type Credential, type CredentialStore } from "@earendil-works/pi-ai";
|
||||||
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
import type { GlobalSessionEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
@@ -89,6 +89,37 @@ export function createTestModelRuntime(credentials: CredentialStore = new InMemo
|
|||||||
*/
|
*/
|
||||||
export const testModelRuntime = await createTestModelRuntime();
|
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"]> {
|
export function testModel(): NonNullable<PiAgentSession["model"]> {
|
||||||
const model = testModelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
|
const model = testModelRuntime.getModel(TEST_MODEL_PROVIDER, TEST_MODEL_ID);
|
||||||
if (model === undefined) throw new Error("test model not found");
|
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 },
|
settingsManager: { getWarnings: () => ({}), setWarnings: () => undefined },
|
||||||
modelRuntime: testModelRuntime,
|
modelRuntime: testModelRuntime,
|
||||||
scopedModels: [],
|
scopedModels: [],
|
||||||
extensionRunner: { getRegisteredCommands: () => [] },
|
extensionRunner: {
|
||||||
|
getRegisteredCommands: () => [],
|
||||||
|
getUIContext: () => testExtensionUiContext,
|
||||||
|
},
|
||||||
promptTemplates: [],
|
promptTemplates: [],
|
||||||
resourceLoader: { getSkills: () => ({ skills: [] }) },
|
resourceLoader: { getSkills: () => ({ skills: [] }) },
|
||||||
subscribe: (listener: (event: unknown) => void) => {
|
subscribe: (listener: (event: unknown) => void) => {
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
type AgentSessionServices,
|
type AgentSessionServices,
|
||||||
type CreateAgentSessionRuntimeFactory,
|
type CreateAgentSessionRuntimeFactory,
|
||||||
type EditToolDetails,
|
type EditToolDetails,
|
||||||
|
type ExtensionUIContext,
|
||||||
type ModelRuntime,
|
type ModelRuntime,
|
||||||
type ResourceDiagnostic,
|
type ResourceDiagnostic,
|
||||||
} from "@earendil-works/pi-coding-agent";
|
} from "@earendil-works/pi-coding-agent";
|
||||||
@@ -204,6 +205,8 @@ interface PiExtensionError {
|
|||||||
}
|
}
|
||||||
|
|
||||||
interface PiExtensionBindings {
|
interface PiExtensionBindings {
|
||||||
|
uiContext?: ExtensionUIContext;
|
||||||
|
mode?: "rpc";
|
||||||
onError?: (error: PiExtensionError) => void;
|
onError?: (error: PiExtensionError) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,7 +242,10 @@ export interface PiAgentSession {
|
|||||||
isCompacting: boolean;
|
isCompacting: boolean;
|
||||||
isBashRunning: boolean;
|
isBashRunning: boolean;
|
||||||
pendingMessageCount: number;
|
pendingMessageCount: number;
|
||||||
extensionRunner: { getRegisteredCommands(): readonly { invocationName: string; description?: string }[] };
|
extensionRunner: {
|
||||||
|
getRegisteredCommands(): readonly { invocationName: string; description?: string }[];
|
||||||
|
getUIContext(): ExtensionUIContext;
|
||||||
|
};
|
||||||
promptTemplates: readonly { name: string; description?: string }[];
|
promptTemplates: readonly { name: string; description?: string }[];
|
||||||
resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } };
|
resourceLoader: { getSkills(): { skills: readonly { name: string; description?: string }[] } };
|
||||||
subscribe(listener: (event: unknown) => void): () => void;
|
subscribe(listener: (event: unknown) => void): () => void;
|
||||||
@@ -1903,7 +1909,27 @@ export class PiSessionService implements SessionRouteService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async bindSessionExtensions(session: PiAgentSession): Promise<void> {
|
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({
|
await session.bindExtensions({
|
||||||
|
uiContext,
|
||||||
|
mode: "rpc",
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
const message = `${error.extensionPath}: ${error.error}`;
|
const message = `${error.extensionPath}: ${error.error}`;
|
||||||
this.publishActivity(session, "extension error", "error", message);
|
this.publishActivity(session, "extension error", "error", message);
|
||||||
|
|||||||
Reference in New Issue
Block a user