docs: clarify Pi-compatible agent profiles

This commit is contained in:
Federico Jaramillo Martinez
2026-07-14 00:30:09 +02:00
38 changed files with 1282 additions and 160 deletions
+30
View File
@@ -250,6 +250,36 @@ describe("session API compatibility", () => {
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt");
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo", text: "hello" });
});
it("clears a session queue through an encoded machine route and parses the returned status", async () => {
const fetchMock = stubJsonFetch({
sessionId: "s /?",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
tokens: { input: 3, output: 2, cacheRead: 1, cacheWrite: 0, total: 6 },
cost: 0.25,
ignored: "not part of SessionStatus",
});
await expect(sessionsApi.clearQueue({ id: "s /?", cwd: "/repo with spaces" }, "remote /?")).resolves.toEqual({
sessionId: "s /?",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 0,
queuedMessages: [],
tokens: { input: 3, output: 2, cacheRead: 1, cacheWrite: 0, total: 6 },
cost: 0.25,
});
expect(fetchMock).toHaveBeenCalledOnce();
const [url, init] = fetchCall(fetchMock, 0);
expect(url).toBe("https://pi.example.test/api/machines/remote%20%2F%3F/sessions/s%20%2F%3F/queue/clear");
expect(init?.method).toBe("POST");
expect(JSON.parse(requestBody(init))).toEqual({ cwd: "/repo with spaces" });
});
});
describe("machine-scoped file suggestion API", () => {
+1
View File
@@ -209,6 +209,7 @@ export const sessionsApi = {
deleteArchivedMany: (sessions: readonly SessionLookup[], machineId = "local") => request(`${machinePrefix(machineId)}/sessions/bulk/delete-archived`, parseSessionBulkDeleteArchivedResponse, { method: "POST", body: sessionBulkMutationBody(sessions) }),
messages: (session: SessionLookup, options?: { limit?: number; before?: number }, machineId = "local") => request(messagePath(session, options, machineId), parseMessagePage),
status: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "status", machineId), parseSessionStatus),
clearQueue: (session: SessionLookup, machineId = "local") => request(sessionPath(session, "queue/clear", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session) }),
models: (session: SessionLookup, machineId = "local") => request(sessionQueryPath(session, "models", machineId), parseModelSelectionResponse),
setModel: (session: SessionLookup, provider: string, modelId: string, machineId = "local") => request(sessionPath(session, "model", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { provider, modelId }) }),
cycleModel: (session: SessionLookup, direction: "forward" | "backward", machineId = "local") => request(sessionPath(session, "model/cycle", machineId), parseSessionStatus, { method: "POST", body: sessionBody(session, { direction }) }),
@@ -64,6 +64,7 @@ describe("federated route contract", () => {
ignoreParseFailure(sessionsApi.deleteArchivedMany([session], machineId)),
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
ignoreParseFailure(sessionsApi.status(session, machineId)),
ignoreParseFailure(sessionsApi.clearQueue(session, machineId)),
ignoreParseFailure(sessionsApi.models(session, machineId)),
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
+2 -2
View File
@@ -54,13 +54,13 @@ export class AuthDialog extends LitElement {
case "method": return html`
<div class="options">
<button @click=${() => { this.onChooseMethod?.("oauth"); }}><span>Use a subscription</span><small>ChatGPT Plus/Pro, Claude Pro/Max, or GitHub Copilot</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in the configured agent auth.json</small></button>
<button @click=${() => { this.onChooseMethod?.("api_key"); }}><span>Use an API key</span><small>Store an API key in the active Pi-compatible profile's auth.json</small></button>
</div>
`;
case "providers": return html`<div class="options">${state.providers.length === 0 ? html`<div class="empty">No providers available.</div>` : state.providers.map((provider) => this.renderProviderButton(provider))}</div>`;
case "apiKey": return html`
<div class="form">
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored in the configured agent <code>auth.json</code>.</p>
<p>Enter the API key for <strong>${state.provider.name}</strong>. It will be stored in the active Pi-compatible profile's <code>auth.json</code>.</p>
<input type="password" autocomplete="off" placeholder="API key" .value=${state.value} @input=${(event: Event) => { if (event.target instanceof HTMLInputElement) this.onApiKeyInput?.(event.target.value); }}>
${state.error !== undefined && state.error !== "" ? html`<div class="error-text">${state.error}</div>` : null}
<div class="actions"><button @click=${() => { this.cancel(); }}>Cancel</button><button class="primary" ?disabled=${state.saving === true} @click=${() => { this.onSaveApiKey?.(); }}>${state.saving === true ? "Saving…" : "Save API key"}</button></div>
+92 -8
View File
@@ -1,5 +1,6 @@
import type { TemplateResult } from "lit";
import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";
import type { QueuedSessionMessage, SessionStatus } from "../api";
import type { ChatLine } from "./shared";
import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
@@ -12,19 +13,61 @@ describe("chatQueuedMessageSections", () => {
expect(sections).toEqual([
{
source: "client",
heading: "Queued until session starts",
detail: "Will send once the backend session is ready",
messages: [{ kind: "followUp", text: "queued before start" }],
},
{
source: "server",
heading: "Queued messages",
detail: "1 pending · Stop clears the queue",
detail: "1 pending",
messages: [{ kind: "steer", text: "server queued" }],
},
]);
});
});
describe("ChatView queued-message clear action", () => {
// Direct handler extraction keeps this node-environment test focused on the
// Clear queue template wiring without introducing a component-wide DOM shim.
it("renders an accessible server-queue action and invokes its callback", () => {
const view = new ChatView();
const onClearServerQueue = vi.fn();
view.status = queuedStatus([{ kind: "steer", text: "server queued" }]);
view.canClearServerQueue = true;
view.onClearServerQueue = onClearServerQueue;
const rendered = renderQueuedMessages(view);
const markup = templateStaticMarkup(rendered);
expect(markup).toContain('type="button"');
expect(markup).toContain('title="Clear queued messages without stopping active work"');
expect(markup).toContain(">Clear queue</button>");
templateEventHandler(rendered, "Clear queue")(new Event("click"));
expect(onClearServerQueue).toHaveBeenCalledOnce();
});
it("hides the action when the selected runtime does not support clearing", () => {
const view = new ChatView();
view.status = queuedStatus([{ kind: "followUp", text: "server queued" }]);
view.canClearServerQueue = false;
view.onClearServerQueue = vi.fn();
expect(templateStaticMarkup(renderQueuedMessages(view))).not.toContain("Clear queue");
});
it("does not expose the server action for the separate client pending-start queue", () => {
const view = new ChatView();
view.status = queuedStatus([]);
view.clientQueuedMessages = [{ kind: "followUp", text: "waiting for session start" }];
view.canClearServerQueue = true;
view.onClearServerQueue = vi.fn();
expect(templateStaticMarkup(renderQueuedMessages(view))).not.toContain("Clear queue");
});
});
describe("chatMessageMetadataLabel", () => {
it("uses one full date and model label without a model prefix", () => {
const timestamp = "2026-07-10T19:15:30.000Z";
@@ -103,10 +146,17 @@ interface GroupBodyRenderCall {
startIndex: number;
}
type RenderQueuedMessages = (this: ChatView) => TemplateResult;
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
type TemplateEventHandler = (event: Event) => void;
function renderQueuedMessages(view: ChatView): TemplateResult {
const method: unknown = Reflect.get(view, "renderQueuedMessages");
if (!isRenderQueuedMessages(method)) throw new Error("ChatView.renderQueuedMessages is not callable");
return method.call(view);
}
function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult {
const method: unknown = Reflect.get(view, "renderMessageGroup");
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
@@ -125,6 +175,10 @@ function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
return calls;
}
function isRenderQueuedMessages(value: unknown): value is RenderQueuedMessages {
return typeof value === "function";
}
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
return typeof value === "function";
}
@@ -134,13 +188,30 @@ function isRenderMessageGroupBody(value: unknown): value is RenderMessageGroupBo
}
function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler {
const strings = templateStrings(template);
const values = templateValues(template);
for (let index = 0; index < values.length; index += 1) {
const value = values[index];
if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value;
let handler: TemplateEventHandler | undefined;
visit(template);
if (handler === undefined) throw new Error(`Expected template event handler near ${marker}`);
return handler;
function visit(value: unknown): void {
if (handler !== undefined) return;
if (Array.isArray(value)) {
for (const item of value) visit(item);
return;
}
if (!isTemplateResult(value)) return;
const strings = templateStrings(value);
const values = templateValues(value);
for (let index = 0; index < values.length; index += 1) {
const candidate = values[index];
const isNearMarker = strings[index]?.includes(marker) === true || strings[index + 1]?.includes(marker) === true;
if (isNearMarker && isTemplateEventHandler(candidate)) {
handler = candidate;
return;
}
visit(candidate);
}
}
throw new Error(`Expected template event handler after ${marker}`);
}
function isTemplateEventHandler(value: unknown): value is TemplateEventHandler {
@@ -221,3 +292,16 @@ function isTemplateResult(value: unknown): value is TemplateResult {
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
function queuedStatus(queuedMessages: QueuedSessionMessage[]): SessionStatus {
return {
sessionId: "session-1",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: queuedMessages.length,
queuedMessages,
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
}
+16 -4
View File
@@ -39,6 +39,7 @@ function clampNumber(value: number, min: number, max: number): number {
}
export interface QueuedMessageSection {
source: "client" | "server";
heading: string;
detail: string;
messages: QueuedSessionMessage[];
@@ -46,8 +47,8 @@ export interface QueuedMessageSection {
export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] {
return [
clientQueued.length === 0 ? undefined : { heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued },
serverQueued.length === 0 ? undefined : { heading: "Queued messages", detail: `${String(serverQueued.length)} pending · Stop clears the queue`, messages: serverQueued },
clientQueued.length === 0 ? undefined : { source: "client", heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued },
serverQueued.length === 0 ? undefined : { source: "server", heading: "Queued messages", detail: `${String(serverQueued.length)} pending`, messages: serverQueued },
].filter((section): section is QueuedMessageSection => section !== undefined);
}
@@ -89,6 +90,8 @@ export class ChatView extends LitElement {
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
@property({ attribute: false }) status?: SessionStatus;
@property({ attribute: false }) activity?: SessionActivity;
@property({ type: Boolean }) canClearServerQueue = false;
@property({ attribute: false }) onClearServerQueue?: () => void;
@property({ attribute: false }) onLoadMore?: () => void;
@query(".chat") private chat?: HTMLDivElement;
@state() private pinnedToBottom = true;
@@ -123,6 +126,9 @@ export class ChatView extends LitElement {
private readonly onPageHide = () => {
this.saveScrollPosition();
};
private readonly handleClearServerQueue = (): void => {
this.onClearServerQueue?.();
};
override connectedCallback(): void {
super.connectedCallback();
@@ -261,11 +267,17 @@ export class ChatView extends LitElement {
}
private renderQueuedMessageList(section: QueuedMessageSection) {
const canClear = section.source === "server" && this.canClearServerQueue && this.onClearServerQueue !== undefined;
return html`
<aside class="queued-messages" aria-live="polite">
<div class="queued-header">
<strong>${section.heading}</strong>
<small>${section.detail}</small>
<div class="queued-heading">
<strong>${section.heading}</strong>
<small>${section.detail}</small>
</div>
${canClear ? html`
<button type="button" class="queued-clear-button" title="Clear queued messages without stopping active work" @click=${this.handleClearServerQueue}>Clear queue</button>
` : null}
</div>
${section.messages.map((message, index) => html`
<div class="queued-message">
@@ -0,0 +1,151 @@
import type { TemplateResult } from "lit";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { MachineRuntime, SessionInfo, SessionStatus } from "../api";
import { initialAppState, type AppState } from "../appState";
import { SessionController } from "../controllers/sessionController";
import { PI_WEB_CAPABILITIES } from "../../../shared/capabilities";
import { PiWebApp } from "./PiWebApp";
afterEach(() => {
vi.restoreAllMocks();
vi.unstubAllGlobals();
});
describe("PiWebApp queued-message clear wiring", () => {
it("passes a stable supported-runtime callback through to SessionController", () => {
const app = createApp();
const state = stateWithRuntime(runtimeWithCapabilities([PI_WEB_CAPABILITIES.sessionsClearQueue]));
setAppState(app, state);
const controller = appSessionController(app);
const clearServerQueue = vi.spyOn(controller, "clearServerQueue").mockResolvedValue(undefined);
const firstRender = renderChatView(app, state);
const secondRender = renderChatView(app, state);
const firstCallback = templateCallbackAfterMarker(firstRender, ".onClearServerQueue=");
const secondCallback = templateCallbackAfterMarker(secondRender, ".onClearServerQueue=");
expect(templateValueAfterMarker(firstRender, ".canClearServerQueue=")).toBe(true);
expect(secondCallback).toBe(firstCallback);
firstCallback();
expect(clearServerQueue).toHaveBeenCalledOnce();
});
it("passes false when runtime discovery is unavailable, unhealthy, or lacks the capability", () => {
const app = createApp();
const runtimes: (MachineRuntime | undefined)[] = [
undefined,
{ ...runtimeWithCapabilities([PI_WEB_CAPABILITIES.sessionsClearQueue]), ok: false },
runtimeWithCapabilities([PI_WEB_CAPABILITIES.sessionsReload]),
];
for (const runtime of runtimes) {
const state = stateWithRuntime(runtime);
setAppState(app, state);
expect(templateValueAfterMarker(renderChatView(app, state), ".canClearServerQueue=")).toBe(false);
}
});
});
type RenderChatView = (this: PiWebApp, state: AppState, session: SessionInfo) => TemplateResult;
type ClearServerQueueCallback = () => void;
function createApp(): PiWebApp {
const storage = {
getItem: () => null,
setItem: () => undefined,
removeItem: () => undefined,
};
vi.stubGlobal("window", { location: { search: "" }, localStorage: storage });
return new PiWebApp();
}
function stateWithRuntime(runtime: MachineRuntime | undefined): AppState {
const session: SessionInfo = {
id: "session-1",
cwd: "/repo",
path: "/repo/session-1.jsonl",
created: "2026-07-14T00:00:00.000Z",
modified: "2026-07-14T00:00:00.000Z",
messageCount: 1,
firstMessage: "hello",
};
return {
...initialAppState(),
selectedSession: session,
status: queuedStatus(),
machineRuntimes: runtime === undefined ? {} : { local: runtime },
};
}
function runtimeWithCapabilities(capabilities: NonNullable<MachineRuntime["capabilities"]>): MachineRuntime {
return { machineId: "local", ok: true, checkedAt: "2026-07-14T00:00:00.000Z", capabilities };
}
function queuedStatus(): SessionStatus {
return {
sessionId: "session-1",
isStreaming: true,
isCompacting: false,
isBashRunning: false,
pendingMessageCount: 1,
queuedMessages: [{ kind: "followUp", text: "queued" }],
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
cost: 0,
};
}
function setAppState(app: PiWebApp, state: AppState): void {
if (!Reflect.set(app, "state", state)) throw new Error("Could not set PiWebApp state");
}
function appSessionController(app: PiWebApp): SessionController {
const controller: unknown = Reflect.get(app, "sessions");
if (!(controller instanceof SessionController)) throw new Error("PiWebApp SessionController was unavailable");
return controller;
}
function renderChatView(app: PiWebApp, state: AppState): TemplateResult {
const method: unknown = Reflect.get(app, "renderChatView");
if (!isRenderChatView(method)) throw new Error("PiWebApp.renderChatView is not callable");
const session = state.selectedSession;
if (session === undefined) throw new Error("Expected a selected session");
return method.call(app, state, session);
}
function isRenderChatView(value: unknown): value is RenderChatView {
return typeof value === "function";
}
function templateCallbackAfterMarker(template: TemplateResult, marker: string): ClearServerQueueCallback {
const value = templateValueAfterMarker(template, marker);
if (!isClearServerQueueCallback(value)) throw new Error(`Expected callback after ${marker}`);
return value;
}
function isClearServerQueueCallback(value: unknown): value is ClearServerQueueCallback {
return typeof value === "function";
}
function templateValueAfterMarker(template: TemplateResult, marker: string): unknown {
const strings = templateStrings(template);
const values = templateValues(template);
const index = strings.findIndex((part) => part.includes(marker));
if (index < 0) throw new Error(`Expected template marker ${marker}`);
return values[index];
}
function templateStrings(template: TemplateResult): readonly string[] {
const strings = Reflect.get(template, "strings");
if (!isStringArray(strings)) throw new Error("TemplateResult strings were unavailable");
return strings;
}
function templateValues(template: TemplateResult): readonly unknown[] {
const values = Reflect.get(template, "values");
if (!Array.isArray(values)) throw new Error("TemplateResult values were unavailable");
return values.map((value: unknown) => value);
}
function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item: unknown) => typeof item === "string");
}
+19 -4
View File
@@ -1023,6 +1023,11 @@ export class PiWebApp extends LitElement {
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsReload);
}
private canClearServerQueue(): boolean {
const runtime = this.selectedMachineRuntime();
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsClearQueue);
}
private canCleanupSessions(): boolean {
const runtime = this.selectedMachineRuntime();
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
@@ -1854,9 +1859,9 @@ export class PiWebApp extends LitElement {
void this.sessions.send(text, streamingBehavior, attachments, delivery);
}
// Stable handler identities for <prompt-editor>. Inlined arrow closures would
// be a fresh reference on every render, forcing Lit to re-commit the bindings
// each time the app re-renders; bound class fields keep them constant.
// Stable handler identities for child components. Inlined arrow closures
// would be a fresh reference on every render, forcing Lit to re-commit the
// bindings each time the app re-renders; bound class fields keep them constant.
private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => {
this.sendPrompt(text, streamingBehavior, attachments, delivery);
};
@@ -1865,6 +1870,10 @@ export class PiWebApp extends LitElement {
void this.sessions.stopActiveWork();
};
private readonly handleClearServerQueue = (): void => {
void this.sessions.clearServerQueue();
};
private readonly handleSelectModel = (): void => {
void this.openModelDialog();
};
@@ -1873,6 +1882,12 @@ export class PiWebApp extends LitElement {
void this.openThinkingDialog();
};
private renderChatView(state: AppState, session: SessionInfo) {
return html`
<chat-view .sessionId=${session.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[session.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[session.id] ?? []} .status=${state.status} .activity=${state.activity} .canClearServerQueue=${this.canClearServerQueue()} .onClearServerQueue=${this.handleClearServerQueue} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
`;
}
private renderContextBar() {
if (!this.appShell.isMobileNavigationLayout) return null;
return html`
@@ -1931,7 +1946,7 @@ export class PiWebApp extends LitElement {
${state.error ? html`<div class="error">${state.error}</div>` : null}
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
${state.selectedSession ? html`
<chat-view .sessionId=${state.selectedSession.id} .messages=${state.messages} .messageStart=${state.messagePageStart} .messageEnd=${state.messagePageEnd} .messageTotal=${state.messagePageTotal} .hasMore=${state.messagePageStart > 0} .loadingMore=${state.isLoadingEarlierMessages} .isReceivingPartialStream=${state.isReceivingPartialStream} .isSendingPrompt=${state.sendingPrompts[state.selectedSession.id] === true} .isCompacting=${state.status?.isCompacting === true} .pendingMessageCount=${state.status?.pendingMessageCount ?? 0} .clientQueuedMessages=${state.clientQueuedSessionMessages[state.selectedSession.id] ?? []} .status=${state.status} .activity=${state.activity} .onLoadMore=${() => this.withChatPrependTransition(() => this.sessions.loadEarlierMessages())}></chat-view>
${this.renderChatView(state, state.selectedSession)}
<prompt-editor .sessionId=${state.selectedSession.id} .cwd=${state.selectedWorkspace?.path} .machineId=${selectedMachineId(state)} .projectId=${state.selectedWorkspace?.projectId} .workspaceId=${state.selectedWorkspace?.id} .workspaceScopedFileSuggestions=${this.supportsWorkspaceFileSuggestions()} .disabled=${state.selectedSession.archived === true} .canSteer=${state.status?.isStreaming === true} .isCompacting=${state.status?.isCompacting === true} .canStop=${state.status?.isStreaming === true || state.status?.isBashRunning === true || state.status?.isCompacting === true || (state.status?.pendingMessageCount ?? 0) > 0} .status=${state.status} .availableThinkingLevels=${state.availableThinkingLevels} .sending=${state.sendingPrompts[state.selectedSession.id] === true} .onSend=${this.handleSendPrompt} .onStop=${this.handleStopActiveWork} .onSelectModel=${this.handleSelectModel} .onSelectThinking=${this.handleSelectThinking}></prompt-editor>
<status-bar .status=${state.status}></status-bar>
${state.commandDialog !== undefined ? html`<command-picker .title=${state.commandDialog.title} .options=${state.commandDialog.options} .onPick=${(value: string) => this.sessions.respondToCommand(state.commandDialog?.requestId ?? "", value)} .onCancel=${() => { this.sessions.cancelCommand(); }}></command-picker>` : null}
@@ -87,7 +87,7 @@ describe("settings-dialog session daemon machine targeting", () => {
await callDialogPromise(dialog, "saveSessiondConfig", { agent: { command: "agent-lab", dir: "/srv/agent-lab" } });
expect(saveSpy).not.toHaveBeenCalled();
expect(getDialogProperty(dialog, "sessiondError")).toBe("Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
expect(getDialogProperty(dialog, "sessiondError")).toBe("Pi-compatible agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.");
});
it("saves a remote agent profile when granular support is advertised", async () => {
+1 -1
View File
@@ -437,7 +437,7 @@ export class SettingsDialog extends LitElement {
if (config.agent !== undefined) {
const profileSupport = this.agentProfileSettingsSupport(target);
if (!isAgentProfileSettingsSupported(profileSupport)) {
this.sessiondError = profileSupport.message ?? `Agent profile settings are not available on ${settingsMachineTargetLabel(target)}.`;
this.sessiondError = profileSupport.message ?? `Pi-compatible agent profile settings are not available on ${settingsMachineTargetLabel(target)}.`;
return;
}
}
@@ -19,14 +19,14 @@ describe("settings-sessiond-panel layout", () => {
expectTextOrder(rendered, [
"Session daemon",
"These settings affect the long-lived session runtime on Lab Mac (remote machine).",
"Select the Pi-compatible agent profile and companion CLI for Lab Mac (remote machine).",
"Reload",
"Agent profile restart required on Lab Mac (remote machine)",
"Pi-compatible agent profile restart required on Lab Mac (remote machine)",
"Run <code>pi-web restart</code> on that machine",
"Config file",
"Companion CLI command",
"agent-lab",
"Agent state directory",
"Profile state directory",
"/srv/agent-lab",
"Allow agents to start sessions",
]);
@@ -44,7 +44,7 @@ describe("settings-sessiond-panel layout", () => {
expectTextOrder(rendered, [
"Failed to save session-daemon config.",
"Session daemon settings saved.",
"Agent profile restart required on local (local gateway)",
"Pi-compatible agent profile restart required on local (local gateway)",
"Config file",
]);
});
@@ -69,7 +69,7 @@ export class SettingsSessiondPanel extends LitElement {
<code>${config.path}</code>
</div>
<form class="profile-form" aria-label="Pi-compatible agent profile" @submit=${(event: Event) => { void this.saveAgentProfile(event); }}>
${profileEditingSupported ? null : html`<div class="profile-support-message">${this.agentProfileSupport.message ?? "Agent profile editing is unavailable for this machine."}</div>`}
${profileEditingSupported ? null : html`<div class="profile-support-message">${this.agentProfileSupport.message ?? "Pi-compatible agent profile editing is unavailable for this machine."}</div>`}
<label class="field">
<span class="field-heading">
<span>Companion CLI command</span>
@@ -89,7 +89,7 @@ export class SettingsSessiondPanel extends LitElement {
</label>
<label class="field">
<span class="field-heading">
<span>Agent state directory</span>
<span>Profile state directory</span>
${effectiveAgentDirOverridden ? html`<span class="override-badge">environment override</span>` : null}
</span>
<input
@@ -102,7 +102,7 @@ export class SettingsSessiondPanel extends LitElement {
?disabled=${this.loading || this.saving || !profileEditingSupported || agentDirLocked}
@input=${(event: Event) => { this.updateAgentDraft({ dir: inputValue(event) }); }}
>
<small>Choose the compatible auth, models, settings, and sessions PI WEB reads. An alternate command and its required state directory are saved together.</small>
<small>Choose the Pi-compatible auth, models, settings, and sessions PI WEB reads. An alternate command and its required state directory are saved together.</small>
</label>
<footer class="form-actions">
<button class="primary" type="submit" ?disabled=${this.loading || this.saving || !profileEditingSupported || (agentCommandOverridden && agentDirLocked)}>${this.saving ? "Saving…" : "Save agent profile"}</button>
@@ -167,13 +167,13 @@ export class SettingsSessiondPanel extends LitElement {
if (activation === "restart-required") {
notices.push({
type: "warning",
title: `Agent profile restart required on ${this.targetLabel}`,
title: `Pi-compatible agent profile restart required on ${this.targetLabel}`,
content: html`The desired profile differs from the active session-daemon profile. Run <code>pi-web restart</code> on that machine (or restart its session daemon service) to apply the command and state directory together.`,
});
} else if (config !== undefined && activation === "unavailable" && this.agentProfileSupport.state === "supported") {
notices.push({
type: "info",
title: `Active agent profile unavailable on ${this.targetLabel}`,
title: `Active Pi-compatible agent profile unavailable on ${this.targetLabel}`,
content: "PI WEB cannot compare the desired profile with the running session daemon. Reload after the daemon is available.",
});
}
@@ -274,5 +274,5 @@ function errorMessage(error: unknown): string {
}
function sessiondDescription(targetLabel: string): string {
return `These settings affect the long-lived session runtime on ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
return `Select the Pi-compatible agent profile and companion CLI for ${targetLabel}. Changes are saved immediately but only take effect after the session daemon on that machine restarts.`;
}
@@ -45,7 +45,7 @@ describe("selected-machine settings target helpers", () => {
expect(agentProfileSettingsSupport({ id: "local", name: "local", kind: "local" }, undefined)).toEqual({ state: "supported" });
expect(agentProfileSettingsSupport(target, undefined)).toEqual({
state: "unknown",
message: "Agent profile support could not be verified on Lab Mac. Reload machine status before changing the profile.",
message: "Pi-compatible agent profile support could not be verified on Lab Mac. Reload machine status before changing the profile.",
});
expect(agentProfileSettingsSupport(target, {
ok: true,
@@ -56,7 +56,7 @@ describe("selected-machine settings target helpers", () => {
expect(isAgentProfileSettingsSupported(unsupported)).toBe(false);
expect(unsupported).toEqual({
state: "unsupported",
message: "Agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.",
message: "Pi-compatible agent profile settings are not available on Lab Mac. Update and restart PI WEB on that machine, then try again.",
});
});
@@ -37,13 +37,13 @@ export function agentProfileSettingsSupport(target: SettingsMachineTarget, runti
if (runtime?.ok !== true) {
return {
state: "unknown",
message: `Agent profile support could not be verified on ${target.name}. Reload machine status before changing the profile.`,
message: `Pi-compatible agent profile support could not be verified on ${target.name}. Reload machine status before changing the profile.`,
};
}
if (supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.agentProfileConfig)) return { state: "supported" };
return {
state: "unsupported",
message: `Agent profile settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`,
message: `Pi-compatible agent profile settings are not available on ${target.name}. Update and restart PI WEB on that machine, then try again.`,
};
}
+6 -3
View File
@@ -305,9 +305,12 @@ export const chatStyles = css`
.history-load-button:hover, .history-load-button:focus { border-color: var(--pi-accent); color: var(--pi-text-bright); }
.history-load-button:disabled { cursor: default; opacity: .55; }
.queued-messages { max-width: 100%; min-width: 0; box-sizing: border-box; display: grid; gap: 8px; margin: 0 0 14px; padding: 12px; border: 1px solid var(--pi-warning-border); border-radius: 10px; background: var(--pi-warning-surface); color: var(--pi-text); overflow: hidden; }
.queued-header { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.queued-header strong { color: var(--pi-warning); }
.queued-header small { color: var(--pi-muted); }
.queued-header { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 10px; }
.queued-heading { min-width: 0; flex: 1 1 180px; display: grid; gap: 2px; }
.queued-heading strong { color: var(--pi-warning); }
.queued-heading small { color: var(--pi-muted); }
.queued-clear-button { flex: 0 0 auto; border: 1px solid var(--pi-warning-border); border-radius: 999px; background: var(--pi-surface); color: var(--pi-warning); padding: 5px 10px; font: 12px system-ui, sans-serif; white-space: nowrap; cursor: pointer; }
.queued-clear-button:hover, .queued-clear-button:focus { border-color: var(--pi-warning); color: var(--pi-text-bright); }
.queued-message { display: grid; gap: 4px; padding-top: 8px; border-top: 1px solid var(--pi-border); }
.queued-message:first-of-type { padding-top: 0; border-top: 0; }
.queued-kind { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
@@ -0,0 +1,179 @@
import { describe, expect, it } from "vitest";
import { initialAppState } from "../appState";
import { SessionController } from "./sessionController";
import { defaultApi, deferred, FakeSocket, oldSession, replacementSession, sessionLookupId, status, workspace, type AppState, type SessionStatus } from "./sessionController.testSupport";
function machine(id: string): NonNullable<AppState["selectedMachine"]> {
return { id, name: id, kind: "remote", createdAt: "now", updatedAt: "now" };
}
describe("SessionController server queue clearing", () => {
it("applies the returned status to the selected session without changing client-side queued sends", async () => {
const queuedStatus: SessionStatus = {
...status(oldSession.id),
isStreaming: true,
pendingMessageCount: 2,
queuedMessages: [
{ kind: "steer", text: "adjust course" },
{ kind: "followUp", text: "then summarize" },
],
};
const clearedStatus: SessionStatus = { ...queuedStatus, pendingMessageCount: 0, queuedMessages: [] };
const clientQueuedSends = [{ kind: "followUp" as const, text: "waiting for session creation" }];
const clearCalls: { sessionId: string; machineId: string }[] = [];
let state: AppState = {
...initialAppState(),
selectedMachine: machine("remote-a"),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
status: queuedStatus,
sessionStatuses: { [oldSession.id]: queuedStatus },
clientQueuedSessionMessages: { [oldSession.id]: clientQueuedSends },
};
const api: typeof defaultApi = {
...defaultApi,
clearQueue: (session, machineId) => {
clearCalls.push({ sessionId: sessionLookupId(session), machineId: machineId ?? "local" });
return Promise.resolve(clearedStatus);
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.clearServerQueue();
expect(clearCalls).toEqual([{ sessionId: oldSession.id, machineId: "remote-a" }]);
expect(state.status).toEqual(clearedStatus);
expect(state.sessionStatuses[oldSession.id]).toEqual(clearedStatus);
expect(state.clientQueuedSessionMessages[oldSession.id]).toBe(clientQueuedSends);
});
it("does not apply a response after another session is selected", async () => {
const request = deferred<SessionStatus>();
const oldStatus: SessionStatus = { ...status(oldSession.id), pendingMessageCount: 1, queuedMessages: [{ kind: "followUp", text: "old queue" }] };
const replacementStatus: SessionStatus = { ...status(replacementSession.id), pendingMessageCount: 3, queuedMessages: [{ kind: "steer", text: "new queue" }] };
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession, replacementSession],
status: oldStatus,
sessionStatuses: { [oldSession.id]: oldStatus, [replacementSession.id]: replacementStatus },
};
const api: typeof defaultApi = { ...defaultApi, clearQueue: () => request.promise };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const clearing = controller.clearServerQueue();
state = { ...state, selectedSession: replacementSession, status: replacementStatus };
request.resolve({ ...oldStatus, pendingMessageCount: 0, queuedMessages: [] });
await clearing;
expect(state.status).toBe(replacementStatus);
expect(state.sessionStatuses[oldSession.id]).toBe(oldStatus);
expect(state.sessionStatuses[replacementSession.id]).toBe(replacementStatus);
});
it("does not apply a response after the selected machine changes", async () => {
const request = deferred<SessionStatus>();
const machineBStatus: SessionStatus = { ...status(oldSession.id), pendingMessageCount: 4, queuedMessages: [{ kind: "followUp", text: "machine B queue" }] };
let state: AppState = {
...initialAppState(),
selectedMachine: machine("remote-a"),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
status: status(oldSession.id),
sessionStatuses: { [oldSession.id]: status(oldSession.id) },
};
const api: typeof defaultApi = { ...defaultApi, clearQueue: () => request.promise };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
const clearing = controller.clearServerQueue();
state = {
...state,
selectedMachine: machine("remote-b"),
status: machineBStatus,
sessionStatuses: { [oldSession.id]: machineBStatus },
};
request.resolve(status(oldSession.id));
await clearing;
expect(state.status).toBe(machineBStatus);
expect(state.sessionStatuses[oldSession.id]).toBe(machineBStatus);
});
it("reports queue-clear failures through the application error state", async () => {
const queuedStatus: SessionStatus = { ...status(oldSession.id), pendingMessageCount: 1, queuedMessages: [{ kind: "steer", text: "keep me" }] };
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: oldSession,
sessions: [oldSession],
status: queuedStatus,
sessionStatuses: { [oldSession.id]: queuedStatus },
};
const api: typeof defaultApi = { ...defaultApi, clearQueue: () => Promise.reject(new Error("queue clear failed")) };
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.clearServerQueue();
expect(state.error).toBe("Error: queue clear failed");
expect(state.status).toBe(queuedStatus);
});
it("does not send a server clear for a client-pending session or discard its queued sends", async () => {
const pendingSession = { ...oldSession, id: "pending-session", clientPendingStart: true as const, machineId: "local" };
const clientQueuedSends = [{ kind: "followUp" as const, text: "send after creation" }];
let clearCalls = 0;
let state: AppState = {
...initialAppState(),
selectedWorkspace: workspace,
selectedSession: pendingSession,
sessions: [pendingSession],
clientQueuedSessionMessages: { [pendingSession.id]: clientQueuedSends },
};
const api: typeof defaultApi = {
...defaultApi,
clearQueue: () => {
clearCalls += 1;
return Promise.resolve(status(pendingSession.id));
},
};
const controller = new SessionController(
() => state,
(patch) => { state = { ...state, ...patch }; },
() => undefined,
undefined,
{ api, socket: new FakeSocket() },
);
await controller.clearServerQueue();
expect(clearCalls).toBe(0);
expect(state.clientQueuedSessionMessages[pendingSession.id]).toBe(clientQueuedSends);
});
});
@@ -723,6 +723,20 @@ export class SessionController {
}
}
async clearServerQueue() {
const state = this.getState();
const session = state.selectedSession;
if (session === undefined || session.archived === true || isClientPendingStartSessionInfo(session)) return;
const machineId = selectedMachineId(state);
const selectionSeq = this.selectionSeq;
try {
const status = await this.api.clearQueue(session, machineId);
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.applyStatus(status);
} catch (error) {
if (this.isCurrentSessionSelection(session.id, machineId, selectionSeq)) this.setState({ error: String(error) });
}
}
async stopActiveWork() {
const session = this.getState().selectedSession;
if (!session) return;
@@ -768,11 +782,15 @@ export class SessionController {
}
private isCurrentRefreshTarget(target: SelectedSessionRefreshTarget): boolean {
return this.isCurrentSessionSelection(target.session.id, target.machineId, target.selectionSeq);
}
private isCurrentSessionSelection(sessionId: string, machineId: string, selectionSeq: number): boolean {
const state = this.getState();
const selected = state.selectedSession;
return target.selectionSeq === this.selectionSeq
&& selectedMachineId(state) === target.machineId
&& selected?.id === target.session.id
return selectionSeq === this.selectionSeq
&& selectedMachineId(state) === machineId
&& selected?.id === sessionId
&& selected.archived !== true
&& !isClientPendingStartSessionInfo(selected);
}