perf(app): reduce startup websocket work
Make provider catalogs compact and content-addressed so repeat launches can reuse the persisted snapshot instead of transferring it again. Remove eager global catalog loading and the stopped-agent defensive tail fetch, while keeping sequence-gap and reconnect recovery as the correctness paths. Add an opt-in production-ID Android profiling build for local startup traces.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import type { AgentModelDefinition, ProviderSnapshotEntry } from "@getpaseo/protocol/agent-types";
|
||||
import { normalizeAgentModelDefinition } from "@getpaseo/protocol/agent-types";
|
||||
import { expandProviderSnapshot } from "@getpaseo/protocol/provider-snapshot-codec";
|
||||
import type {
|
||||
GetProvidersSnapshotResponseMessage,
|
||||
ListProviderModelsResponseMessage,
|
||||
@@ -59,7 +60,9 @@ export function normalizeListProviderModelsPayload(
|
||||
export function normalizeProvidersSnapshotPayload<
|
||||
T extends GetProvidersSnapshotPayload | ProvidersSnapshotUpdatePayload,
|
||||
>(payload: T): T {
|
||||
const entries = normalizeProviderSnapshotEntries(payload.entries);
|
||||
const entries = payload.compactSnapshot
|
||||
? expandProviderSnapshot(payload.compactSnapshot)
|
||||
: normalizeProviderSnapshotEntries(payload.entries);
|
||||
return entries === payload.entries ? payload : { ...payload, entries };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
||||
import { z } from "zod";
|
||||
import { DaemonClient, type DaemonTransport, type Logger } from "./daemon-client";
|
||||
import {
|
||||
DaemonClient,
|
||||
type DaemonClientTrace,
|
||||
type DaemonTransport,
|
||||
type Logger,
|
||||
} from "./daemon-client";
|
||||
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
||||
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
||||
import {
|
||||
@@ -34,6 +39,24 @@ function createMockLogger() {
|
||||
};
|
||||
}
|
||||
|
||||
interface TraceRecord {
|
||||
phase: "begin" | "end";
|
||||
name?: string;
|
||||
args?: Record<string, string>;
|
||||
}
|
||||
|
||||
function createTraceRecorder(): { trace: DaemonClientTrace; records: TraceRecord[] } {
|
||||
const records: TraceRecord[] = [];
|
||||
return {
|
||||
trace: {
|
||||
isEnabled: () => true,
|
||||
beginSection: (name, args) => records.push({ phase: "begin", name, args }),
|
||||
endSection: () => records.push({ phase: "end" }),
|
||||
},
|
||||
records,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockTransport() {
|
||||
const sent: Array<string | Uint8Array | ArrayBuffer> = [];
|
||||
|
||||
@@ -161,6 +184,56 @@ afterEach(async () => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
test("traces WebSocket frames, message types, and JSON parse duration", async () => {
|
||||
const mock = createMockTransport();
|
||||
const recorder = createTraceRecorder();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "trace_unit_test",
|
||||
transportFactory: () => mock.transport,
|
||||
reconnect: { enabled: false },
|
||||
trace: recorder.trace,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen({ preserveSent: true });
|
||||
await connectPromise;
|
||||
|
||||
expect(recorder.records).toEqual([
|
||||
{
|
||||
phase: "begin",
|
||||
name: "paseo.ws.message.outbound",
|
||||
args: { envelopeType: "hello", messageType: "hello" },
|
||||
},
|
||||
{ phase: "end" },
|
||||
{
|
||||
phase: "begin",
|
||||
name: "paseo.ws.frame.outbound",
|
||||
args: { kind: "text", size: expect.any(String) },
|
||||
},
|
||||
{ phase: "end" },
|
||||
{
|
||||
phase: "begin",
|
||||
name: "paseo.ws.frame.inbound",
|
||||
args: { kind: "text", size: expect.any(String) },
|
||||
},
|
||||
{
|
||||
phase: "begin",
|
||||
name: "paseo.ws.json.parse",
|
||||
args: { size: expect.any(String) },
|
||||
},
|
||||
{ phase: "end" },
|
||||
{
|
||||
phase: "begin",
|
||||
name: "paseo.ws.message.inbound",
|
||||
args: { envelopeType: "session", messageType: "status" },
|
||||
},
|
||||
{ phase: "end" },
|
||||
{ phase: "end" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("does not infer browser automation capabilities from Electron runtime", async () => {
|
||||
vi.stubGlobal("navigator", {
|
||||
userAgent:
|
||||
@@ -664,6 +737,7 @@ test("advertises client capabilities in hello", async () => {
|
||||
clientType: "cli",
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
compact_provider_snapshots: true,
|
||||
custom_mode_icons: true,
|
||||
project_updates: true,
|
||||
provider_subagents: true,
|
||||
@@ -4384,6 +4458,81 @@ test("lists available providers via RPC", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("requests provider snapshots conditionally and expands the compact response", async () => {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId: "clsk_provider_snapshot_test",
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
|
||||
const promise = client.getProvidersSnapshot({ cwd: "/repo", ifNoneMatch: "previous-hash" });
|
||||
const request = parseSentFrame(mock.sent[0]);
|
||||
expect(request).toMatchObject({
|
||||
type: "get_providers_snapshot_request",
|
||||
cwd: "/repo",
|
||||
ifNoneMatch: "previous-hash",
|
||||
});
|
||||
|
||||
mock.triggerMessage(
|
||||
wrapSessionMessage({
|
||||
type: "get_providers_snapshot_response",
|
||||
payload: {
|
||||
entries: [],
|
||||
compactSnapshot: {
|
||||
entries: [
|
||||
{
|
||||
provider: "pi",
|
||||
status: "ready",
|
||||
enabled: true,
|
||||
models: [
|
||||
{ id: "model-a", label: "Model A", thinkingSet: 0 },
|
||||
{ id: "model-b", label: "Model B", thinkingSet: 0 },
|
||||
],
|
||||
},
|
||||
],
|
||||
thinkingSets: [
|
||||
{
|
||||
options: [{ id: "high", label: "High", isDefault: true }],
|
||||
defaultOptionId: "high",
|
||||
},
|
||||
],
|
||||
},
|
||||
snapshotHash: "next-hash",
|
||||
generatedAt: "2026-08-04T00:00:00.000Z",
|
||||
requestId: request.requestId,
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const response = await promise;
|
||||
expect(response.entries[0]?.models).toEqual([
|
||||
{
|
||||
provider: "pi",
|
||||
id: "model-a",
|
||||
label: "Model A",
|
||||
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
|
||||
defaultThinkingOptionId: "high",
|
||||
},
|
||||
{
|
||||
provider: "pi",
|
||||
id: "model-b",
|
||||
label: "Model B",
|
||||
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
|
||||
defaultThinkingOptionId: "high",
|
||||
},
|
||||
]);
|
||||
expect(response.entries[0]?.models?.[0]?.thinkingOptions).toBe(
|
||||
response.entries[0]?.models?.[1]?.thinkingOptions,
|
||||
);
|
||||
});
|
||||
|
||||
test("lists commands with draft config via RPC", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -317,9 +317,16 @@ export interface DaemonClientConfig {
|
||||
};
|
||||
runtimeMetricsIntervalMs?: number;
|
||||
runtimeMetricsWindowMs?: number;
|
||||
trace?: DaemonClientTrace;
|
||||
capabilities?: Partial<Record<ClientCapability, unknown>>;
|
||||
}
|
||||
|
||||
export interface DaemonClientTrace {
|
||||
isEnabled(): boolean;
|
||||
beginSection(name: string, args?: Record<string, string>): void;
|
||||
endSection(): void;
|
||||
}
|
||||
|
||||
export interface SendMessageOptions {
|
||||
messageId?: string;
|
||||
images?: Array<{ data: string; mimeType: string }>;
|
||||
@@ -1035,6 +1042,26 @@ function concatByteChunks(chunks: Uint8Array[], size: number): Uint8Array {
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function getTransportFrameSize(frame: string | Uint8Array | ArrayBuffer): number {
|
||||
if (typeof frame === "string") {
|
||||
return frame.length;
|
||||
}
|
||||
return frame.byteLength;
|
||||
}
|
||||
|
||||
function describeInboundTransportFrame(
|
||||
frame: unknown,
|
||||
rawBytes: Uint8Array | null,
|
||||
): Record<string, string> {
|
||||
if (typeof frame === "string") {
|
||||
return { kind: "text", size: String(frame.length) };
|
||||
}
|
||||
if (rawBytes) {
|
||||
return { kind: "binary", size: String(rawBytes.byteLength) };
|
||||
}
|
||||
return { kind: "unknown", size: "0" };
|
||||
}
|
||||
|
||||
function hashForLog(value: string): string {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
@@ -1535,6 +1562,49 @@ export class DaemonClient {
|
||||
// Core Send Helpers
|
||||
// ============================================================================
|
||||
|
||||
private beginTraceSection(name: string, args?: Record<string, string>): boolean {
|
||||
const trace = this.config.trace;
|
||||
if (!trace?.isEnabled()) {
|
||||
return false;
|
||||
}
|
||||
trace.beginSection(name, args);
|
||||
return true;
|
||||
}
|
||||
|
||||
private endTraceSection(isOpen: boolean): void {
|
||||
if (isOpen) {
|
||||
this.config.trace?.endSection();
|
||||
}
|
||||
}
|
||||
|
||||
private traceInstant(name: string, args?: Record<string, string>): void {
|
||||
const isOpen = this.beginTraceSection(name, args);
|
||||
this.endTraceSection(isOpen);
|
||||
}
|
||||
|
||||
private sendJsonMessage(envelopeType: string, messageType: string, message: unknown): void {
|
||||
this.traceInstant("paseo.ws.message.outbound", {
|
||||
envelopeType,
|
||||
messageType,
|
||||
});
|
||||
this.sendTransportFrame(JSON.stringify(message));
|
||||
}
|
||||
|
||||
private sendTransportFrame(frame: string | Uint8Array | ArrayBuffer): void {
|
||||
if (!this.transport) {
|
||||
throw new Error("Transport not connected");
|
||||
}
|
||||
const isOpen = this.beginTraceSection("paseo.ws.frame.outbound", {
|
||||
kind: typeof frame === "string" ? "text" : "binary",
|
||||
size: String(getTransportFrameSize(frame)),
|
||||
});
|
||||
try {
|
||||
this.transport.send(frame);
|
||||
} finally {
|
||||
this.endTraceSection(isOpen);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a session message. For fire-and-forget messages (heartbeats, etc.),
|
||||
* failures are suppressed if `suppressSendErrors` is configured.
|
||||
@@ -1549,7 +1619,7 @@ export class DaemonClient {
|
||||
}
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
try {
|
||||
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
} catch (error) {
|
||||
if (this.config.suppressSendErrors) {
|
||||
return;
|
||||
@@ -1566,7 +1636,11 @@ export class DaemonClient {
|
||||
throw new Error(`Transport not connected (status: ${this.connectionState.status})`);
|
||||
}
|
||||
try {
|
||||
this.transport.send(frame);
|
||||
this.traceInstant("paseo.ws.message.outbound", {
|
||||
envelopeType: "binary",
|
||||
messageType: "binary",
|
||||
});
|
||||
this.sendTransportFrame(frame);
|
||||
} catch (error) {
|
||||
if (this.config.suppressSendErrors) {
|
||||
return;
|
||||
@@ -1587,7 +1661,7 @@ export class DaemonClient {
|
||||
// If connected, send immediately
|
||||
if (this.transport && status === "connected") {
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
@@ -1623,7 +1697,7 @@ export class DaemonClient {
|
||||
try {
|
||||
if (this.transport && this.connectionState.status === "connected") {
|
||||
const payload = SessionInboundMessageSchema.parse(pending.message);
|
||||
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
pending.resolve();
|
||||
} else {
|
||||
pending.reject(new Error("Connection lost before message could be sent"));
|
||||
@@ -1777,7 +1851,7 @@ export class DaemonClient {
|
||||
}
|
||||
const payload = SessionInboundMessageSchema.parse(message);
|
||||
try {
|
||||
this.transport.send(JSON.stringify({ type: "session", message: payload }));
|
||||
this.sendJsonMessage("session", payload.type, { type: "session", message: payload });
|
||||
} catch (error) {
|
||||
throw error instanceof Error ? error : new Error(String(error));
|
||||
}
|
||||
@@ -1952,7 +2026,7 @@ export class DaemonClient {
|
||||
this.pingProbe = probe;
|
||||
|
||||
try {
|
||||
this.transport.send(JSON.stringify({ type: "ping" }));
|
||||
this.sendJsonMessage("ping", "ping", { type: "ping" });
|
||||
} catch (error) {
|
||||
this.clearPingProbe();
|
||||
const sendError = error instanceof Error ? error : new Error(String(error));
|
||||
@@ -4421,6 +4495,7 @@ export class DaemonClient {
|
||||
|
||||
async getProvidersSnapshot(options?: {
|
||||
cwd?: string;
|
||||
ifNoneMatch?: string;
|
||||
requestId?: string;
|
||||
}): Promise<GetProvidersSnapshotPayload> {
|
||||
const payload = await this.sendCorrelatedSessionRequest({
|
||||
@@ -4428,6 +4503,7 @@ export class DaemonClient {
|
||||
message: {
|
||||
type: "get_providers_snapshot_request",
|
||||
cwd: options?.cwd,
|
||||
ifNoneMatch: options?.ifNoneMatch,
|
||||
},
|
||||
responseType: "get_providers_snapshot_response",
|
||||
});
|
||||
@@ -5326,23 +5402,22 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
try {
|
||||
this.transport.send(
|
||||
JSON.stringify({
|
||||
type: "hello",
|
||||
clientId: this.config.clientId,
|
||||
clientType: this.config.clientType ?? "cli",
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.customModeIcons]: true,
|
||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
||||
[CLIENT_CAPS.providerSubagents]: true,
|
||||
[CLIENT_CAPS.projectUpdates]: true,
|
||||
...this.config.capabilities,
|
||||
},
|
||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
||||
}),
|
||||
);
|
||||
this.sendJsonMessage("hello", "hello", {
|
||||
type: "hello",
|
||||
clientId: this.config.clientId,
|
||||
clientType: this.config.clientType ?? "cli",
|
||||
protocolVersion: 1,
|
||||
capabilities: {
|
||||
[CLIENT_CAPS.customModeIcons]: true,
|
||||
[CLIENT_CAPS.reasoningMergeEnum]: true,
|
||||
[CLIENT_CAPS.terminalReflowableSnapshot]: true,
|
||||
[CLIENT_CAPS.providerSubagents]: true,
|
||||
[CLIENT_CAPS.projectUpdates]: true,
|
||||
[CLIENT_CAPS.compactProviderSnapshots]: true,
|
||||
...this.config.capabilities,
|
||||
},
|
||||
...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Failed to send hello message";
|
||||
this.lastErrorValue = message;
|
||||
@@ -5413,24 +5488,37 @@ export class DaemonClient {
|
||||
}
|
||||
|
||||
const rawBytes = asUint8Array(rawData);
|
||||
if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
|
||||
return;
|
||||
const isOpen = this.beginTraceSection(
|
||||
"paseo.ws.frame.inbound",
|
||||
describeInboundTransportFrame(rawData, rawBytes),
|
||||
);
|
||||
try {
|
||||
if (rawBytes && this.tryHandleBinaryFrame(rawBytes)) {
|
||||
return;
|
||||
}
|
||||
const payload = decodeMessageData(rawData);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
this.handleJsonPayload(payload, rawBytes?.byteLength);
|
||||
} finally {
|
||||
this.endTraceSection(isOpen);
|
||||
}
|
||||
const payload = decodeMessageData(rawData);
|
||||
if (!payload) {
|
||||
return;
|
||||
}
|
||||
this.handleJsonPayload(payload, rawBytes?.byteLength);
|
||||
}
|
||||
|
||||
private handleJsonPayload(payload: string, rawBytesLength: number | undefined): void {
|
||||
const bytes = rawBytesLength ?? payload.length;
|
||||
const startMs = perfNow();
|
||||
let parsedJson: unknown;
|
||||
const parseTraceOpen = this.beginTraceSection("paseo.ws.json.parse", {
|
||||
size: String(bytes),
|
||||
});
|
||||
try {
|
||||
parsedJson = JSON.parse(payload);
|
||||
} catch {
|
||||
return;
|
||||
} finally {
|
||||
this.endTraceSection(parseTraceOpen);
|
||||
}
|
||||
|
||||
const parsed = validateWSOutboundMessage(parsedJson);
|
||||
@@ -5457,11 +5545,19 @@ export class DaemonClient {
|
||||
this.consecutiveLivenessFailures = 0;
|
||||
|
||||
if (parsed.data.type === "pong") {
|
||||
this.traceInstant("paseo.ws.message.inbound", {
|
||||
envelopeType: "pong",
|
||||
messageType: "pong",
|
||||
});
|
||||
this.resolvePingProbe();
|
||||
this.runtimeMetrics?.recordMessage("pong", bytes, perfNow() - startMs);
|
||||
return;
|
||||
}
|
||||
|
||||
this.traceInstant("paseo.ws.message.inbound", {
|
||||
envelopeType: "session",
|
||||
messageType: parsed.data.message.type,
|
||||
});
|
||||
this.handleSessionMessage(parsed.data.message);
|
||||
const msgType = parsed.data.message.type;
|
||||
this.runtimeMetrics?.recordMessage(msgType, bytes, perfNow() - startMs);
|
||||
@@ -5473,6 +5569,11 @@ export class DaemonClient {
|
||||
private tryHandleBinaryFrame(rawBytes: Uint8Array): boolean {
|
||||
const fileFrame = decodeFileTransferFrame(rawBytes);
|
||||
if (fileFrame) {
|
||||
this.traceInstant("paseo.ws.message.inbound", {
|
||||
envelopeType: "binary",
|
||||
messageType: "file",
|
||||
opcode: String(fileFrame.opcode),
|
||||
});
|
||||
this.consecutiveLivenessFailures = 0;
|
||||
this.handleFileTransferFrame(fileFrame);
|
||||
this.runtimeMetrics?.recordBinaryFrame("other", rawBytes.byteLength, 0);
|
||||
@@ -5483,6 +5584,11 @@ export class DaemonClient {
|
||||
if (!frame) {
|
||||
return false;
|
||||
}
|
||||
this.traceInstant("paseo.ws.message.inbound", {
|
||||
envelopeType: "binary",
|
||||
messageType: "terminal",
|
||||
opcode: String(frame.opcode),
|
||||
});
|
||||
this.consecutiveLivenessFailures = 0;
|
||||
const binaryStartMs = perfNow();
|
||||
this.terminalStreams.handleFrame(frame);
|
||||
|
||||
Reference in New Issue
Block a user