Archived
perf: speed up chat loading and resume
This commit is contained in:
@@ -0,0 +1,90 @@
|
||||
import { Readable } from "node:stream";
|
||||
import { gunzipSync } from "node:zlib";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { appTestContext, fakeRemoteClient, registerAppTestHooks } from "./app.testSupport.js";
|
||||
|
||||
registerAppTestHooks();
|
||||
|
||||
describe("browser-facing HTTP compression", () => {
|
||||
it("negotiates compression for large local-machine API responses", async () => {
|
||||
const marker = "local transcript content ".repeat(256);
|
||||
appTestContext.piWebConfig = {
|
||||
plugins: { fake: { settings: { marker } } },
|
||||
};
|
||||
|
||||
const compressed = await appTestContext.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/machines/local/config",
|
||||
headers: { "accept-encoding": "gzip" },
|
||||
});
|
||||
const identity = await appTestContext.app.inject({
|
||||
method: "GET",
|
||||
url: "/api/machines/local/config",
|
||||
headers: { "accept-encoding": "identity" },
|
||||
});
|
||||
|
||||
expect(compressed.statusCode).toBe(200);
|
||||
expect(compressed.headers["content-encoding"]).toBe("gzip");
|
||||
expect(compressed.headers["content-length"]).toBeUndefined();
|
||||
expect(compressed.headers.vary).toContain("accept-encoding");
|
||||
expect(gunzipJson(compressed)).toMatchObject({ effectiveConfig: { plugins: { fake: { settings: { marker } } } } });
|
||||
|
||||
expect(identity.statusCode).toBe(200);
|
||||
expect(identity.headers["content-encoding"]).toBeUndefined();
|
||||
expect(identity.json()).toMatchObject({ effectiveConfig: { plugins: { fake: { settings: { marker } } } } });
|
||||
});
|
||||
|
||||
it("negotiates compression after streaming a remote-machine API response", 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 projects = Array.from({ length: 64 }, (_, index) => ({
|
||||
id: `p-${String(index)}`,
|
||||
name: `Remote project ${String(index)}`,
|
||||
path: `/repos/project-${String(index)}`,
|
||||
createdAt: "2026-07-11T00:00:00.000Z",
|
||||
}));
|
||||
const body = JSON.stringify(projects);
|
||||
const request = vi.fn(() => Promise.resolve({
|
||||
statusCode: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-length": String(Buffer.byteLength(body)),
|
||||
},
|
||||
body: Readable.from([body]),
|
||||
}));
|
||||
appTestContext.remoteClient = fakeRemoteClient({ request });
|
||||
const url = `/api/machines/${remote.id}/projects`;
|
||||
|
||||
const compressed = await appTestContext.app.inject({
|
||||
method: "GET",
|
||||
url,
|
||||
headers: { "accept-encoding": "gzip" },
|
||||
});
|
||||
const identity = await appTestContext.app.inject({
|
||||
method: "GET",
|
||||
url,
|
||||
headers: { "accept-encoding": "identity" },
|
||||
});
|
||||
|
||||
expect(compressed.statusCode).toBe(200);
|
||||
expect(compressed.headers["content-encoding"]).toBe("gzip");
|
||||
expect(compressed.headers["content-length"]).toBeUndefined();
|
||||
expect(compressed.headers.vary).toContain("accept-encoding");
|
||||
expect(gunzipJson(compressed)).toEqual(projects);
|
||||
|
||||
expect(identity.statusCode).toBe(200);
|
||||
expect(identity.headers["content-encoding"]).toBeUndefined();
|
||||
expect(identity.json()).toEqual(projects);
|
||||
expect(request).toHaveBeenNthCalledWith(1, "GET", "/api/projects", undefined);
|
||||
expect(request).toHaveBeenNthCalledWith(2, "GET", "/api/projects", undefined);
|
||||
});
|
||||
});
|
||||
|
||||
function gunzipJson(response: { rawPayload: Buffer }): unknown {
|
||||
const value: unknown = JSON.parse(gunzipSync(response.rawPayload).toString("utf8"));
|
||||
return value;
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import Fastify, { type FastifyInstance, type FastifyServerOptions } from "fastify";
|
||||
import fastifyCompress from "@fastify/compress";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import fastifyWebsocket from "@fastify/websocket";
|
||||
import { ProjectStore } from "./storage/projectStore.js";
|
||||
@@ -120,6 +121,13 @@ function registerLocalFileSuggestionRoutes(app: FastifyInstance, projects: Proje
|
||||
|
||||
export async function buildApp(deps: AppDependencies = {}): Promise<FastifyInstance> {
|
||||
const app = Fastify({ logger: deps.logger ?? true, ...(deps.bodyLimit === undefined ? {} : { bodyLimit: deps.bodyLimit }) });
|
||||
// Vite proxies development API requests here, while production and machine-scoped
|
||||
// API requests already terminate here, so this is the shared browser HTTP edge.
|
||||
await app.register(fastifyCompress, {
|
||||
globalCompression: true,
|
||||
globalDecompression: false,
|
||||
threshold: 1024,
|
||||
});
|
||||
await app.register(fastifyWebsocket);
|
||||
|
||||
const projects = deps.projects ?? new ProjectService(new ProjectStore());
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeMessage } from "../client/src/chatMessages.js";
|
||||
import type { MessagePage } from "../shared/apiTypes.js";
|
||||
import { projectBrowserMessage, projectBrowserMessageResponse, projectBrowserSessionEvent } from "./browserMessageProjection.js";
|
||||
|
||||
function signedAssistantMessage() {
|
||||
return {
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true },
|
||||
{ type: "text", text: "visible answer", textSignature: "text-metadata" },
|
||||
{ type: "toolCall", name: "read", arguments: { thinkingSignature: "ordinary nested argument" }, thoughtSignature: "tool-metadata" },
|
||||
],
|
||||
model: "model-1",
|
||||
};
|
||||
}
|
||||
|
||||
describe("browser message projection", () => {
|
||||
it("omits only thinking-block signatures without mutating runtime messages", () => {
|
||||
const message = signedAssistantMessage();
|
||||
|
||||
const projected = projectBrowserMessage(message);
|
||||
|
||||
expect(projected).toEqual({
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "thinking", thinking: "private chain", redacted: true },
|
||||
{ type: "text", text: "visible answer", textSignature: "text-metadata" },
|
||||
{ type: "toolCall", name: "read", arguments: { thinkingSignature: "ordinary nested argument" }, thoughtSignature: "tool-metadata" },
|
||||
],
|
||||
model: "model-1",
|
||||
});
|
||||
expect(message.content[0]).toEqual({ type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true });
|
||||
expect(normalizeMessage(projected)).toEqual(normalizeMessage(message));
|
||||
});
|
||||
|
||||
it("projects both paged and legacy array history responses", () => {
|
||||
const message = signedAssistantMessage();
|
||||
const page: MessagePage = { messages: [message], start: 4, total: 5 };
|
||||
|
||||
expect(projectBrowserMessageResponse(page)).toEqual({
|
||||
messages: [{ ...message, content: [{ type: "thinking", thinking: "private chain", redacted: true }, ...message.content.slice(1)] }],
|
||||
start: 4,
|
||||
total: 5,
|
||||
});
|
||||
expect(projectBrowserMessageResponse([message])).toEqual([
|
||||
{ ...message, content: [{ type: "thinking", thinking: "private chain", redacted: true }, ...message.content.slice(1)] },
|
||||
]);
|
||||
expect(page.messages[0]).toBe(message);
|
||||
});
|
||||
|
||||
it("projects final-message events but leaves other event shapes untouched", () => {
|
||||
const message = signedAssistantMessage();
|
||||
const finalEvent = { type: "message.end" as const, message };
|
||||
const appendEvent = { type: "message.append" as const, message };
|
||||
|
||||
expect(projectBrowserSessionEvent(finalEvent)).toEqual({
|
||||
type: "message.end",
|
||||
message: { ...message, content: [{ type: "thinking", thinking: "private chain", redacted: true }, ...message.content.slice(1)] },
|
||||
});
|
||||
expect(projectBrowserSessionEvent(appendEvent)).toBe(appendEvent);
|
||||
expect(finalEvent.message).toBe(message);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import type { MessagePage, SessionUiEvent } from "../shared/apiTypes.js";
|
||||
|
||||
/**
|
||||
* Remove provider-only thinking data at the browser transport boundary. The
|
||||
* runtime message remains unchanged because only affected messages and content
|
||||
* blocks are copied.
|
||||
*/
|
||||
export function projectBrowserMessage(message: unknown): unknown {
|
||||
if (!isRecord(message)) return message;
|
||||
const originalContent = message["content"];
|
||||
if (!isUnknownArray(originalContent)) return message;
|
||||
|
||||
const content = mapChanged(originalContent, (part) => {
|
||||
if (!isRecord(part) || part["type"] !== "thinking" || !Object.hasOwn(part, "thinkingSignature")) return part;
|
||||
const projected = { ...part };
|
||||
delete projected["thinkingSignature"];
|
||||
return projected;
|
||||
});
|
||||
|
||||
return content === originalContent ? message : { ...message, content };
|
||||
}
|
||||
|
||||
export function projectBrowserMessageResponse(response: unknown[] | MessagePage): unknown[] | MessagePage {
|
||||
if (Array.isArray(response)) return mapChanged(response, projectBrowserMessage);
|
||||
const messages = mapChanged(response.messages, projectBrowserMessage);
|
||||
return messages === response.messages ? response : { ...response, messages };
|
||||
}
|
||||
|
||||
export function projectBrowserSessionEvent(event: SessionUiEvent): SessionUiEvent {
|
||||
if (event.type !== "message.end" || event.message === undefined) return event;
|
||||
const message = projectBrowserMessage(event.message);
|
||||
return message === event.message ? event : { ...event, message };
|
||||
}
|
||||
|
||||
function mapChanged<T>(values: T[], project: (value: T) => T): T[] {
|
||||
let projectedValues: T[] | undefined;
|
||||
let index = 0;
|
||||
for (const value of values) {
|
||||
const projected = project(value);
|
||||
if (projectedValues === undefined) {
|
||||
if (projected === value) {
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
projectedValues = values.slice(0, index);
|
||||
}
|
||||
projectedValues.push(projected);
|
||||
index += 1;
|
||||
}
|
||||
return projectedValues ?? values;
|
||||
}
|
||||
|
||||
function isUnknownArray(value: unknown): value is unknown[] {
|
||||
return Array.isArray(value);
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
@@ -29,6 +29,38 @@ describe("RemoteMachineClient", () => {
|
||||
expect(new Headers(init.headers).get("content-type")).toBe("application/json");
|
||||
expect(init.body).toBe(JSON.stringify({ cwd: "/repo" }));
|
||||
});
|
||||
|
||||
it("requests compression for the remote hop even when configured headers use different casing", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response("ok", { status: 200 })));
|
||||
const client = new RemoteMachineClient({
|
||||
baseUrl: "https://remote.example.test/",
|
||||
headers: { "Accept-Encoding": "identity" },
|
||||
}, fetchImpl);
|
||||
|
||||
await client.request("GET", "/api/projects");
|
||||
|
||||
const { init } = onlyFetchCall(fetchImpl);
|
||||
expect(new Headers(init.headers).get("accept-encoding")).toBe("gzip, deflate");
|
||||
});
|
||||
|
||||
it("removes stale representation headers after Fetch decodes a compressed response", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(() => Promise.resolve(new Response(JSON.stringify({ ok: true }), {
|
||||
status: 200,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"content-encoding": "gzip",
|
||||
"content-length": "31",
|
||||
},
|
||||
})));
|
||||
const client = new RemoteMachineClient({ baseUrl: "https://remote.example.test/" }, fetchImpl);
|
||||
|
||||
const response = await client.requestJson("GET", "/api/projects");
|
||||
|
||||
expect(response.body).toEqual({ ok: true });
|
||||
expect(response.headers["content-type"]).toBe("application/json");
|
||||
expect(response.headers["content-encoding"]).toBeUndefined();
|
||||
expect(response.headers["content-length"]).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
function fetchInputUrl(input: RequestInfo | URL): string {
|
||||
|
||||
@@ -28,6 +28,8 @@ export interface MachineClient {
|
||||
export const DEFAULT_REMOTE_REQUEST_TIMEOUT_MS = 30_000;
|
||||
export const DEFAULT_REMOTE_HEALTH_TIMEOUT_MS = 3_000;
|
||||
|
||||
const REMOTE_RESPONSE_ACCEPT_ENCODING = "gzip, deflate";
|
||||
|
||||
const BLOCKED_CONFIGURED_HEADER_NAMES = new Set([
|
||||
"host",
|
||||
"connection",
|
||||
@@ -57,7 +59,7 @@ export class RemoteMachineClient implements MachineClient {
|
||||
const response = await this.fetchResponse(method, path, body, options);
|
||||
return {
|
||||
statusCode: response.status,
|
||||
headers: headersToRecord(response.headers),
|
||||
headers: decodedResponseHeaders(response.headers),
|
||||
...(response.body === null ? {} : { body: readableFromWebResponseBody(response.body) }),
|
||||
};
|
||||
}
|
||||
@@ -68,7 +70,7 @@ export class RemoteMachineClient implements MachineClient {
|
||||
const parsed: unknown = text === "" ? undefined : JSON.parse(text);
|
||||
return {
|
||||
statusCode: response.status,
|
||||
headers: headersToRecord(response.headers),
|
||||
headers: decodedResponseHeaders(response.headers),
|
||||
body: parsed,
|
||||
};
|
||||
}
|
||||
@@ -100,12 +102,12 @@ export class RemoteMachineClient implements MachineClient {
|
||||
}
|
||||
}
|
||||
|
||||
private requestHeaders(body: unknown, options: MachineRequestOptions): HeadersInit {
|
||||
return {
|
||||
...this.remoteHeaders(),
|
||||
accept: "*/*",
|
||||
...(body === undefined ? {} : { "content-type": options.contentType ?? defaultContentTypeForBody(body) }),
|
||||
};
|
||||
private requestHeaders(body: unknown, options: MachineRequestOptions): Headers {
|
||||
const headers = new Headers(this.remoteHeaders());
|
||||
headers.set("accept", "*/*");
|
||||
headers.set("accept-encoding", REMOTE_RESPONSE_ACCEPT_ENCODING);
|
||||
if (body !== undefined) headers.set("content-type", options.contentType ?? defaultContentTypeForBody(body));
|
||||
return headers;
|
||||
}
|
||||
|
||||
private remoteHeaders(): Record<string, string> {
|
||||
@@ -145,8 +147,16 @@ function filterConfiguredHeaders(headers: Record<string, string> | undefined): R
|
||||
return Object.fromEntries(Object.entries(headers).filter(([key]) => !BLOCKED_CONFIGURED_HEADER_NAMES.has(key.toLowerCase())));
|
||||
}
|
||||
|
||||
function headersToRecord(headers: Headers): Record<string, string> {
|
||||
return Object.fromEntries(headers.entries());
|
||||
function decodedResponseHeaders(headers: Headers): Record<string, string> {
|
||||
const values: Record<string, string> = Object.fromEntries(headers.entries());
|
||||
const contentEncoding = values["content-encoding"];
|
||||
if (contentEncoding !== undefined && contentEncoding !== "identity") {
|
||||
// Fetch decodes response bodies but retains headers for the encoded wire
|
||||
// representation. The outer HTTP edge must negotiate and frame the decoded body.
|
||||
delete values["content-encoding"];
|
||||
delete values["content-length"];
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
function serializeRequestBody(method: string, body: unknown): NonNullable<RequestInit["body"]> | undefined {
|
||||
|
||||
@@ -22,6 +22,22 @@ describe("SessionEventHub", () => {
|
||||
expect(otherSocket.send).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("omits thinking signatures from final-message payloads without mutating source events", () => {
|
||||
const hub = new SessionEventHub();
|
||||
const socket = new FakeSocket();
|
||||
hub.add("s1", socket);
|
||||
const thinkingBlock = { type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true };
|
||||
const message = { role: "assistant", content: [thinkingBlock, { type: "text", text: "visible answer" }] };
|
||||
|
||||
hub.publish("s1", { type: "message.end", message });
|
||||
|
||||
expect(socket.send).toHaveBeenCalledWith(JSON.stringify({
|
||||
type: "message.end",
|
||||
message: { role: "assistant", content: [{ type: "thinking", thinking: "private chain", redacted: true }, { type: "text", text: "visible answer" }] },
|
||||
}));
|
||||
expect(thinkingBlock.thinkingSignature).toBe("opaque-provider-payload");
|
||||
});
|
||||
|
||||
it("removes session sockets on close and skips non-open sockets", () => {
|
||||
const hub = new SessionEventHub();
|
||||
const closed = new FakeSocket();
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { GlobalSessionEvent, RealtimeEvent, SessionUiEvent } from "../../shared/apiTypes.js";
|
||||
import { projectBrowserSessionEvent } from "../browserMessageProjection.js";
|
||||
|
||||
export interface RealtimeSocket {
|
||||
readonly OPEN: number;
|
||||
@@ -29,7 +30,7 @@ export class SessionEventHub {
|
||||
}
|
||||
|
||||
publish(sessionId: string, event: SessionUiEvent): void {
|
||||
const payload = JSON.stringify(event);
|
||||
const payload = JSON.stringify(projectBrowserSessionEvent(event));
|
||||
for (const socket of this.socketsBySession.get(sessionId) ?? []) {
|
||||
if (socket.readyState === socket.OPEN) socket.send(payload);
|
||||
}
|
||||
|
||||
@@ -2,8 +2,18 @@ import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { PiSessionService, type PiAgentSession } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
import { PiSessionService, type PiAgentSession, type PiSessionRuntime } from "./piSessionService.js";
|
||||
import { CapturingSessionEventHub, emptyArchiveStore, fakeRuntime, fakeSessionManager, runtimeCreator, sessionGateway, sessionRecord, sessionRef, type RuntimeCreator } from "./piSessionService.testSupport.js";
|
||||
|
||||
function deferred<T = void>() {
|
||||
let resolve!: (value: T | PromiseLike<T>) => void;
|
||||
let reject!: (reason?: unknown) => void;
|
||||
const promise = new Promise<T>((promiseResolve, promiseReject) => {
|
||||
resolve = promiseResolve;
|
||||
reject = promiseReject;
|
||||
});
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
it("starts sessions through an injected runtime creator", async () => {
|
||||
@@ -85,6 +95,156 @@ describe("PiSessionService lifecycle, listing, and reload", () => {
|
||||
await service.dispose();
|
||||
});
|
||||
|
||||
it("shares one runtime when concurrent cold lookups resolve to the same session", async () => {
|
||||
const sessionId = "single-flight-session";
|
||||
const createStarted = deferred();
|
||||
const releaseCreate = deferred();
|
||||
const winnerUnsubscribe = vi.fn();
|
||||
const loserUnsubscribe = vi.fn();
|
||||
const winnerSubscribe = vi.fn(() => winnerUnsubscribe);
|
||||
const loserSubscribe = vi.fn(() => loserUnsubscribe);
|
||||
const winner = fakeRuntime(sessionId, {
|
||||
sessionManager: fakeSessionManager("/workspace", {
|
||||
getSessionId: () => sessionId,
|
||||
getBranch: () => [{ type: "message", message: { role: "user", content: "shared runtime" } }],
|
||||
}),
|
||||
subscribe: winnerSubscribe,
|
||||
});
|
||||
const loser = fakeRuntime(sessionId, {
|
||||
sessionManager: fakeSessionManager("/workspace", { getSessionId: () => sessionId }),
|
||||
subscribe: loserSubscribe,
|
||||
});
|
||||
const runtimes = [winner.runtime, loser.runtime];
|
||||
let createCalls = 0;
|
||||
const createAgentRuntime: RuntimeCreator = async () => {
|
||||
const runtime = runtimes[createCalls];
|
||||
createCalls += 1;
|
||||
createStarted.resolve();
|
||||
await releaseCreate.promise;
|
||||
if (runtime === undefined) throw new Error("unexpected runtime creation");
|
||||
return runtime;
|
||||
};
|
||||
const gateway = sessionGateway([sessionRecord(sessionId)]);
|
||||
const open = vi.spyOn(gateway, "open");
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
archiveStore: emptyArchiveStore(),
|
||||
createAgentRuntime,
|
||||
sessionManager: gateway,
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const messagesPromise = service.messages(sessionRef(sessionId));
|
||||
await createStarted.promise;
|
||||
const statusPromise = service.status(sessionRef("single-flight"));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const callsWhileOpening = createCalls;
|
||||
releaseCreate.resolve();
|
||||
|
||||
const [messages, status] = await Promise.all([messagesPromise, statusPromise]);
|
||||
const activeCount = service.activeCount();
|
||||
await service.dispose();
|
||||
|
||||
expect(callsWhileOpening).toBe(1);
|
||||
expect(createCalls).toBe(1);
|
||||
expect(open).toHaveBeenCalledOnce();
|
||||
expect(activeCount).toBe(1);
|
||||
expect(messages).toEqual([{ role: "user", content: "shared runtime" }]);
|
||||
expect(status).toMatchObject({ sessionId });
|
||||
expect(winnerSubscribe).toHaveBeenCalledOnce();
|
||||
expect(winnerUnsubscribe).toHaveBeenCalledOnce();
|
||||
expect(winner.calls.dispose).toBe(1);
|
||||
expect(loserSubscribe).not.toHaveBeenCalled();
|
||||
expect(loserUnsubscribe).not.toHaveBeenCalled();
|
||||
expect(loser.calls.dispose).toBe(0);
|
||||
});
|
||||
|
||||
it("clears a failed pending open so the session can be retried", async () => {
|
||||
const sessionId = "retry-open-session";
|
||||
const bindStarted = deferred();
|
||||
const bindResult = deferred();
|
||||
const openingError = new Error("extension binding failed");
|
||||
const failed = fakeRuntime(sessionId, {
|
||||
bindExtensions: () => {
|
||||
bindStarted.resolve();
|
||||
return bindResult.promise;
|
||||
},
|
||||
});
|
||||
const retried = fakeRuntime(sessionId);
|
||||
const runtimes = [failed.runtime, retried.runtime];
|
||||
let createCalls = 0;
|
||||
const createAgentRuntime: RuntimeCreator = () => {
|
||||
const runtime = runtimes[createCalls];
|
||||
createCalls += 1;
|
||||
return runtime === undefined
|
||||
? Promise.reject(new Error("unexpected runtime creation"))
|
||||
: Promise.resolve(runtime);
|
||||
};
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
archiveStore: emptyArchiveStore(),
|
||||
createAgentRuntime,
|
||||
sessionManager: sessionGateway([sessionRecord(sessionId)]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const messagesPromise = service.messages(sessionRef(sessionId));
|
||||
await bindStarted.promise;
|
||||
const statusPromise = service.status(sessionRef("retry-open"));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const callsWhileOpening = createCalls;
|
||||
const failedLookups = Promise.allSettled([messagesPromise, statusPromise]);
|
||||
bindResult.reject(openingError);
|
||||
|
||||
const outcomes = await failedLookups;
|
||||
expect(callsWhileOpening).toBe(1);
|
||||
expect(outcomes).toHaveLength(2);
|
||||
for (const outcome of outcomes) {
|
||||
expect(outcome.status).toBe("rejected");
|
||||
if (outcome.status === "rejected") expect(outcome.reason).toBe(openingError);
|
||||
}
|
||||
expect(service.activeCount()).toBe(0);
|
||||
expect(failed.calls.abort).toBe(1);
|
||||
expect(failed.calls.dispose).toBe(1);
|
||||
|
||||
await expect(service.status(sessionRef(sessionId))).resolves.toMatchObject({ sessionId });
|
||||
expect(createCalls).toBe(2);
|
||||
expect(service.activeCount()).toBe(1);
|
||||
|
||||
await service.dispose();
|
||||
expect(retried.calls.dispose).toBe(1);
|
||||
});
|
||||
|
||||
it("waits for an in-flight open before disposing the service", async () => {
|
||||
const sessionId = "dispose-opening-session";
|
||||
const createStarted = deferred();
|
||||
const runtimeResult = deferred<PiSessionRuntime>();
|
||||
const fake = fakeRuntime(sessionId);
|
||||
const service = new PiSessionService(new CapturingSessionEventHub(), {
|
||||
archiveStore: emptyArchiveStore(),
|
||||
createAgentRuntime: () => {
|
||||
createStarted.resolve();
|
||||
return runtimeResult.promise;
|
||||
},
|
||||
sessionManager: sessionGateway([sessionRecord(sessionId)]),
|
||||
heartbeatIntervalMs: 60_000,
|
||||
});
|
||||
|
||||
const statusPromise = service.status(sessionRef(sessionId));
|
||||
await createStarted.promise;
|
||||
let disposeSettled = false;
|
||||
const disposePromise = service.dispose().then(() => { disposeSettled = true; });
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
const settledWhileOpening = disposeSettled;
|
||||
runtimeResult.resolve(fake.runtime);
|
||||
|
||||
await expect(statusPromise).resolves.toMatchObject({ sessionId });
|
||||
await disposePromise;
|
||||
|
||||
expect(settledWhileOpening).toBe(false);
|
||||
expect(service.activeCount()).toBe(0);
|
||||
expect(fake.calls.abort).toBe(1);
|
||||
expect(fake.calls.dispose).toBe(1);
|
||||
});
|
||||
|
||||
it("binds extensions again when the SDK runtime replaces the active session", async () => {
|
||||
const hub = new CapturingSessionEventHub();
|
||||
const fake = fakeRuntime("session-1");
|
||||
|
||||
@@ -31,7 +31,7 @@ import { attachmentsToInlineImages, saveAttachmentsToWorkspace } from "./attachm
|
||||
import { parsePromptAttachments } from "../../shared/promptAttachments.js";
|
||||
import type { SavedPromptAttachment, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkFailure, SessionBulkMutationRef } from "../../shared/apiTypes.js";
|
||||
|
||||
import { cwdPathsEqual } from "../workingDirectory.js";
|
||||
import { canonicalizeStoredCwd, cwdPathsEqual } from "../workingDirectory.js";
|
||||
import type { WorkspaceActivityService } from "../activity/workspaceActivityService.js";
|
||||
import { createSpawnSessionToolDefinition, type SpawnSessionInvocation, type SpawnSessionResult } from "./spawnSessionTool.js";
|
||||
import { createSubsessionToolDefinitions, type SpawnSubsessionInvocation, type SpawnSubsessionResult, type SubsessionCheckResult, type SubsessionReadQuery, type SubsessionReadResult, type SubsessionStatus, type SubsessionSummary, type SubsessionToolDeps } from "./spawnSubsessionTool.js";
|
||||
@@ -261,6 +261,11 @@ export interface PiSessionRuntime {
|
||||
dispose(): Promise<void>;
|
||||
}
|
||||
|
||||
interface PendingSessionOpen {
|
||||
sessionId: string;
|
||||
promise: Promise<ActiveSession<PiSessionRuntime>>;
|
||||
}
|
||||
|
||||
interface CreateAgentRuntimeOptions {
|
||||
cwd: string;
|
||||
agentDir: string;
|
||||
@@ -404,6 +409,7 @@ export interface PiSessionServiceDependencies {
|
||||
|
||||
export class PiSessionService {
|
||||
private readonly active = new Map<string, ActiveSession<PiSessionRuntime>>();
|
||||
private readonly pendingSessionOpens = new Map<string, PendingSessionOpen>();
|
||||
private readonly activities = new Map<string, { phase: "active" | "idle" | "error"; label: string; detail?: string; at: string }>();
|
||||
private readonly heartbeat: NodeJS.Timeout;
|
||||
private readonly commandService: SessionCommandService<PiAgentSession>;
|
||||
@@ -533,8 +539,11 @@ export class PiSessionService {
|
||||
async dispose(): Promise<void> {
|
||||
clearInterval(this.heartbeat);
|
||||
this.clearCompactionDrainTimers();
|
||||
const pendingOpens = this.pendingSessionOpenPromises();
|
||||
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
|
||||
const activeSessions = Array.from(new Set(this.active.values()));
|
||||
this.active.clear();
|
||||
this.pendingSessionOpens.clear();
|
||||
this.activities.clear();
|
||||
this.compactionPromptQueues.clear();
|
||||
this.authLossWarnings.clear();
|
||||
@@ -546,8 +555,11 @@ export class PiSessionService {
|
||||
await Promise.all(activeSessions.map(async (active) => {
|
||||
active.unsubscribe();
|
||||
this.workspaceActivity?.removeSession(active.runtime.session.sessionId, active.runtime.session.sessionManager.getCwd());
|
||||
await active.runtime.session.abort();
|
||||
await active.runtime.dispose();
|
||||
try {
|
||||
await active.runtime.session.abort();
|
||||
} finally {
|
||||
await active.runtime.dispose();
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1540,6 +1552,8 @@ export class PiSessionService {
|
||||
}
|
||||
|
||||
private async closeActive(sessionId: string): Promise<void> {
|
||||
const pendingOpens = this.pendingSessionOpenPromises(sessionId);
|
||||
if (pendingOpens.length > 0) await Promise.allSettled(pendingOpens);
|
||||
const active = this.active.get(sessionId);
|
||||
if (!active) return;
|
||||
this.active.delete(sessionId);
|
||||
@@ -1573,13 +1587,49 @@ export class PiSessionService {
|
||||
if (active !== undefined) return active;
|
||||
|
||||
const archived = await this.getArchived(ref);
|
||||
if (archived?.archivePath !== undefined) return this.create(this.sessionManager.open(archived.archivePath), archived.cwd);
|
||||
if (archived?.archivePath !== undefined) {
|
||||
const { archivePath } = archived;
|
||||
return this.openExistingSession(
|
||||
archived.sessionId,
|
||||
archived.cwd,
|
||||
() => this.sessionManager.open(archivePath),
|
||||
);
|
||||
}
|
||||
|
||||
const match = isPiSessionRef(ref)
|
||||
? (await this.sessionManager.list(ref.cwd)).find((s) => s.id === ref.id || s.id.startsWith(ref.id))
|
||||
: (await this.sessionManager.listAll?.() ?? []).find((s) => s.id === ref || s.id.startsWith(ref));
|
||||
if (!match) throw new Error("Session not found");
|
||||
return this.create(this.sessionManager.open(match.path), match.cwd);
|
||||
return this.openExistingSession(match.id, match.cwd, () => this.sessionManager.open(match.path));
|
||||
}
|
||||
|
||||
private openExistingSession(
|
||||
sessionId: string,
|
||||
cwd: string,
|
||||
openSessionManager: () => PiSessionManager,
|
||||
): Promise<ActiveSession<PiSessionRuntime>> {
|
||||
const active = this.activeForLookup({ id: sessionId, cwd });
|
||||
if (active !== undefined) return Promise.resolve(active);
|
||||
|
||||
const key = JSON.stringify([canonicalizeStoredCwd(cwd), sessionId]);
|
||||
const existing = this.pendingSessionOpens.get(key);
|
||||
if (existing !== undefined) return existing.promise;
|
||||
|
||||
const pending: PendingSessionOpen = {
|
||||
sessionId,
|
||||
promise: this.create(openSessionManager(), cwd),
|
||||
};
|
||||
pending.promise = pending.promise.finally(() => {
|
||||
if (this.pendingSessionOpens.get(key) === pending) this.pendingSessionOpens.delete(key);
|
||||
});
|
||||
this.pendingSessionOpens.set(key, pending);
|
||||
return pending.promise;
|
||||
}
|
||||
|
||||
private pendingSessionOpenPromises(sessionId?: string): Promise<ActiveSession<PiSessionRuntime>>[] {
|
||||
return [...this.pendingSessionOpens.values()]
|
||||
.filter((pending) => sessionId === undefined || pending.sessionId === sessionId)
|
||||
.map((pending) => pending.promise);
|
||||
}
|
||||
|
||||
private async getArchived(ref: PiSessionLookup): Promise<ArchivedSessionRecord | undefined> {
|
||||
@@ -1613,18 +1663,40 @@ export class PiSessionService {
|
||||
delegationToolsEnabled,
|
||||
...(options.initialModel === undefined ? {} : { initialModel: options.initialModel }),
|
||||
});
|
||||
await this.bindSessionExtensions(runtime.session);
|
||||
const active: ActiveSession<PiSessionRuntime> = { runtime, unsubscribe: noop };
|
||||
this.bindRuntime(active);
|
||||
runtime.setRebindSession(async (session) => {
|
||||
await this.bindSessionExtensions(session);
|
||||
try {
|
||||
await this.bindSessionExtensions(runtime.session);
|
||||
this.bindRuntime(active);
|
||||
await this.recoverSubsessionTrackingForOpenedSession(session);
|
||||
});
|
||||
this.active.set(runtime.session.sessionId, active);
|
||||
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
|
||||
this.publishStatus(runtime.session);
|
||||
return active;
|
||||
runtime.setRebindSession(async (session) => {
|
||||
await this.bindSessionExtensions(session);
|
||||
this.bindRuntime(active);
|
||||
await this.recoverSubsessionTrackingForOpenedSession(session);
|
||||
});
|
||||
this.active.set(runtime.session.sessionId, active);
|
||||
await this.recoverSubsessionTrackingForOpenedSession(runtime.session);
|
||||
this.publishStatus(runtime.session);
|
||||
return active;
|
||||
} catch (error: unknown) {
|
||||
active.unsubscribe();
|
||||
let removedActive = false;
|
||||
for (const [sessionId, candidate] of this.active.entries()) {
|
||||
if (candidate !== active) continue;
|
||||
this.active.delete(sessionId);
|
||||
this.activities.delete(sessionId);
|
||||
this.clearAuthLossWarningsForSession(sessionId);
|
||||
this.clearCompactionPromptQueue(sessionId);
|
||||
removedActive = true;
|
||||
}
|
||||
if (removedActive) {
|
||||
this.workspaceActivity?.removeSession(runtime.session.sessionId, runtime.session.sessionManager.getCwd());
|
||||
}
|
||||
try {
|
||||
await runtime.session.abort();
|
||||
} finally {
|
||||
await runtime.dispose();
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async bindSessionExtensions(session: PiAgentSession): Promise<void> {
|
||||
|
||||
@@ -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 { SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
|
||||
import type { MessagePage, SessionBulkArchiveResponse, SessionBulkDeleteArchivedResponse, SessionBulkMutationRef, SessionCleanupExecuteResponse, SessionCleanupPreviewResponse } from "../../shared/apiTypes.js";
|
||||
import { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import { PiSessionService, type PiSessionManagerGateway, type PiSessionRef } from "./piSessionService.js";
|
||||
import { registerSessionRoutes } from "./sessionRoutes.js";
|
||||
@@ -55,6 +55,32 @@ describe("session routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("omits thinking signatures from browser history without mutating service messages", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
const eventHub = new SessionEventHub();
|
||||
const routeService = new CapturingRouteSessionService(eventHub);
|
||||
const thinkingBlock = { type: "thinking", thinking: "private chain", thinkingSignature: "opaque-provider-payload", redacted: true };
|
||||
const message = { role: "assistant", content: [thinkingBlock, { type: "text", text: "visible answer" }] };
|
||||
routeService.messagesResponse = { messages: [message], start: 0, total: 1 };
|
||||
registerSessionRoutes(routeApp, routeService, eventHub);
|
||||
|
||||
try {
|
||||
const response = await routeApp.inject({ method: "GET", url: "/sessions/session-1/messages?limit=20" });
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
messages: [{ role: "assistant", content: [{ type: "thinking", thinking: "private chain", redacted: true }, { type: "text", text: "visible answer" }] }],
|
||||
start: 0,
|
||||
total: 1,
|
||||
});
|
||||
expect(thinkingBlock.thinkingSignature).toBe("opaque-provider-payload");
|
||||
} finally {
|
||||
await routeService.dispose();
|
||||
await routeApp.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("forwards prompt attachments and supports the save-attachments route", async () => {
|
||||
const routeApp = Fastify({ logger: false });
|
||||
await routeApp.register(fastifyWebsocket);
|
||||
@@ -226,6 +252,7 @@ describe("session routes", () => {
|
||||
class CapturingRouteSessionService extends PiSessionService {
|
||||
readonly calls: unknown[] = [];
|
||||
readonly reloadCalls: (string | PiSessionRef)[] = [];
|
||||
messagesResponse: unknown[] | MessagePage = [];
|
||||
readonly cleanupPreviewCalls: NormalizedSessionCleanupRequest[] = [];
|
||||
readonly cleanupCalls: NormalizedSessionCleanupRequest[] = [];
|
||||
readonly bulkArchiveCalls: SessionBulkMutationRef[][] = [];
|
||||
@@ -262,6 +289,10 @@ class CapturingRouteSessionService extends PiSessionService {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
override messages(): Promise<unknown[] | MessagePage> {
|
||||
return Promise.resolve(this.messagesResponse);
|
||||
}
|
||||
|
||||
override status(lookup: string | PiSessionRef) {
|
||||
this.calls.push(lookup);
|
||||
return Promise.resolve({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { SessionBulkMutationRequest, SessionBulkMutationRef, SessionCleanupRequest } from "../../shared/apiTypes.js";
|
||||
import { projectBrowserMessageResponse } from "../browserMessageProjection.js";
|
||||
import { normalizeRequestCwd } from "../workingDirectory.js";
|
||||
import type { SessionEventHub } from "../realtime/sessionEventHub.js";
|
||||
import type { PiSessionRef, PiSessionService } from "./piSessionService.js";
|
||||
@@ -83,7 +84,8 @@ export function registerSessionRoutes(app: FastifyInstance, sessions: PiSessionS
|
||||
app.get<{ Params: { sessionId: string }; Querystring: MessageQuery }>(`${prefix}/sessions/:sessionId/messages`, async (request, reply) => {
|
||||
try {
|
||||
const page = { ...optionalField("before", optionalNumber(request.query.before)), ...optionalField("limit", optionalNumber(request.query.limit)) };
|
||||
return await sessions.messages(sessionLookupFromQuery(request.params.sessionId, request.query), page);
|
||||
const messages = await sessions.messages(sessionLookupFromQuery(request.params.sessionId, request.query), page);
|
||||
return projectBrowserMessageResponse(messages);
|
||||
} catch (error) {
|
||||
return reply.code(404).send({ error: errorMessage(error) });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user