Archived
feat: add server session queue clearing
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
---
|
||||||
|
"@jmfederico/pi-web": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
Add a capability-aware Clear queue action that removes queued session messages, including prompts held during compaction, without stopping active work.
|
||||||
@@ -246,6 +246,36 @@ describe("session API compatibility", () => {
|
|||||||
expect(url).toBe("https://pi.example.test/api/machines/remote%20a/sessions/s%201/prompt");
|
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" });
|
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", () => {
|
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) }),
|
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),
|
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),
|
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),
|
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 }) }),
|
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 }) }),
|
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.deleteArchivedMany([session], machineId)),
|
||||||
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
|
ignoreParseFailure(sessionsApi.messages(session, { limit: 20, before: 10 }, machineId)),
|
||||||
ignoreParseFailure(sessionsApi.status(session, machineId)),
|
ignoreParseFailure(sessionsApi.status(session, machineId)),
|
||||||
|
ignoreParseFailure(sessionsApi.clearQueue(session, machineId)),
|
||||||
ignoreParseFailure(sessionsApi.models(session, machineId)),
|
ignoreParseFailure(sessionsApi.models(session, machineId)),
|
||||||
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
|
ignoreParseFailure(sessionsApi.setModel(session, "openai", "gpt", machineId)),
|
||||||
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
|
ignoreParseFailure(sessionsApi.cycleModel(session, "forward", machineId)),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import type { TemplateResult } from "lit";
|
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 type { ChatLine } from "./shared";
|
||||||
import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
|
import { ChatView, chatMessageMetadataLabel, chatQueuedMessageSections } from "./ChatView";
|
||||||
|
|
||||||
@@ -12,19 +13,61 @@ describe("chatQueuedMessageSections", () => {
|
|||||||
|
|
||||||
expect(sections).toEqual([
|
expect(sections).toEqual([
|
||||||
{
|
{
|
||||||
|
source: "client",
|
||||||
heading: "Queued until session starts",
|
heading: "Queued until session starts",
|
||||||
detail: "Will send once the backend session is ready",
|
detail: "Will send once the backend session is ready",
|
||||||
messages: [{ kind: "followUp", text: "queued before start" }],
|
messages: [{ kind: "followUp", text: "queued before start" }],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
source: "server",
|
||||||
heading: "Queued messages",
|
heading: "Queued messages",
|
||||||
detail: "1 pending · Stop clears the queue",
|
detail: "1 pending",
|
||||||
messages: [{ kind: "steer", text: "server queued" }],
|
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", () => {
|
describe("chatMessageMetadataLabel", () => {
|
||||||
it("uses one full date and model label without a model prefix", () => {
|
it("uses one full date and model label without a model prefix", () => {
|
||||||
const timestamp = "2026-07-10T19:15:30.000Z";
|
const timestamp = "2026-07-10T19:15:30.000Z";
|
||||||
@@ -103,10 +146,17 @@ interface GroupBodyRenderCall {
|
|||||||
startIndex: number;
|
startIndex: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type RenderQueuedMessages = (this: ChatView) => TemplateResult;
|
||||||
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
|
type RenderMessageGroup = (this: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean) => TemplateResult;
|
||||||
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
|
type RenderMessageGroupBody = (this: ChatView, messages: ChatLine[], startIndex: number) => TemplateResult;
|
||||||
type TemplateEventHandler = (event: Event) => void;
|
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 {
|
function renderMessageGroup(view: ChatView, messages: ChatLine[], startIndex: number, endIndex: number, defaultOpen: boolean): TemplateResult {
|
||||||
const method: unknown = Reflect.get(view, "renderMessageGroup");
|
const method: unknown = Reflect.get(view, "renderMessageGroup");
|
||||||
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
|
if (!isRenderMessageGroup(method)) throw new Error("ChatView.renderMessageGroup is not callable");
|
||||||
@@ -125,6 +175,10 @@ function observeGroupBodyRenders(view: ChatView): GroupBodyRenderCall[] {
|
|||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isRenderQueuedMessages(value: unknown): value is RenderQueuedMessages {
|
||||||
|
return typeof value === "function";
|
||||||
|
}
|
||||||
|
|
||||||
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
|
function isRenderMessageGroup(value: unknown): value is RenderMessageGroup {
|
||||||
return typeof value === "function";
|
return typeof value === "function";
|
||||||
}
|
}
|
||||||
@@ -134,13 +188,30 @@ function isRenderMessageGroupBody(value: unknown): value is RenderMessageGroupBo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler {
|
function templateEventHandler(template: TemplateResult, marker: string): TemplateEventHandler {
|
||||||
const strings = templateStrings(template);
|
let handler: TemplateEventHandler | undefined;
|
||||||
const values = templateValues(template);
|
visit(template);
|
||||||
for (let index = 0; index < values.length; index += 1) {
|
if (handler === undefined) throw new Error(`Expected template event handler near ${marker}`);
|
||||||
const value = values[index];
|
return handler;
|
||||||
if (strings[index]?.includes(marker) === true && isTemplateEventHandler(value)) return value;
|
|
||||||
|
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 {
|
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[] {
|
function isStringArray(value: unknown): value is string[] {
|
||||||
return Array.isArray(value) && value.every((item: unknown) => typeof item === "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 {
|
export interface QueuedMessageSection {
|
||||||
|
source: "client" | "server";
|
||||||
heading: string;
|
heading: string;
|
||||||
detail: string;
|
detail: string;
|
||||||
messages: QueuedSessionMessage[];
|
messages: QueuedSessionMessage[];
|
||||||
@@ -46,8 +47,8 @@ export interface QueuedMessageSection {
|
|||||||
|
|
||||||
export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] {
|
export function chatQueuedMessageSections(clientQueued: QueuedSessionMessage[], serverQueued: QueuedSessionMessage[]): QueuedMessageSection[] {
|
||||||
return [
|
return [
|
||||||
clientQueued.length === 0 ? undefined : { heading: "Queued until session starts", detail: "Will send once the backend session is ready", messages: clientQueued },
|
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 : { heading: "Queued messages", detail: `${String(serverQueued.length)} pending · Stop clears the queue`, messages: serverQueued },
|
serverQueued.length === 0 ? undefined : { source: "server", heading: "Queued messages", detail: `${String(serverQueued.length)} pending`, messages: serverQueued },
|
||||||
].filter((section): section is QueuedMessageSection => section !== undefined);
|
].filter((section): section is QueuedMessageSection => section !== undefined);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +90,8 @@ export class ChatView extends LitElement {
|
|||||||
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
|
@property({ attribute: false }) clientQueuedMessages: QueuedSessionMessage[] = [];
|
||||||
@property({ attribute: false }) status?: SessionStatus;
|
@property({ attribute: false }) status?: SessionStatus;
|
||||||
@property({ attribute: false }) activity?: SessionActivity;
|
@property({ attribute: false }) activity?: SessionActivity;
|
||||||
|
@property({ type: Boolean }) canClearServerQueue = false;
|
||||||
|
@property({ attribute: false }) onClearServerQueue?: () => void;
|
||||||
@property({ attribute: false }) onLoadMore?: () => void;
|
@property({ attribute: false }) onLoadMore?: () => void;
|
||||||
@query(".chat") private chat?: HTMLDivElement;
|
@query(".chat") private chat?: HTMLDivElement;
|
||||||
@state() private pinnedToBottom = true;
|
@state() private pinnedToBottom = true;
|
||||||
@@ -123,6 +126,9 @@ export class ChatView extends LitElement {
|
|||||||
private readonly onPageHide = () => {
|
private readonly onPageHide = () => {
|
||||||
this.saveScrollPosition();
|
this.saveScrollPosition();
|
||||||
};
|
};
|
||||||
|
private readonly handleClearServerQueue = (): void => {
|
||||||
|
this.onClearServerQueue?.();
|
||||||
|
};
|
||||||
|
|
||||||
override connectedCallback(): void {
|
override connectedCallback(): void {
|
||||||
super.connectedCallback();
|
super.connectedCallback();
|
||||||
@@ -261,11 +267,17 @@ export class ChatView extends LitElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private renderQueuedMessageList(section: QueuedMessageSection) {
|
private renderQueuedMessageList(section: QueuedMessageSection) {
|
||||||
|
const canClear = section.source === "server" && this.canClearServerQueue && this.onClearServerQueue !== undefined;
|
||||||
return html`
|
return html`
|
||||||
<aside class="queued-messages" aria-live="polite">
|
<aside class="queued-messages" aria-live="polite">
|
||||||
<div class="queued-header">
|
<div class="queued-header">
|
||||||
<strong>${section.heading}</strong>
|
<div class="queued-heading">
|
||||||
<small>${section.detail}</small>
|
<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>
|
</div>
|
||||||
${section.messages.map((message, index) => html`
|
${section.messages.map((message, index) => html`
|
||||||
<div class="queued-message">
|
<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);
|
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 {
|
private canCleanupSessions(): boolean {
|
||||||
const runtime = this.selectedMachineRuntime();
|
const runtime = this.selectedMachineRuntime();
|
||||||
return runtime?.ok === true && supportsPiWebCapability(runtime, PI_WEB_CAPABILITIES.sessionsCleanup);
|
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);
|
void this.sessions.send(text, streamingBehavior, attachments, delivery);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stable handler identities for <prompt-editor>. Inlined arrow closures would
|
// Stable handler identities for child components. Inlined arrow closures
|
||||||
// be a fresh reference on every render, forcing Lit to re-commit the bindings
|
// would be a fresh reference on every render, forcing Lit to re-commit the
|
||||||
// each time the app re-renders; bound class fields keep them constant.
|
// 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 => {
|
private readonly handleSendPrompt = (text: string, streamingBehavior?: "steer" | "followUp", attachments?: import("../api").PromptAttachment[], delivery?: import("../../../shared/apiTypes").PromptAttachmentDelivery): void => {
|
||||||
this.sendPrompt(text, streamingBehavior, attachments, delivery);
|
this.sendPrompt(text, streamingBehavior, attachments, delivery);
|
||||||
};
|
};
|
||||||
@@ -1865,6 +1870,10 @@ export class PiWebApp extends LitElement {
|
|||||||
void this.sessions.stopActiveWork();
|
void this.sessions.stopActiveWork();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
private readonly handleClearServerQueue = (): void => {
|
||||||
|
void this.sessions.clearServerQueue();
|
||||||
|
};
|
||||||
|
|
||||||
private readonly handleSelectModel = (): void => {
|
private readonly handleSelectModel = (): void => {
|
||||||
void this.openModelDialog();
|
void this.openModelDialog();
|
||||||
};
|
};
|
||||||
@@ -1873,6 +1882,12 @@ export class PiWebApp extends LitElement {
|
|||||||
void this.openThinkingDialog();
|
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() {
|
private renderContextBar() {
|
||||||
if (!this.appShell.isMobileNavigationLayout) return null;
|
if (!this.appShell.isMobileNavigationLayout) return null;
|
||||||
return html`
|
return html`
|
||||||
@@ -1931,7 +1946,7 @@ export class PiWebApp extends LitElement {
|
|||||||
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
${state.error ? html`<div class="error">${state.error}</div>` : null}
|
||||||
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
|
<div class="mobile-navigation-panel">${this.appShell.isMobileNavigationLayout ? this.renderNavigationPanel() : null}</div>
|
||||||
${state.selectedSession ? html`
|
${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>
|
<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>
|
<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}
|
${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}
|
||||||
|
|||||||
@@ -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:hover, .history-load-button:focus { border-color: var(--pi-accent); color: var(--pi-text-bright); }
|
||||||
.history-load-button:disabled { cursor: default; opacity: .55; }
|
.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-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 { display: flex; flex-wrap: wrap; align-items: flex-start; justify-content: space-between; gap: 10px; }
|
||||||
.queued-header strong { color: var(--pi-warning); }
|
.queued-heading { min-width: 0; flex: 1 1 180px; display: grid; gap: 2px; }
|
||||||
.queued-header small { color: var(--pi-muted); }
|
.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 { 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-message:first-of-type { padding-top: 0; border-top: 0; }
|
||||||
.queued-kind { color: var(--pi-muted); font-size: 12px; text-transform: uppercase; }
|
.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() {
|
async stopActiveWork() {
|
||||||
const session = this.getState().selectedSession;
|
const session = this.getState().selectedSession;
|
||||||
if (!session) return;
|
if (!session) return;
|
||||||
@@ -768,11 +782,15 @@ export class SessionController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private isCurrentRefreshTarget(target: SelectedSessionRefreshTarget): boolean {
|
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 state = this.getState();
|
||||||
const selected = state.selectedSession;
|
const selected = state.selectedSession;
|
||||||
return target.selectionSeq === this.selectionSeq
|
return selectionSeq === this.selectionSeq
|
||||||
&& selectedMachineId(state) === target.machineId
|
&& selectedMachineId(state) === machineId
|
||||||
&& selected?.id === target.session.id
|
&& selected?.id === sessionId
|
||||||
&& selected.archived !== true
|
&& selected.archived !== true
|
||||||
&& !isClientPendingStartSessionInfo(selected);
|
&& !isClientPendingStartSessionInfo(selected);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -186,6 +186,24 @@ describe("buildApp remote machine proxy routes", () => {
|
|||||||
expect(request).toHaveBeenCalledWith("POST", "/api/sessions/s1/reload", { cwd: "/repo" });
|
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 () => {
|
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 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 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 }]);
|
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 () => {
|
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" } });
|
const response = await app.inject({ method: "POST", url: "/api/machines/local/auth/api-key", payload: { providerId: "p", key: "k" } });
|
||||||
|
|
||||||
|
|||||||
@@ -212,6 +212,82 @@ describe("PiSessionService prompt, queue, and auth warnings", () => {
|
|||||||
await service.dispose();
|
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, {
|
||||||
|
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(), {
|
||||||
|
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 () => {
|
it("clears queued messages when aborting active work", async () => {
|
||||||
const fake = fakeRuntime("abort-session");
|
const fake = fakeRuntime("abort-session");
|
||||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||||
|
|||||||
@@ -1363,6 +1363,15 @@ export class PiSessionService {
|
|||||||
this.unregisterSubsession(session.sessionId);
|
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> {
|
async abort(ref: PiSessionLookup): Promise<void> {
|
||||||
const active = this.activeForLookup(ref);
|
const active = this.activeForLookup(ref);
|
||||||
if (active === undefined) return;
|
if (active === undefined) return;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { resolve } from "node:path";
|
|||||||
import Fastify, { type FastifyInstance } from "fastify";
|
import Fastify, { type FastifyInstance } from "fastify";
|
||||||
import fastifyWebsocket from "@fastify/websocket";
|
import fastifyWebsocket from "@fastify/websocket";
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
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 { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||||
import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js";
|
import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js";
|
||||||
import { registerSessionRoutes } from "./sessionRoutes.js";
|
import { registerSessionRoutes } from "./sessionRoutes.js";
|
||||||
@@ -165,6 +165,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(eventHub);
|
||||||
|
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(eventHub);
|
||||||
|
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 () => {
|
it("normalizes cleanup requests for preview and execute routes", async () => {
|
||||||
const routeApp = Fastify({ logger: false });
|
const routeApp = Fastify({ logger: false });
|
||||||
await routeApp.register(fastifyWebsocket);
|
await routeApp.register(fastifyWebsocket);
|
||||||
@@ -252,12 +301,14 @@ describe("session routes", () => {
|
|||||||
class CapturingRouteSessionService extends PiSessionService {
|
class CapturingRouteSessionService extends PiSessionService {
|
||||||
readonly calls: unknown[] = [];
|
readonly calls: unknown[] = [];
|
||||||
readonly reloadCalls: (string | PiSessionRef)[] = [];
|
readonly reloadCalls: (string | PiSessionRef)[] = [];
|
||||||
|
readonly clearQueueCalls: (string | PiSessionRef)[] = [];
|
||||||
messagesResponse: unknown[] | MessagePage = [];
|
messagesResponse: unknown[] | MessagePage = [];
|
||||||
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
|
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
|
||||||
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
|
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
|
||||||
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
||||||
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
readonly bulkDeleteCalls: SessionBulkMutationRef[][] = [];
|
||||||
reloadError: Error | undefined;
|
reloadError: Error | undefined;
|
||||||
|
clearQueueError: Error | undefined;
|
||||||
|
|
||||||
constructor(eventHub: SessionEventHub) {
|
constructor(eventHub: SessionEventHub) {
|
||||||
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
|
super(eventHub, { sessionManager: new RejectingSessionManager(), heartbeatIntervalMs: 60_000 });
|
||||||
@@ -289,6 +340,21 @@ class CapturingRouteSessionService extends PiSessionService {
|
|||||||
return Promise.resolve();
|
return Promise.resolve();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
override clearQueue(lookup: string | PiSessionRef): 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,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
override messages(): Promise<unknown[] | MessagePage> {
|
override messages(): Promise<unknown[] | MessagePage> {
|
||||||
return Promise.resolve(this.messagesResponse);
|
return Promise.resolve(this.messagesResponse);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -173,6 +173,14 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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) => {
|
app.post<{ Params: { sessionId: string }; Body: AttachmentsRequestBody | undefined }>(`${prefix}/sessions/:sessionId/attachments`, async (request, reply) => {
|
||||||
try {
|
try {
|
||||||
const body = optionalRecord(request.body);
|
const body = optionalRecord(request.body);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const PI_WEB_CAPABILITIES = {
|
|||||||
sessionsBulkMutations: "sessions.bulkMutations",
|
sessionsBulkMutations: "sessions.bulkMutations",
|
||||||
sessionsCleanup: "sessions.cleanup",
|
sessionsCleanup: "sessions.cleanup",
|
||||||
sessionsReload: "sessions.reload",
|
sessionsReload: "sessions.reload",
|
||||||
|
sessionsClearQueue: "sessions.clearQueue",
|
||||||
sessionsPersistedState: "sessions.persistedState",
|
sessionsPersistedState: "sessions.persistedState",
|
||||||
promptAttachments: "prompt.attachments",
|
promptAttachments: "prompt.attachments",
|
||||||
workspaceFileSuggestions: "workspace.fileSuggestions",
|
workspaceFileSuggestions: "workspace.fileSuggestions",
|
||||||
|
|||||||
@@ -28,6 +28,26 @@ describe("PI WEB capabilities", () => {
|
|||||||
})).toContain(PI_WEB_CAPABILITIES.sessionsPersistedState);
|
})).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", () => {
|
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, PI_WEB_CAPABILITIES.selectedMachineSettings, "future.capability"])).toEqual([PI_WEB_CAPABILITIES.piPackagesManage, PI_WEB_CAPABILITIES.selectedMachineSettings]);
|
||||||
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
|
expect(parseKnownPiWebCapabilities([PI_WEB_CAPABILITIES.piPackagesManage, 1])).toBeUndefined();
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export const WEB_RUNTIME_CAPABILITIES = [
|
|||||||
PI_WEB_CAPABILITIES.sessionsBulkMutations,
|
PI_WEB_CAPABILITIES.sessionsBulkMutations,
|
||||||
PI_WEB_CAPABILITIES.sessionsCleanup,
|
PI_WEB_CAPABILITIES.sessionsCleanup,
|
||||||
PI_WEB_CAPABILITIES.sessionsReload,
|
PI_WEB_CAPABILITIES.sessionsReload,
|
||||||
|
PI_WEB_CAPABILITIES.sessionsClearQueue,
|
||||||
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
||||||
PI_WEB_CAPABILITIES.promptAttachments,
|
PI_WEB_CAPABILITIES.promptAttachments,
|
||||||
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
|
PI_WEB_CAPABILITIES.workspaceFileSuggestions,
|
||||||
@@ -23,6 +24,7 @@ export const SESSIOND_RUNTIME_CAPABILITIES = [
|
|||||||
PI_WEB_CAPABILITIES.sessionsBulkMutations,
|
PI_WEB_CAPABILITIES.sessionsBulkMutations,
|
||||||
PI_WEB_CAPABILITIES.sessionsCleanup,
|
PI_WEB_CAPABILITIES.sessionsCleanup,
|
||||||
PI_WEB_CAPABILITIES.sessionsReload,
|
PI_WEB_CAPABILITIES.sessionsReload,
|
||||||
|
PI_WEB_CAPABILITIES.sessionsClearQueue,
|
||||||
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
PI_WEB_CAPABILITIES.sessionsPersistedState,
|
||||||
PI_WEB_CAPABILITIES.promptAttachments,
|
PI_WEB_CAPABILITIES.promptAttachments,
|
||||||
] as const satisfies readonly PiWebCapability[];
|
] as const satisfies readonly PiWebCapability[];
|
||||||
@@ -32,6 +34,7 @@ const EFFECTIVE_CAPABILITY_REQUIREMENTS = {
|
|||||||
[PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"],
|
[PI_WEB_CAPABILITIES.sessionsBulkMutations]: ["web", "sessiond"],
|
||||||
[PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"],
|
[PI_WEB_CAPABILITIES.sessionsCleanup]: ["web", "sessiond"],
|
||||||
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
|
[PI_WEB_CAPABILITIES.sessionsReload]: ["web", "sessiond"],
|
||||||
|
[PI_WEB_CAPABILITIES.sessionsClearQueue]: ["web", "sessiond"],
|
||||||
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
|
[PI_WEB_CAPABILITIES.sessionsPersistedState]: ["web", "sessiond"],
|
||||||
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
[PI_WEB_CAPABILITIES.promptAttachments]: ["web", "sessiond"],
|
||||||
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
[PI_WEB_CAPABILITIES.workspaceFileSuggestions]: ["web"],
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export const FEDERATED_HTTP_ROUTES = [
|
|||||||
{ method: "POST", path: "/sessions/:sessionId/thinking-level/cycle" },
|
{ method: "POST", path: "/sessions/:sessionId/thinking-level/cycle" },
|
||||||
{ method: "GET", path: "/sessions/:sessionId/commands" },
|
{ method: "GET", path: "/sessions/:sessionId/commands" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/prompt" },
|
{ method: "POST", path: "/sessions/:sessionId/prompt" },
|
||||||
|
{ method: "POST", path: "/sessions/:sessionId/queue/clear" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/attachments" },
|
{ method: "POST", path: "/sessions/:sessionId/attachments" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
{ method: "POST", path: "/sessions/:sessionId/shell" },
|
||||||
{ method: "POST", path: "/sessions/:sessionId/commands/run" },
|
{ method: "POST", path: "/sessions/:sessionId/commands/run" },
|
||||||
|
|||||||
Reference in New Issue
Block a user