Archived
docs: clarify Pi-compatible agent profiles
This commit is contained in:
@@ -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", () => {
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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.`,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -186,6 +186,24 @@ describe("buildApp remote machine proxy routes", () => {
|
||||
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
|
||||
});
|
||||
|
||||
it("proxies remote session queue clearing through the allowlisted route", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
const status = { sessionId: "s1", pendingMessageCount: 0, queuedMessages: [] };
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: Readable.from([JSON.stringify(status)]),
|
||||
}));
|
||||
appTestContext.remoteClient = fakeRemoteClient({ request });
|
||||
|
||||
const response = await appTestContext.app.inject({ method: "POST", url: `/api/machines/${remote.id}/sessions/s1/queue/clear`, payload: { cwd: "/repo" } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual(status);
|
||||
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/queue/clear", { cwd: "/repo" });
|
||||
});
|
||||
|
||||
it("forwards remote JSON request bodies and normalizes remote timeouts", async () => {
|
||||
const addResponse = await appTestContext.app.inject({ method: "POST", url: "/api/machines", payload: { name: "Remote", baseUrl: "https://remote.example.test/" } });
|
||||
const remote = addResponse.json<{ id: string }>();
|
||||
|
||||
@@ -28,6 +28,17 @@ describe("machine-scoped session proxy routes", () => {
|
||||
expect(daemon.requests).toEqual([{ method: "GET", path: "/sessions?cwd=/repo", body: undefined }]);
|
||||
});
|
||||
|
||||
it("forwards queue-clear mutations and their status through the session daemon", async () => {
|
||||
const status = { sessionId: "session-1", pendingMessageCount: 0, queuedMessages: [] };
|
||||
daemon.respondWith({ statusCode: 200, headers: { "content-type": "application/json" }, body: JSON.stringify(status) });
|
||||
|
||||
const response = await app.inject({ method: "POST", url: "/api/machines/local/sessions/session-1/queue/clear", payload: { cwd: "/repo" } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual(status);
|
||||
expect(daemon.requests).toEqual([{ method: "POST", path: "/sessions/session-1/queue/clear", body: { cwd: "/repo" } }]);
|
||||
});
|
||||
|
||||
it("strips the machine prefix before forwarding auth requests", async () => {
|
||||
const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } });
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ describe("delegation tool capability boundary", () => {
|
||||
"list_subsessions",
|
||||
"check_subsession",
|
||||
"read_subsession",
|
||||
"yield_to_subsessions",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -222,6 +222,84 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("clears runtime and compaction queues without interrupting active work", async () => {
|
||||
const steeringMessages = ["adjust this turn"];
|
||||
const followUpMessages = ["then do this"];
|
||||
const transcript = [{ role: "user", content: "keep this history" }];
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("clear-queue-session", {
|
||||
messages: transcript,
|
||||
isStreaming: true,
|
||||
isCompacting: true,
|
||||
pendingMessageCount: 2,
|
||||
getSteeringMessages: () => steeringMessages,
|
||||
getFollowUpMessages: () => followUpMessages,
|
||||
});
|
||||
const clearRuntimeQueue = vi.fn(() => {
|
||||
const cleared = { steering: [...steeringMessages], followUp: [...followUpMessages] };
|
||||
steeringMessages.length = 0;
|
||||
followUpMessages.length = 0;
|
||||
fake.session.pendingMessageCount = 0;
|
||||
return cleared;
|
||||
});
|
||||
fake.session.clearQueue = clearRuntimeQueue;
|
||||
const service = new PiSessionService(hub, {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("clear-queue-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
await service.prompt(sessionRef("clear-queue-session"), "queued during compaction", "followUp");
|
||||
await expect(service.status(sessionRef("clear-queue-session"))).resolves.toMatchObject({
|
||||
isStreaming: true,
|
||||
isCompacting: true,
|
||||
pendingMessageCount: 3,
|
||||
queuedMessages: [
|
||||
{ kind: "steer", text: "adjust this turn" },
|
||||
{ kind: "followUp", text: "then do this" },
|
||||
{ kind: "followUp", text: "queued during compaction" },
|
||||
],
|
||||
});
|
||||
|
||||
const status = await service.clearQueue(sessionRef("clear-queue-session"));
|
||||
|
||||
expect(clearRuntimeQueue).toHaveBeenCalledOnce();
|
||||
expect(status).toMatchObject({
|
||||
isStreaming: true,
|
||||
isCompacting: true,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
messageCount: 1,
|
||||
});
|
||||
expect(fake.session.messages).toBe(transcript);
|
||||
expect(fake.calls.prompt).toEqual([]);
|
||||
expect(fake.calls.abort).toBe(0);
|
||||
expect(fake.calls.dispose).toBe(0);
|
||||
const publishedStatuses = hub.sessionEvents.filter(({ event }) => event.type === "status.update");
|
||||
expect(publishedStatuses.at(-1)?.event).toEqual({ type: "status.update", status });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("clears an already-empty queue idempotently", async () => {
|
||||
const fake = fakeRuntime("clear-empty-queue-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
agentDir: TEST_AGENT_DIR,
|
||||
createAgentRuntime: runtimeCreator(fake.runtime),
|
||||
sessionManager: sessionGateway([sessionRecord("clear-empty-queue-session")]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const firstStatus = await service.clearQueue(sessionRef("clear-empty-queue-session"));
|
||||
const secondStatus = await service.clearQueue(sessionRef("clear-empty-queue-session"));
|
||||
|
||||
expect(fake.calls.clearQueue).toBe(2);
|
||||
expect(fake.calls.abort).toBe(0);
|
||||
expect(firstStatus).toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
|
||||
expect(secondStatus).toMatchObject({ pendingMessageCount: 0, queuedMessages: [] });
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("clears queued messages when aborting active work", async () => {
|
||||
const fake = fakeRuntime("abort-session");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
|
||||
@@ -10,10 +10,15 @@ const TEST_AGENT_DIR = "/tmp/pi-web-test-agent";
|
||||
|
||||
describe("PiSessionService", () => {
|
||||
describe("spawnSubsession", () => {
|
||||
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000) {
|
||||
function subsessionService(decision: SpawnTargetDecision, heartbeatIntervalMs = 60_000, childIds = ["child-1"]) {
|
||||
const parent = fakeRuntime("parent-1", { sessionFile: "/tmp/parent-1.jsonl" });
|
||||
const child = fakeRuntime("child-1", { sessionFile: "/tmp/child-1.jsonl", sessionManager: fakeSessionManager("/workspace-feature") });
|
||||
const created = [parent.runtime, child.runtime];
|
||||
const children = childIds.map((childId) => fakeRuntime(childId, {
|
||||
sessionFile: `/tmp/${childId}.jsonl`,
|
||||
sessionManager: fakeSessionManager("/workspace-feature"),
|
||||
}));
|
||||
const child = children[0];
|
||||
if (child === undefined) throw new Error("At least one child fixture is required");
|
||||
const created = [parent.runtime, ...children.map(({ runtime }) => runtime)];
|
||||
let index = 0;
|
||||
const createAgentRuntime: RuntimeCreator = async () => {
|
||||
await Promise.resolve();
|
||||
@@ -41,7 +46,7 @@ describe("PiSessionService", () => {
|
||||
spawnTargets: { resolveSpawnTarget: () => Promise.resolve(decision) },
|
||||
heartbeatIntervalMs,
|
||||
});
|
||||
return { parent, child, service };
|
||||
return { parent, child, children, service };
|
||||
}
|
||||
|
||||
it("records the parent, delivers the prompt, and lists the tracked child", async () => {
|
||||
@@ -796,6 +801,41 @@ describe("PiSessionService", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("reports other working children in each completion notice", async () => {
|
||||
const { parent, children, service } = subsessionService(
|
||||
{ allowed: true, cwd: "/workspace-feature" },
|
||||
60_000,
|
||||
["child-1", "child-2"],
|
||||
);
|
||||
const [first, second] = children;
|
||||
if (first === undefined || second === undefined) throw new Error("Expected two child fixtures");
|
||||
await service.start("/workspace");
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "first", cwd: "/workspace-feature" });
|
||||
await service.spawnSubsession({ spawningCwd: "/workspace", parentSessionId: "parent-1", parentSessionFile: "/tmp/parent-1.jsonl", prompt: "second", cwd: "/workspace-feature" });
|
||||
|
||||
first.session.isStreaming = true;
|
||||
first.emit({ type: "agent_start" });
|
||||
second.session.isStreaming = true;
|
||||
second.emit({ type: "agent_start" });
|
||||
|
||||
first.session.isStreaming = false;
|
||||
first.emit({ type: "agent_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(parent.calls.sendCustomMessage[0]?.message.content).toBe(
|
||||
"Subsession child-1 stopped working (idle).\nStill working: child-2. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.\n\n--- SUBSESSION OUTPUT: child-1 ---\n(no output)",
|
||||
);
|
||||
|
||||
second.session.isStreaming = false;
|
||||
second.emit({ type: "agent_end" });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
|
||||
expect(parent.calls.sendCustomMessage[1]?.message.content).toBe(
|
||||
"Subsession child-2 stopped working (idle).\nNo other tracked subsessions are working.\n\n--- SUBSESSION OUTPUT: child-2 ---\n(no output)",
|
||||
);
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("notifies via the heartbeat when the child settles without a further event", async () => {
|
||||
const { parent, child, service } = subsessionService({ allowed: true, cwd: "/workspace-feature" }, 10);
|
||||
await service.start("/workspace");
|
||||
|
||||
@@ -896,6 +896,16 @@ export class PiSessionService implements SessionRouteService {
|
||||
return "idle";
|
||||
}
|
||||
|
||||
private workingSubsessionIds(parentSessionId: string): string[] {
|
||||
const childIds = this.subsessionChildren.get(parentSessionId);
|
||||
if (childIds === undefined) return [];
|
||||
return [...childIds].filter((childId) => {
|
||||
const link = this.subsessionLinks.get(childId);
|
||||
const active = link === undefined ? undefined : this.activeChildForSubsessionLink(link);
|
||||
return active !== undefined && this.hasActiveWork(active.runtime.session);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive parent notifications from a tracked child's status. Arms a pending
|
||||
* notification while the child is working, and when it stops fires a single
|
||||
@@ -915,7 +925,11 @@ export class PiSessionService implements SessionRouteService {
|
||||
const status: SubsessionStatus = this.activities.get(childId)?.phase === "error" ? "error" : "idle";
|
||||
const finalText = finalAssistantText(historyMessages(session));
|
||||
const preview = finalText === "" ? "(no output)" : truncateForNotification(finalText);
|
||||
const text = `Subsession ${childId} stopped working (status: ${status}). Latest output:\n\n${preview}\n\nStatus and latest output are available through check_subsession with sessionId "${childId}"; its full transcript is available through read_subsession.`;
|
||||
const workingIds = this.workingSubsessionIds(link.parentSessionId);
|
||||
const next = workingIds.length === 0
|
||||
? "No other tracked subsessions are working."
|
||||
: `Still working: ${workingIds.join(", ")}. Continue working, or call yield_to_subsessions alone and last at the next join point. Further completion notices arrive automatically; do not poll.`;
|
||||
const text = `Subsession ${childId} stopped working (${status}).\n${next}\n\n--- SUBSESSION OUTPUT: ${childId} ---\n${preview}`;
|
||||
void this.notifyParentOfSubsession(link.parentSessionId, childId, text);
|
||||
}
|
||||
|
||||
@@ -1347,6 +1361,15 @@ export class PiSessionService implements SessionRouteService {
|
||||
this.unregisterSubsession(session.sessionId);
|
||||
}
|
||||
|
||||
async clearQueue(ref: PiSessionLookup): Promise<ClientSessionStatus> {
|
||||
await this.assertWritable(ref);
|
||||
const session = await this.getOrOpen(ref);
|
||||
this.clearCompactionPromptQueue(session.sessionId);
|
||||
clearSessionQueue(session);
|
||||
this.publishStatus(session);
|
||||
return this.statusFromSession(session);
|
||||
}
|
||||
|
||||
async abort(ref: PiSessionLookup): Promise<void> {
|
||||
const active = this.activeForLookup(ref);
|
||||
if (active === undefined) return;
|
||||
|
||||
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
|
||||
import Fastify, { type FastifyInstance } from "fastify";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
|
||||
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse, SessionStatus } from "../../shared/apiTypes.js";
|
||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { PiSessionService, type PiSessionManagerGateway } from "./piSessionService.js";
|
||||
import type { SessionRouteLookup, SessionRouteService } from "./sessionService.js";
|
||||
@@ -168,6 +168,55 @@ describe("session routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("clears a session queue with workspace context and returns fresh status", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService();
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const requestCwd = resolve("/repo");
|
||||
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/queue/clear", payload: { cwd: requestCwd } });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
sessionId: "session-1",
|
||||
isStreaming: true,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
});
|
||||
expect(routeService.clearQueueCalls).toEqual([{ id: "session-1", cwd: requestCwd }]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("maps archived queue-clear failures to a mutation error without requiring a body", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService();
|
||||
routeService.clearQueueError = new Error("Archived sessions are read-only. Restore the session to continue.");
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const response = await routeApp.inject({ method: "POST", url: "/sessions/session-1/queue/clear" });
|
||||
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json()).toEqual({ error: "Archived sessions are read-only. Restore the session to continue." });
|
||||
expect(routeService.clearQueueCalls).toEqual(["session-1"]);
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("normalizes cleanup requests for preview and execute routes", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
@@ -255,12 +304,14 @@ describe("session routes", () => {
|
||||
class CapturingRouteSessionService implements SessionRouteService {
|
||||
readonly calls: unknown[] = [];
|
||||
readonly reloadCalls: SessionRouteLookup[] = [];
|
||||
readonly clearQueueCalls: SessionRouteLookup[] = [];
|
||||
messagesResponse: unknown[] | MessagePage = [];
|
||||
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
|
||||
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
|
||||
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
||||
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
||||
reloadError: Error | undefined;
|
||||
clearQueueError: Error | undefined;
|
||||
|
||||
cleanupPreview(request: NormalizedSessionCleanupRequest): Promise<SessionCleanupPreviewResponse> {
|
||||
this.cleanupPreviewCalls.push(request);
|
||||
@@ -294,6 +345,22 @@ class CapturingRouteSessionService implements SessionRouteService {
|
||||
|
||||
list(): never { throw unusedRouteMethod("list"); }
|
||||
start(): never { throw unusedRouteMethod("start"); }
|
||||
|
||||
clearQueue(lookup: SessionRouteLookup): Promise<SessionStatus> {
|
||||
this.clearQueueCalls.push(lookup);
|
||||
if (this.clearQueueError !== undefined) return Promise.reject(this.clearQueueError);
|
||||
return Promise.resolve({
|
||||
sessionId: sessionIdFromLookup(lookup),
|
||||
isStreaming: true,
|
||||
isCompacting: false,
|
||||
isBashRunning: false,
|
||||
pendingMessageCount: 0,
|
||||
queuedMessages: [],
|
||||
tokens: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
cost: 0,
|
||||
});
|
||||
}
|
||||
|
||||
messages(): Promise<unknown[] | MessagePage> {
|
||||
return Promise.resolve(this.messagesResponse);
|
||||
}
|
||||
|
||||
@@ -173,6 +173,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: SessionRou
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: { cwd?: unknown } | undefined }>(`${prefix}/sessions/:sessionId/queue/clear`, async (request, reply) => {
|
||||
try {
|
||||
return await sessions.clearQueue(sessionLookupFromBody(request.params.sessionId, optionalRecord(request.body)));
|
||||
} catch (error) {
|
||||
return reply.code(mutationErrorStatus(error)).send({ error: errorMessage(error) });
|
||||
}
|
||||
});
|
||||
|
||||
app.post<{ Params: { sessionId: string }; Body: AttachmentsRequestBody | undefined }>(`${prefix}/sessions/:sessionId/attachments`, async (request, reply) => {
|
||||
try {
|
||||
const body = optionalRecord(request.body);
|
||||
|
||||
@@ -25,15 +25,16 @@ export type SessionRouteLookup = string | SessionRouteRef;
|
||||
/**
|
||||
* Route-facing session contract for PI WEB's HTTP/WebSocket API.
|
||||
*
|
||||
* Keep this surface neutral: implementations may be backed by the native Pi SDK,
|
||||
* an out-of-process agent bridge, or another daemon. Pi-specific lifecycle hooks
|
||||
* such as auth-change handling and daemon shutdown stay on the concrete service.
|
||||
* Keep transport concerns separate from the bundled Pi SDK implementation so
|
||||
* routes remain testable. Pi-specific lifecycle hooks such as auth-change
|
||||
* handling and daemon shutdown stay on the concrete service.
|
||||
*/
|
||||
export interface SessionRouteService {
|
||||
list(cwd: string): Promise<ClientSession[]>;
|
||||
start(cwd: string): Promise<ClientSession>;
|
||||
messages(ref: SessionRouteLookup, page?: { before?: number; limit?: number }): Promise<unknown[] | ClientMessagePage>;
|
||||
status(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||
clearQueue(ref: SessionRouteLookup): Promise<ClientSessionStatus>;
|
||||
availableModels(ref: SessionRouteLookup): Promise<ClientSessionModel[]>;
|
||||
setModel(ref: SessionRouteLookup, provider: string, modelId: string): Promise<ClientSessionStatus>;
|
||||
cycleModel(ref: SessionRouteLookup, direction: "forward" | "backward"): Promise<ClientSessionStatus>;
|
||||
|
||||
@@ -0,0 +1,163 @@
|
||||
import type { Api, AssistantMessage, Message, Model } from "@earendil-works/pi-ai";
|
||||
import { createAssistantMessageEventStream } from "@earendil-works/pi-ai";
|
||||
import { runAgentLoop, type AgentEvent, type AgentMessage, type AgentTool, type StreamFn } from "@earendil-works/pi-agent-core";
|
||||
import type { ExtensionContext, ToolDefinition } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createSubsessionToolDefinitions, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||
|
||||
const model: Model<Api> = {
|
||||
id: "fake-model",
|
||||
name: "Fake Model",
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
baseUrl: "https://example.test",
|
||||
reasoning: false,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 1_000,
|
||||
maxTokens: 100,
|
||||
};
|
||||
|
||||
function extensionContext(): ExtensionContext {
|
||||
const sessionManager = {
|
||||
getSessionId: () => "parent-1",
|
||||
getSessionFile: () => "/sessions/parent-1.jsonl",
|
||||
};
|
||||
// The wrapped yield definition only reads the two session-manager methods above.
|
||||
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions -- minimal integration boundary for a Pi tool definition.
|
||||
return { sessionManager } as unknown as ExtensionContext;
|
||||
}
|
||||
|
||||
function wrapDefinition(definition: ToolDefinition, ctx: ExtensionContext): AgentTool {
|
||||
return {
|
||||
name: definition.name,
|
||||
label: definition.label,
|
||||
description: definition.description,
|
||||
parameters: definition.parameters,
|
||||
...(definition.executionMode === undefined ? {} : { executionMode: definition.executionMode }),
|
||||
execute: (toolCallId, params, signal, onUpdate) => definition.execute(toolCallId, params, signal, onUpdate, ctx),
|
||||
};
|
||||
}
|
||||
|
||||
function isLlmMessage(agentMessage: AgentMessage): agentMessage is Message {
|
||||
return agentMessage.role === "user" || agentMessage.role === "assistant" || agentMessage.role === "toolResult";
|
||||
}
|
||||
|
||||
function message(stopReason: "stop" | "toolUse", content: AssistantMessage["content"]): AssistantMessage {
|
||||
return {
|
||||
role: "assistant",
|
||||
content,
|
||||
api: "anthropic-messages",
|
||||
provider: "anthropic",
|
||||
model: model.id,
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason,
|
||||
timestamp: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function streamSequence(messages: AssistantMessage[]): StreamFn {
|
||||
let index = 0;
|
||||
return vi.fn(() => {
|
||||
const next = messages[index];
|
||||
index += 1;
|
||||
if (next === undefined) throw new Error("unexpected provider invocation");
|
||||
if (next.stopReason !== "stop" && next.stopReason !== "toolUse" && next.stopReason !== "length") {
|
||||
throw new Error(`unsupported fake stop reason ${next.stopReason}`);
|
||||
}
|
||||
const stream = createAssistantMessageEventStream();
|
||||
stream.push({ type: "done", reason: next.stopReason, message: next });
|
||||
stream.end(next);
|
||||
return stream;
|
||||
});
|
||||
}
|
||||
|
||||
async function runYieldBatch(subsessions: SubsessionSummary[], includeSentinel = false) {
|
||||
const list = vi.fn(() => Promise.resolve(subsessions));
|
||||
const deps: SubsessionToolDeps = {
|
||||
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace" })),
|
||||
list,
|
||||
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, finalText: "", messageCount: 0 })),
|
||||
read: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/workspace", status: "idle" as const, entries: [], total: 0, matched: 0, start: 0, hasMore: false })),
|
||||
};
|
||||
const yieldDefinition = createSubsessionToolDefinitions("/workspace", deps)
|
||||
.find(({ name }) => name === "yield_to_subsessions");
|
||||
if (yieldDefinition === undefined) throw new Error("missing yield_to_subsessions");
|
||||
|
||||
const sentinel = vi.fn(() => Promise.resolve({ content: [{ type: "text" as const, text: "sentinel complete" }], details: {} }));
|
||||
const sentinelTool: AgentTool = {
|
||||
name: "sentinel",
|
||||
label: "Sentinel",
|
||||
description: "Return normally.",
|
||||
parameters: Type.Object({}),
|
||||
execute: sentinel,
|
||||
};
|
||||
const firstContent: AssistantMessage["content"] = [
|
||||
{ type: "toolCall", id: "yield-call", name: "yield_to_subsessions", arguments: {} },
|
||||
...(includeSentinel
|
||||
? [{ type: "toolCall" as const, id: "sentinel-call", name: "sentinel", arguments: {} }]
|
||||
: []),
|
||||
];
|
||||
const streamFn = streamSequence([
|
||||
message("toolUse", firstContent),
|
||||
message("stop", [{ type: "text", text: "normal follow-up" }]),
|
||||
]);
|
||||
const events: AgentEvent[] = [];
|
||||
|
||||
const messages = await runAgentLoop(
|
||||
[{ role: "user", content: "join now", timestamp: 0 }],
|
||||
{
|
||||
systemPrompt: "",
|
||||
messages: [],
|
||||
tools: [wrapDefinition(yieldDefinition, extensionContext()), sentinelTool],
|
||||
},
|
||||
{ model, convertToLlm: (agentMessages) => agentMessages.filter(isLlmMessage) },
|
||||
(event) => { events.push(event); },
|
||||
undefined,
|
||||
streamFn,
|
||||
);
|
||||
|
||||
return { events, list, messages, sentinel, streamFn };
|
||||
}
|
||||
|
||||
describe("yield_to_subsessions Pi agent-loop integration", () => {
|
||||
it("ends the run after one provider call when invoked alone with a working child", async () => {
|
||||
const result = await runYieldBatch([
|
||||
{ sessionId: "child-1", cwd: "/workspace", status: "working" },
|
||||
]);
|
||||
|
||||
expect(result.streamFn).toHaveBeenCalledTimes(1);
|
||||
expect(result.list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl");
|
||||
expect(result.events.slice(-2).map(({ type }) => type)).toEqual(["turn_end", "agent_end"]);
|
||||
expect(result.messages.at(-1)).toMatchObject({ role: "toolResult", toolName: "yield_to_subsessions" });
|
||||
});
|
||||
|
||||
it("makes a normal follow-up provider call when no child is working", async () => {
|
||||
const result = await runYieldBatch([]);
|
||||
|
||||
expect(result.streamFn).toHaveBeenCalledTimes(2);
|
||||
expect(result.events.filter(({ type }) => type === "turn_start")).toHaveLength(2);
|
||||
expect(result.messages.at(-1)).toMatchObject({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "normal follow-up" }],
|
||||
});
|
||||
});
|
||||
|
||||
it("does not terminate a mixed batch with a non-terminating sibling tool", async () => {
|
||||
const result = await runYieldBatch([
|
||||
{ sessionId: "child-1", cwd: "/workspace", status: "working" },
|
||||
], true);
|
||||
|
||||
expect(result.sentinel).toHaveBeenCalledTimes(1);
|
||||
expect(result.streamFn).toHaveBeenCalledTimes(2);
|
||||
expect(result.messages.at(-1)).toMatchObject({ role: "assistant" });
|
||||
});
|
||||
});
|
||||
@@ -25,7 +25,17 @@ function tools(deps: Partial<SubsessionToolDeps>) {
|
||||
if (tool === undefined) throw new Error(`missing tool ${name}`);
|
||||
return tool;
|
||||
};
|
||||
return { spawn: find("spawn_subsession"), list: find("list_subsessions"), check: find("check_subsession"), read: find("read_subsession") };
|
||||
return {
|
||||
spawn: find("spawn_subsession"),
|
||||
list: find("list_subsessions"),
|
||||
check: find("check_subsession"),
|
||||
read: find("read_subsession"),
|
||||
yield: find("yield_to_subsessions"),
|
||||
};
|
||||
}
|
||||
|
||||
function workingGuidance(sessionId: string): string {
|
||||
return `Subsession ${sessionId} is working; partial output is withheld. Continue independent work, or call yield_to_subsessions alone and last at the join point. Completion notices wake you; do not poll.`;
|
||||
}
|
||||
|
||||
function firstText(content: readonly (TextContent | ImageContent)[]): string {
|
||||
@@ -52,27 +62,40 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
expect(firstText(result.content)).toContain("Started tracked subsession child-1");
|
||||
});
|
||||
|
||||
it("guides the parent to join all required subsessions without polling", async () => {
|
||||
it("guides the parent to continue independent work and use the explicit join action", async () => {
|
||||
const { spawn: spawnTool } = tools({
|
||||
spawn: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a-feature" })),
|
||||
});
|
||||
|
||||
expect(spawnTool.description).toBe("Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion.");
|
||||
expect(spawnTool.promptSnippet).toBe("spawn_subsession: delegate parallel work; yield at a join point until all required children complete.");
|
||||
expect(spawnTool.description).toBe("Start a tracked child and return immediately. Continue independent work, then use yield_to_subsessions at the join point. Completion notices wake you; do not poll.");
|
||||
expect(spawnTool.promptSnippet).toBe("spawn_subsession: tracked parallel work; continue, then join with yield_to_subsessions");
|
||||
|
||||
const result = await spawnTool.execute("call-contract", { prompt: "do it" }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
expect(firstText(result.content)).toBe("Started tracked subsession child-1 in /repos/a-feature. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion.");
|
||||
expect(firstText(result.content)).toBe("Started tracked subsession child-1 in /repos/a-feature. Continue independent work, then join with yield_to_subsessions; do not poll.");
|
||||
});
|
||||
|
||||
it("keeps subsession inspection tool descriptions capability-oriented", () => {
|
||||
it("distinguishes status inspection from yielding in tool metadata", () => {
|
||||
const definitions = tools({});
|
||||
|
||||
expect(definitions.list.description).toBe("List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown).");
|
||||
expect(definitions.check.description).toBe("Return a tracked subsession's current status, message count, and most recent assistant output.");
|
||||
expect(definitions.read.description).toBe("Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries.");
|
||||
for (const definition of [definitions.list, definitions.check, definitions.read]) {
|
||||
expect(definition.description).not.toMatch(/use this|do not poll|continue working|start narrow|for just the final|relay/i);
|
||||
}
|
||||
expect(definitions.list.description).toBe("List tracked child statuses. Never yields or changes control flow; do not poll.");
|
||||
expect(definitions.list.promptSnippet).toBe("list_subsessions: inspect child statuses; never yields");
|
||||
expect(definitions.check.description).toBe("Get a tracked child's status and latest output. Working output is withheld. Never yields; do not poll.");
|
||||
expect(definitions.check.promptSnippet).toBe("check_subsession: inspect child status and available output; never yields");
|
||||
expect(definitions.read.description).toBe("Read a tracked child's filtered transcript. Working transcripts are withheld. Never yields; do not poll.");
|
||||
expect(definitions.read.promptSnippet).toBe("read_subsession: inspect an available child transcript; never yields");
|
||||
});
|
||||
|
||||
it("registers the parameterless yield action with terminal-batch guidance", () => {
|
||||
const { yield: yieldTool } = tools({});
|
||||
|
||||
expect(yieldTool.parameters).toMatchObject({ type: "object", properties: {} });
|
||||
expect(yieldTool.description).toBe("At a join point, end this run while tracked children work; completion notices wake you. If none work, continue. Call alone and last; do not poll.");
|
||||
expect(yieldTool.promptSnippet).toBe("yield_to_subsessions: end the run at a join point; call alone and last");
|
||||
expect(yieldTool.promptGuidelines).toEqual([
|
||||
"After independent work, yield only at a join point; use spawn_session for fire-and-forget work.",
|
||||
"Call alone and last; a mixed tool batch may continue the run.",
|
||||
"Completion notices wake you; do not poll inspection tools.",
|
||||
]);
|
||||
});
|
||||
|
||||
it("spawn_subsession omits the inherited model when the dispatching session has no current model", async () => {
|
||||
@@ -105,12 +128,51 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" },
|
||||
] });
|
||||
expect(firstText(result.content)).toContain("child-1 [working]");
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("list_subsessions reports an empty state", async () => {
|
||||
const { list: listTool } = tools({ list: vi.fn(() => Promise.resolve([])) });
|
||||
const result = await listTool.execute("call-3", {}, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
expect(result.content[0]).toMatchObject({ type: "text", text: "No tracked subsessions." });
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("yield_to_subsessions terminates when tracked children are working", async () => {
|
||||
const subsessions = [
|
||||
{ sessionId: "child-1", cwd: "/repos/a", status: "working" as const },
|
||||
{ sessionId: "child-2", cwd: "/repos/a", status: "idle" as const },
|
||||
{ sessionId: "child-3", cwd: "/repos/a", status: "working" as const },
|
||||
];
|
||||
const list = vi.fn(() => Promise.resolve(subsessions));
|
||||
const { yield: yieldTool } = tools({ list });
|
||||
|
||||
const result = await yieldTool.execute("call-yield", {}, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
|
||||
expect(list).toHaveBeenCalledWith("parent-1", "/sessions/parent-1.jsonl");
|
||||
expect(result.details).toEqual({ subsessions });
|
||||
expect(firstText(result.content)).toBe("Working: child-1, child-3. Ending this run; completion notices will wake you.");
|
||||
expect(result.terminate).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ label: "an empty list", subsessions: [] },
|
||||
{
|
||||
label: "only non-working children",
|
||||
subsessions: [
|
||||
{ sessionId: "child-idle", cwd: "/repos/a", status: "idle" as const },
|
||||
{ sessionId: "child-error", cwd: "/repos/a", status: "error" as const },
|
||||
{ sessionId: "child-unknown", cwd: "/repos/a", status: "unknown" as const },
|
||||
],
|
||||
},
|
||||
])("yield_to_subsessions remains active with $label", async ({ subsessions }) => {
|
||||
const { yield: yieldTool } = tools({ list: vi.fn(() => Promise.resolve(subsessions)) });
|
||||
|
||||
const result = await yieldTool.execute("call-no-yield", {}, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
|
||||
expect(result.details).toEqual({ subsessions });
|
||||
expect(firstText(result.content)).toBe("No tracked subsessions are working; continuing.");
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("check_subsession scopes by parent and returns the final result", async () => {
|
||||
@@ -121,7 +183,33 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
|
||||
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
|
||||
expect(result.details).toMatchObject({ sessionId: "child-1", status: "idle", finalText: "all done" });
|
||||
expect(firstText(result.content)).toContain("all done");
|
||||
expect(firstText(result.content)).toBe("Subsession child-1 [idle].\n\n--- SUBSESSION OUTPUT: child-1 ---\nall done");
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("check_subsession withholds partial working output without yielding", async () => {
|
||||
const partial = { sessionId: "child-1", cwd: "/repos/a", status: "working" as const, finalText: "SECRET PARTIAL OUTPUT", messageCount: 4 };
|
||||
const check = vi.fn(() => Promise.resolve(partial));
|
||||
const { check: checkTool } = tools({ check });
|
||||
|
||||
const result = await checkTool.execute("call-working-check", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
|
||||
expect(check).toHaveBeenCalledWith("parent-1", "child-1", "/sessions/parent-1.jsonl");
|
||||
expect(firstText(result.content)).toBe(workingGuidance("child-1"));
|
||||
expect(firstText(result.content)).not.toContain("SECRET PARTIAL OUTPUT");
|
||||
expect(result.details).toEqual(partial);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("check_subsession preserves non-working error output without yielding", async () => {
|
||||
const { check: checkTool } = tools({
|
||||
check: vi.fn(() => Promise.resolve({ sessionId: "child-1", cwd: "/repos/a", status: "error" as const, finalText: "child failed", messageCount: 3 })),
|
||||
});
|
||||
|
||||
const result = await checkTool.execute("call-error-check", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", undefined));
|
||||
|
||||
expect(firstText(result.content)).toBe("Subsession child-1 [error].\n\n--- SUBSESSION OUTPUT: child-1 ---\nchild failed");
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("check_subsession propagates scope errors so the agent loop reports them", async () => {
|
||||
@@ -136,15 +224,34 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
const read = vi.fn(() => Promise.resolve({
|
||||
sessionId: "child-1", cwd: "/repos/a", status: "idle" as const,
|
||||
entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "the answer" }] }],
|
||||
total: 5, matched: 1, start: 2, hasMore: false,
|
||||
total: 5, matched: 2, start: 2, hasMore: true,
|
||||
}));
|
||||
const { read: readTool } = tools({ read });
|
||||
|
||||
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
const result = await readTool.execute("call-6", { sessionId: "child-1", roles: ["assistant"], maxChars: 200, limit: 1 }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
|
||||
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200 }, "/sessions/parent-1.jsonl");
|
||||
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 1 });
|
||||
expect(firstText(result.content)).toContain("the answer");
|
||||
expect(read).toHaveBeenCalledWith("parent-1", "child-1", { roles: ["assistant"], maxChars: 200, limit: 1 }, "/sessions/parent-1.jsonl");
|
||||
expect(result.details).toMatchObject({ sessionId: "child-1", matched: 2 });
|
||||
expect(firstText(result.content)).toBe("Subsession child-1 [idle] — messages 2–2 of 5 (2 matched). Earlier matching messages exist before index 2.\n\n--- SUBSESSION TRANSCRIPT: child-1 ---\n#2 assistant\nthe answer");
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("read_subsession withholds partial working transcripts without yielding", async () => {
|
||||
const partial = {
|
||||
sessionId: "child-1", cwd: "/repos/a", status: "working" as const,
|
||||
entries: [{ index: 2, role: "assistant" as const, parts: [{ kind: "text" as const, text: "SECRET TRANSCRIPT ENTRY" }] }],
|
||||
total: 3, matched: 1, start: 2, hasMore: false,
|
||||
};
|
||||
const read = vi.fn(() => Promise.resolve(partial));
|
||||
const { read: readTool } = tools({ read });
|
||||
|
||||
const result = await readTool.execute("call-working-read", { sessionId: "child-1" }, undefined, undefined, ctxFor("parent-1", "/sessions/parent-1.jsonl"));
|
||||
|
||||
expect(read).toHaveBeenCalledWith("parent-1", "child-1", {}, "/sessions/parent-1.jsonl");
|
||||
expect(firstText(result.content)).toBe(workingGuidance("child-1"));
|
||||
expect(firstText(result.content)).not.toContain("SECRET TRANSCRIPT ENTRY");
|
||||
expect(result.details).toEqual(partial);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("read_subsession renders raw tool-call args and the truncation marker in the model-facing text", async () => {
|
||||
@@ -165,6 +272,7 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
expect(text).toContain("command"); // raw args surfaced in text, not only details
|
||||
expect(text).toContain("ls -la");
|
||||
expect(text).toContain("[+43 chars truncated"); // 50 - 7
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("read_subsession distinguishes an empty page-window from a zero-match result", async () => {
|
||||
@@ -178,6 +286,7 @@ describe("createSubsessionToolDefinitions", () => {
|
||||
const text = firstText(result.content);
|
||||
expect(text).toContain("4 matched"); // not "nothing matched"
|
||||
expect(text).not.toContain("nothing matched");
|
||||
expect(result.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("read_subsession propagates scope errors so the agent loop reports them", async () => {
|
||||
|
||||
@@ -67,50 +67,51 @@ export interface SubsessionToolDeps {
|
||||
|
||||
const SpawnSubsessionParams = Type.Object({
|
||||
prompt: Type.String({
|
||||
description: "The first instruction to send to the new tracked subsession.",
|
||||
description: "Initial instruction for the tracked child.",
|
||||
}),
|
||||
cwd: Type.Optional(Type.String({
|
||||
description: "Working directory for the subsession. Must be a workspace (worktree, or root) of the same project as this session. Defaults to this session's working directory.",
|
||||
description: "Child workspace in the same project (worktree or root); defaults to the parent's directory.",
|
||||
})),
|
||||
});
|
||||
|
||||
const ListSubsessionsParams = Type.Object({});
|
||||
const YieldToSubsessionsParams = Type.Object({});
|
||||
|
||||
const CheckSubsessionParams = Type.Object({
|
||||
sessionId: Type.String({
|
||||
description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.",
|
||||
description: "Tracked child id from spawn_subsession or list_subsessions.",
|
||||
}),
|
||||
});
|
||||
|
||||
const ReadSubsessionParams = Type.Object({
|
||||
sessionId: Type.String({
|
||||
description: "Id of a tracked subsession owned by the calling session, as returned by spawn_subsession or list_subsessions.",
|
||||
description: "Tracked child id from spawn_subsession or list_subsessions.",
|
||||
}),
|
||||
roles: Type.Optional(Type.Array(
|
||||
Type.Union([Type.Literal("assistant"), Type.Literal("user"), Type.Literal("tool"), Type.Literal("system"), Type.Literal("custom")]),
|
||||
{ description: "Message roles to include. Omit for all roles." },
|
||||
{ description: "Roles to include; omit for all." },
|
||||
)),
|
||||
include: Type.Optional(Type.Array(
|
||||
Type.Union([Type.Literal("text"), Type.Literal("thinking"), Type.Literal("tool_call"), Type.Literal("tool_result"), Type.Literal("image")]),
|
||||
{ description: "Content kinds to keep within messages. Omit for all kinds." },
|
||||
{ description: "Content kinds to include; omit for all." },
|
||||
)),
|
||||
search: Type.Optional(Type.String({
|
||||
description: "Case-insensitive substring; keep only messages whose text or tool name matches. Always searches full message content, even when maxChars is set.",
|
||||
description: "Case-insensitive text or tool-name substring; searches full content before maxChars truncation.",
|
||||
})),
|
||||
maxChars: Type.Optional(Type.Integer({
|
||||
minimum: 0,
|
||||
description: "Truncate each text/thinking/tool-result value to this many characters; clipped parts are marked '[+N chars truncated]'. Omit for full, untruncated text (there is no default, so truncation only happens when you ask for it).",
|
||||
description: "Maximum characters per text, thinking, or tool-result value; omit for no truncation.",
|
||||
})),
|
||||
includeToolArgs: Type.Optional(Type.Boolean({
|
||||
description: "Include raw tool-call arguments (can be large). A compact one-line summary of each call is always shown regardless.",
|
||||
description: "Include raw tool-call arguments; summaries are always included.",
|
||||
})),
|
||||
before: Type.Optional(Type.Integer({
|
||||
minimum: 0,
|
||||
description: "Return only messages before this transcript index; page backward by passing the previous response's 'start'.",
|
||||
description: "Return messages before this index; use the previous start to page backward.",
|
||||
})),
|
||||
limit: Type.Optional(Type.Integer({
|
||||
minimum: 1,
|
||||
description: "Maximum number of most-recent matching messages to return within the window (returned in chronological order). Defaults to 50.",
|
||||
description: "Maximum recent matches, in chronological order. Defaults to 50.",
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -118,6 +119,10 @@ function statusLine(summary: SubsessionSummary): string {
|
||||
return `- ${summary.sessionId} [${summary.status}] in ${summary.cwd}`;
|
||||
}
|
||||
|
||||
function workingInspectionGuidance(sessionId: string): string {
|
||||
return `Subsession ${sessionId} is working; partial output is withheld. Continue independent work, or call yield_to_subsessions alone and last at the join point. Completion notices wake you; do not poll.`;
|
||||
}
|
||||
|
||||
function renderEntry(entry: TranscriptEntry): string {
|
||||
const header = `#${String(entry.index)} ${entry.role}`;
|
||||
const body = entry.parts.map(renderPart).filter((line) => line !== "").join("\n");
|
||||
@@ -153,7 +158,7 @@ function renderTranscript(result: SubsessionReadResult): string {
|
||||
? "no messages matched your filters"
|
||||
: `no messages in this window (${String(result.matched)} matched outside it)`)
|
||||
: `messages ${String(result.start)}–${String(last.index)} of ${String(result.total)} (${String(result.matched)} matched)`;
|
||||
const more = result.hasMore ? `\n\nEarlier matching messages exist before index ${String(result.start)}.` : "";
|
||||
const more = result.hasMore ? ` Earlier matching messages exist before index ${String(result.start)}.` : "";
|
||||
// Empty entries with matches means the `before` cursor excluded every match
|
||||
// (they all sit at index >= before): the agent paged too far back and should
|
||||
// raise `before` or omit it, not page back further.
|
||||
@@ -162,11 +167,12 @@ function renderTranscript(result: SubsessionReadResult): string {
|
||||
: (result.matched === 0
|
||||
? "(no messages matched the filters)"
|
||||
: `(no messages before index ${String(result.start)}; all ${String(result.matched)} matches have later indexes)`);
|
||||
return `Subsession ${result.sessionId} [${result.status}] — ${range}:\n\n${body}${more}`;
|
||||
return `Subsession ${result.sessionId} [${result.status}] — ${range}.${more}\n\n--- SUBSESSION TRANSCRIPT: ${result.sessionId} ---\n${body}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tools that let an agent spawn *tracked* child sessions and inspect them.
|
||||
* Tools that let an agent spawn *tracked* child sessions, inspect them, and
|
||||
* explicitly yield at a join point.
|
||||
*
|
||||
* Unlike `spawn_session` (fire-and-forget peers), a subsession records its
|
||||
* parent in its session header, the parent is notified when it stops working,
|
||||
@@ -178,8 +184,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
const spawnTool = defineTool<typeof SpawnSubsessionParams, SpawnSubsessionResult>({
|
||||
name: "spawn_subsession",
|
||||
label: "Spawn subsession",
|
||||
description: "Start a tracked child and return after dispatch. Track required children as pending: continue independent work, then yield at a join point until all have notified completion. Notifications queue while the parent is busy; do not poll for completion.",
|
||||
promptSnippet: "spawn_subsession: delegate parallel work; yield at a join point until all required children complete.",
|
||||
description: "Start a tracked child and return immediately. Continue independent work, then use yield_to_subsessions at the join point. Completion notices wake you; do not poll.",
|
||||
promptSnippet: "spawn_subsession: tracked parallel work; continue, then join with yield_to_subsessions",
|
||||
parameters: SpawnSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
@@ -193,7 +199,7 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
...(ctx.model === undefined ? {} : { model: ctx.model }),
|
||||
});
|
||||
return {
|
||||
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Track it as pending and, before finalizing dependent work, yield until all required children have notified completion.` }],
|
||||
content: [{ type: "text", text: `Started tracked subsession ${result.sessionId} in ${result.cwd}. Continue independent work, then join with yield_to_subsessions; do not poll.` }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
@@ -202,8 +208,8 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
const listTool = defineTool<typeof ListSubsessionsParams, { subsessions: SubsessionSummary[] }>({
|
||||
name: "list_subsessions",
|
||||
label: "List subsessions",
|
||||
description: "List tracked child sessions owned by the calling session, with each child's current status (working, idle, error, or unknown).",
|
||||
promptSnippet: "list_subsessions: see the tracked child sessions you spawned",
|
||||
description: "List tracked child statuses. Never yields or changes control flow; do not poll.",
|
||||
promptSnippet: "list_subsessions: inspect child statuses; never yields",
|
||||
parameters: ListSubsessionsParams,
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
@@ -219,16 +225,19 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
const checkTool = defineTool<typeof CheckSubsessionParams, SubsessionCheckResult>({
|
||||
name: "check_subsession",
|
||||
label: "Check subsession",
|
||||
description: "Return a tracked subsession's current status, message count, and most recent assistant output.",
|
||||
promptSnippet: "check_subsession: glance at a subsession's status and latest output",
|
||||
description: "Get a tracked child's status and latest output. Working output is withheld. Never yields; do not poll.",
|
||||
promptSnippet: "check_subsession: inspect child status and available output; never yields",
|
||||
parameters: CheckSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
||||
const result = await deps.check(parentSessionId, params.sessionId, parentSessionFile);
|
||||
const body = result.finalText === "" ? "(no output yet)" : result.finalText;
|
||||
const text = result.status === "working"
|
||||
? workingInspectionGuidance(result.sessionId)
|
||||
: `Subsession ${result.sessionId} [${result.status}].\n\n--- SUBSESSION OUTPUT: ${result.sessionId} ---\n${body}`;
|
||||
return {
|
||||
content: [{ type: "text", text: `Subsession ${result.sessionId} [${result.status}]:\n\n${body}` }],
|
||||
content: [{ type: "text", text }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
@@ -237,20 +246,53 @@ export function createSubsessionToolDefinitions(spawningCwd: string, deps: Subse
|
||||
const readTool = defineTool<typeof ReadSubsessionParams, SubsessionReadResult>({
|
||||
name: "read_subsession",
|
||||
label: "Read subsession",
|
||||
description: "Return a filtered, paginated transcript of a tracked subsession. Filters select message roles and content kinds, search full message content, optionally include raw tool arguments, and cap or page the returned entries.",
|
||||
promptSnippet: "read_subsession: read through a subsession's transcript with filters",
|
||||
description: "Read a tracked child's filtered transcript. Working transcripts are withheld. Never yields; do not poll.",
|
||||
promptSnippet: "read_subsession: inspect an available child transcript; never yields",
|
||||
parameters: ReadSubsessionParams,
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
||||
const { sessionId, ...query } = params;
|
||||
const result = await deps.read(parentSessionId, sessionId, query, parentSessionFile);
|
||||
const text = result.status === "working"
|
||||
? workingInspectionGuidance(result.sessionId)
|
||||
: renderTranscript(result);
|
||||
return {
|
||||
content: [{ type: "text", text: renderTranscript(result) }],
|
||||
content: [{ type: "text", text }],
|
||||
details: result,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [spawnTool, listTool, checkTool, readTool];
|
||||
const yieldTool = defineTool<typeof YieldToSubsessionsParams, { subsessions: SubsessionSummary[] }>({
|
||||
name: "yield_to_subsessions",
|
||||
label: "Yield to subsessions",
|
||||
description: "At a join point, end this run while tracked children work; completion notices wake you. If none work, continue. Call alone and last; do not poll.",
|
||||
promptSnippet: "yield_to_subsessions: end the run at a join point; call alone and last",
|
||||
promptGuidelines: [
|
||||
"After independent work, yield only at a join point; use spawn_session for fire-and-forget work.",
|
||||
"Call alone and last; a mixed tool batch may continue the run.",
|
||||
"Completion notices wake you; do not poll inspection tools.",
|
||||
],
|
||||
parameters: YieldToSubsessionsParams,
|
||||
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
||||
const parentSessionId = ctx.sessionManager.getSessionId();
|
||||
const parentSessionFile = ctx.sessionManager.getSessionFile() ?? undefined;
|
||||
const subsessions = await deps.list(parentSessionId, parentSessionFile);
|
||||
const working = subsessions.filter(({ status }) => status === "working");
|
||||
if (working.length === 0) {
|
||||
return {
|
||||
content: [{ type: "text", text: "No tracked subsessions are working; continuing." }],
|
||||
details: { subsessions },
|
||||
};
|
||||
}
|
||||
return {
|
||||
content: [{ type: "text", text: `Working: ${working.map(({ sessionId }) => sessionId).join(", ")}. Ending this run; completion notices will wake you.` }],
|
||||
details: { subsessions },
|
||||
terminate: true,
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
return [spawnTool, listTool, checkTool, readTool, yieldTool];
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const PI_WEB_CAPABILITIES = {
|
||||
sessionsBulkMutations: "sessions.bulkMutations",
|
||||
sessionsCleanup: "sessions.cleanup",
|
||||
sessionsReload: "sessions.reload",
|
||||
sessionsClearQueue: "sessions.clearQueue",
|
||||
sessionsPersistedState: "sessions.persistedState",
|
||||
promptAttachments: "prompt.attachments",
|
||||
workspaceFileSuggestions: "workspace.fileSuggestions",
|
||||
@@ -67,9 +68,9 @@ export interface PiWebUploadsConfig {
|
||||
}
|
||||
|
||||
export interface PiWebAgentConfig {
|
||||
/** Agent CLI command used for diagnostics and package-managed updates. */
|
||||
/** Pi-compatible companion CLI used for diagnostics and safe package-managed updates. */
|
||||
command?: string;
|
||||
/** Agent config/state directory containing auth.json, models.json, settings.json, and sessions/. */
|
||||
/** Pi-compatible profile directory containing auth.json, models.json, settings.json, and sessions/. */
|
||||
dir?: string;
|
||||
}
|
||||
|
||||
@@ -94,7 +95,7 @@ export interface PiWebConfigValues {
|
||||
* while the capability stabilizes. Requires spawnSessions to be enabled.
|
||||
*/
|
||||
subsessions?: boolean;
|
||||
/** Agent runtime command/state used by PI WEB and the session daemon (Pi by default). */
|
||||
/** Desired Pi-compatible agent profile and companion CLI (Pi by default). */
|
||||
agent?: PiWebAgentConfig;
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,26 @@ describe("PI WEB capabilities", () => {
|
||||
})).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
|
||||
});
|
||||
|
||||
it("requires web and session daemon support for server-side queue clearing", () => {
|
||||
const clearQueue = PI_WEB_CAPABILITIES.sessionsClearQueue;
|
||||
expect(WEB_RUNTIME_CAPABILITIES).toContain(clearQueue);
|
||||
expect(SESSIOND_RUNTIME_CAPABILITIES).toContain(clearQueue);
|
||||
expect(parseKnownPiWebCapabilities([clearQueue, "future.capability"])).toEqual([clearQueue]);
|
||||
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [clearQueue] },
|
||||
sessiond: { available: true, capabilities: [] },
|
||||
})).not.toContain(clearQueue);
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [] },
|
||||
sessiond: { available: true, capabilities: [clearQueue] },
|
||||
})).not.toContain(clearQueue);
|
||||
expect(effectivePiWebCapabilities({
|
||||
web: { available: true, capabilities: [clearQueue] },
|
||||
sessiond: { available: true, capabilities: [clearQueue] },
|
||||
})).toContain(clearQueue);
|
||||
});
|
||||
|
||||
it("keeps only known string capabilities when parsing runtime data", () => {
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
|
||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
|
||||
|
||||
@@ -11,6 +11,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
|
||||
PI_WEB_CAPABILITIES.sessionsBulkMutations,
|
||||
PI_WEB_CAPABILITIES.sessionsCleanup,
|
||||
PI_WEB_CAPABILITIES.sessionsReload,
|
||||
PI_WEB_CAPABILITIES.sessionsClearQueue,
|
||||
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
||||
PI_WEB_CAPABILITIES.promptAttachments,
|
||||
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
|
||||
@@ -24,6 +25,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
|
||||
PI_WEB_CAPABILITIES.sessionsBulkMutations,
|
||||
PI_WEB_CAPABILITIES.sessionsCleanup,
|
||||
PI_WEB_CAPABILITIES.sessionsReload,
|
||||
PI_WEB_CAPABILITIES.sessionsClearQueue,
|
||||
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
||||
PI_WEB_CAPABILITIES.promptAttachments,
|
||||
] as const satisfies readonly PiWebCapability[];
|
||||
@@ -33,6 +35,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
||||
[PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsClearQueue]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
||||
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
||||
|
||||
@@ -59,6 +59,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
||||
{ method: "POST", path: "/sessions/:sessionId/thinking-level/cycle" },
|
||||
{ method: "GET", path: "/sessions/:sessionId/commands" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/prompt" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/queue/clear" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/attachments" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
||||
{ method: "POST", path: "/sessions/:sessionId/commands/run" },
|
||||
|
||||
Reference in New Issue
Block a user