mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-25 10:42:45 +00:00
* feat(usage): stream reports for live agent accounts Resolve account scope from the running harness environment without invoking lifecycle hooks. Keep the client aggregate result while delivering report updates as each fetch settles; the app presentation follows separately. * fix(usage): retain account scope for open agent sessions Capture the launch environment once per session, independently of lazy child-process startup or exit. Use a session UUID for registry identity. Update remaining global discovery fixtures to the scoped SDK contract. * test(usage): update browser fixtures for scoped report streams * feat(usage): let a usage card show window rows without pins UsageCard takes a required pinnable prop. When false, a window row is a plain bar: no Pressable, hover highlight, or pin glyph. The settings Usage list passes true. * feat(usage): show the agent's usage in the context window popover Reports stream into the query cache as each update arrives, for the host list and for a new per-agent query keyed by agent. The context window popover fetches its agent's reports while open and renders one unpinnable card per report as it lands, a loading sentence before the first, the request error as a sentence, and nothing on hosts without usage reports. * feat(usage): hide the Refresh button on popover usage cards UsageCard takes a required refreshable prop. The popover passes false because its content takes no pointer events, so the button could not be pressed; the card then shows its freshness inline. The settings Usage list keeps the button. * feat(usage): make the sidebar Usage summary opt-in The sidebar Usage item starts hidden on every layout, not only on compact ones, so resolveSidebarNavItems drops its compact input. While the summary is off, the Usage list renders window rows without pins; saved pins stay in preferences and return when it is turned on. Specs that read the summary now turn it on first. * docs(glossary): describe the popover usage card and the opt-in sidebar summary * fix(usage): show a popover request error without retrying A daemon error for an agent's usage, such as an unknown agent, does not heal on retry, and reopening the popover fetches again. Retrying held the loading sentence for about seven seconds before the error. * test(usage): cover agent usage in the context window popover The usage fixture records agentId, streams reports held behind promises, and can switch host support between reloads. The meter spec walks loading, streaming, a report problem, a request error and a host without usage reports, on desktop and compact in both themes. * fix(usage): keep the plain context window tooltip on hosts without usage The popover widened to fit usage cards on every host. Hosts that cannot report usage now keep the tooltip's default padding and width, as before, and mount no usage section. * fix(usage): let a report's Refresh replace it in the agent's list too Refresh wrote the refreshed report only into the host's list, so a card rendered from an agent's list kept the old report. Both lists now sit under one host key and Refresh replaces the report in each that holds it. * feat(usage): open the context window details in a sheet on compact screens Wide screens keep the tooltip. On compact screens pressing the meter opens a content-sized bottom sheet titled Context window. Both render one ContextWindowDetails body; only the sheet's usage cards have Refresh, since the tooltip cannot be pressed. Pins stay off in both. * test(usage): cover the compact context window sheet On the compact viewport the meter opens the sheet; its cards have Refresh, a Refresh replaces the card, and Close dismisses the sheet. The glossary names the sheet. * feat(ui): give tooltips and the context window sheet accessible roles Tooltip content is role=tooltip, and the context window sheet is a dialog named Context window, so assistive tech and tests find them by role. The agent usage message drops its test ID; it is found by its text. * test(usage): reshape the context window and Usage switch specs as journeys The context window spec runs one tooltip journey and one sheet journey per theme, with no layout branches. Its fixture script, meter actions and report builders move to a context-window helper, and it finds the meter, tooltip, sheet and Refresh buttons by role. The theme comes from the system colour scheme. The Usage switch test uses named helpers for the Usage icon and the Summary in sidebar switch. * fix(usage): say an agent's usage request failed instead of showing older reports A failed request keeps whatever reports the query held: those from an earlier open, or those that streamed in before the failure. The context window details showed them as if they were the agent's current usage. The request error now wins over held reports. * test(usage): let a fixture clean up the context window spec's agent The tooltip and sheet tests no longer wrap their steps in try/finally; a spec-local fixture seeds the agent and removes it after the test. * fix(usage): keep a refreshed report when the list it is in finishes Pressing Refresh on one card while another source was still streaming put the pre-refresh report back: the finished list replaced the query's data with the copies the request had fetched when it started. A report now keeps whichever copy was fetched later, both when a streamed copy arrives and when the finished list replaces the streamed one. * fix(usage): show only the login a resumed agent runs under now An agent resumed under another login keeps its ID, so its usage query kept the previous login's reports: reopening the context window showed them while the new login's reports loaded, then both side by side until the request finished. The agent's usage is now dropped as soon as its tooltip or sheet closes, so each open loads the login the agent runs under at that moment. * fix(usage): stop a closed agent usage request from writing reports Closing the context window details cancels the agent's usage request and drops reports still on their way, so reopening after a resume under another login sends a new request instead of joining the old one.
7328 lines
196 KiB
TypeScript
7328 lines
196 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import {
|
|
DaemonClient,
|
|
supportsUsageReports,
|
|
type DaemonClientTrace,
|
|
type CreateAgentRequestOptions,
|
|
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 {
|
|
decodeFileTransferFrame,
|
|
encodeFileTransferFrame,
|
|
FileTransferOpcode,
|
|
} from "@getpaseo/protocol/binary-frames/index";
|
|
import {
|
|
encodeTerminalSnapshotPayload,
|
|
encodeTerminalStreamFrame,
|
|
TerminalStreamOpcode,
|
|
} from "@getpaseo/protocol/terminal-stream-protocol";
|
|
|
|
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
|
expectTypeOf<
|
|
"getHighlightedDiff" extends keyof DaemonClient ? true : false
|
|
>().toEqualTypeOf<false>();
|
|
expectTypeOf<
|
|
"exploreFileSystem" extends keyof DaemonClient ? true : false
|
|
>().toEqualTypeOf<false>();
|
|
|
|
function createMockLogger() {
|
|
return {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
}
|
|
|
|
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> = [];
|
|
|
|
let onMessage: (data: unknown) => void = () => {};
|
|
let onOpen: () => void = () => {};
|
|
let onClose: (_event?: unknown) => void = () => {};
|
|
let onError: (_event?: unknown) => void = () => {};
|
|
let serverInfoOrdinal = 1;
|
|
|
|
const transport: DaemonTransport = {
|
|
send: (data) => {
|
|
sent.push(data);
|
|
if (typeof data !== "string") {
|
|
return;
|
|
}
|
|
const frame = JSON.parse(data) as { type?: string };
|
|
if (frame.type === "ping") {
|
|
onMessage(JSON.stringify({ type: "pong" }));
|
|
}
|
|
},
|
|
close: () => {},
|
|
onMessage: (handler) => {
|
|
onMessage = (data) => handler(data, typeof data !== "string");
|
|
return () => {};
|
|
},
|
|
onOpen: (handler) => {
|
|
onOpen = handler;
|
|
return () => {};
|
|
},
|
|
onClose: (handler) => {
|
|
onClose = handler;
|
|
return () => {};
|
|
},
|
|
onError: (handler) => {
|
|
onError = handler;
|
|
return () => {};
|
|
},
|
|
};
|
|
|
|
return {
|
|
transport,
|
|
sent,
|
|
triggerOpen: (options?: { preserveSent?: boolean; features?: Record<string, boolean> }) => {
|
|
onOpen();
|
|
if (!options?.preserveSent) {
|
|
// Ignore HELLO handshake payloads in assertions.
|
|
sent.length = 0;
|
|
}
|
|
onMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "status",
|
|
payload: {
|
|
status: "server_info",
|
|
serverId: `srv_test_${serverInfoOrdinal++}`,
|
|
hostname: null,
|
|
version: null,
|
|
features: options?.features ?? { ownedSubscriptions: true },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
triggerClose: (event?: unknown) => onClose(event),
|
|
triggerError: (event?: unknown) => onError(event),
|
|
triggerMessage: (data: unknown) => onMessage(data),
|
|
};
|
|
}
|
|
|
|
function wrapSessionMessage(message: unknown): string {
|
|
return JSON.stringify({
|
|
type: "session",
|
|
message,
|
|
});
|
|
}
|
|
|
|
function assertStr(data: string | Uint8Array | ArrayBuffer | undefined): string {
|
|
if (typeof data !== "string") throw new Error("Expected string frame");
|
|
return data;
|
|
}
|
|
|
|
function assertUint8Array(data: string | Uint8Array | ArrayBuffer | undefined): Uint8Array {
|
|
if (data instanceof Uint8Array) return data;
|
|
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
throw new Error("Expected binary frame");
|
|
}
|
|
|
|
function parseSentFrame(
|
|
data: string | Uint8Array | ArrayBuffer | undefined,
|
|
): Record<string, unknown> {
|
|
return z
|
|
.object({
|
|
type: z.literal("session"),
|
|
message: z.record(z.string(), z.unknown()),
|
|
})
|
|
.parse(JSON.parse(assertStr(data))).message;
|
|
}
|
|
|
|
function respondToScheduleRequest(
|
|
mock: ReturnType<typeof createMockTransport>,
|
|
request: Record<string, unknown>,
|
|
): void {
|
|
const responseType =
|
|
request.type === "schedule/create" ? "schedule/create/response" : "schedule/update/response";
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: responseType,
|
|
payload: {
|
|
requestId: request.requestId,
|
|
schedule: null,
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const clients: DaemonClient[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(clients.map((client) => client.close()));
|
|
clients.length = 0;
|
|
vi.useRealTimers();
|
|
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:
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Paseo/0.1.89 Chrome/146 Electron/41.2.0 Safari/537.36",
|
|
});
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "electron_unit_test",
|
|
transportFactory: () => mock.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connectPromise;
|
|
|
|
const hello = z
|
|
.object({
|
|
type: z.literal("hello"),
|
|
capabilities: z.record(z.string(), z.unknown()),
|
|
})
|
|
.parse(JSON.parse(assertStr(mock.sent[0])));
|
|
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined();
|
|
expect(hello.capabilities[CLIENT_CAPS.selectiveAgentTimeline]).toBe(true);
|
|
});
|
|
|
|
test("advertises consumer-provided browser automation capabilities", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "browser_capability_unit_test",
|
|
transportFactory: () => mock.transport,
|
|
reconnect: { enabled: false },
|
|
capabilities: {
|
|
[CLIENT_CAPS.browserHost]: {
|
|
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
|
|
hostKind: "desktop app",
|
|
},
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connectPromise;
|
|
|
|
const hello = z
|
|
.object({
|
|
type: z.literal("hello"),
|
|
capabilities: z.record(z.string(), z.unknown()),
|
|
})
|
|
.parse(JSON.parse(assertStr(mock.sent[0])));
|
|
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toEqual({
|
|
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
|
|
hostKind: "desktop app",
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
{ receipts: false, structured: false },
|
|
{ receipts: true, structured: false },
|
|
{ receipts: false, structured: true },
|
|
{ receipts: true, structured: true },
|
|
])(
|
|
"legacy creation preserves the original payload with receipts=$receipts, structured=$structured",
|
|
async ({ receipts, structured }) => {
|
|
const transport = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "receipt-gate",
|
|
transportFactory: () => transport.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const connecting = client.connect();
|
|
transport.triggerOpen({ features: { agentRequestReceipts: receipts } });
|
|
await connecting;
|
|
const input: CreateAgentRequestOptions = {
|
|
config: {
|
|
provider: "codex",
|
|
cwd: "/project",
|
|
title: "Explicit title",
|
|
model: "gpt-5",
|
|
modeId: "full-access",
|
|
},
|
|
workspaceId: "workspace",
|
|
callerAgentId: "parent",
|
|
env: { CREATION_CONTEXT: "preserved" },
|
|
labels: { source: "test" },
|
|
idempotencyKey: "creation",
|
|
clientMessageId: "first-message",
|
|
initialPrompt: "Start this agent",
|
|
images: [{ data: "aGVsbG8=", mimeType: "image/png" }],
|
|
attachments: [
|
|
{
|
|
type: "github_pr",
|
|
mimeType: "application/github-pr",
|
|
number: 123,
|
|
title: "Review this PR",
|
|
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
},
|
|
],
|
|
...(structured ? { outputSchema: { type: "object" } } : {}),
|
|
};
|
|
const created = client.createAgent(input);
|
|
void created.catch(() => {});
|
|
const duplicate = client.createAgent(input);
|
|
void duplicate.catch(() => {});
|
|
expect(transport.sent).toHaveLength(1);
|
|
const request = parseSentFrame(transport.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "create_agent_request",
|
|
config: input.config,
|
|
workspaceId: input.workspaceId,
|
|
callerAgentId: input.callerAgentId,
|
|
env: input.env,
|
|
labels: input.labels,
|
|
clientMessageId: input.clientMessageId,
|
|
initialPrompt: input.initialPrompt,
|
|
images: input.images,
|
|
attachments: input.attachments,
|
|
...(structured ? { outputSchema: input.outputSchema } : {}),
|
|
});
|
|
expect(request).not.toHaveProperty("idempotencyKey");
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: request.requestId,
|
|
error: "Provider failed",
|
|
},
|
|
}),
|
|
);
|
|
await expect(created).rejects.toThrow("Provider failed");
|
|
await expect(duplicate).rejects.toThrow("Provider failed");
|
|
},
|
|
);
|
|
|
|
test.each([false, true])(
|
|
"legacy workspace creation preserves availability with receipt support=%s",
|
|
async (receipts) => {
|
|
const transport = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "legacy-workspace",
|
|
transportFactory: () => transport.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const connecting = client.connect();
|
|
transport.triggerOpen({ features: { workspaceRequestReceipts: receipts } });
|
|
await connecting;
|
|
const input = {
|
|
source: { kind: "directory" as const, path: "/project" },
|
|
idempotencyKey: "workspace",
|
|
};
|
|
const created = client.createWorkspace(input);
|
|
void created.catch(() => {});
|
|
const duplicate = client.createWorkspace(input);
|
|
void duplicate.catch(() => {});
|
|
expect(transport.sent).toHaveLength(1);
|
|
const request = parseSentFrame(transport.sent[0]);
|
|
expect(request).toMatchObject({ type: "workspace.create.request", source: input.source });
|
|
expect(request.idempotencyKey).toBe(receipts ? input.idempotencyKey : undefined);
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "workspace.create.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: "Directory unavailable",
|
|
setupTerminalId: null,
|
|
},
|
|
}),
|
|
);
|
|
await expect(created).resolves.toMatchObject({ error: "Directory unavailable" });
|
|
await expect(duplicate).resolves.toMatchObject({ error: "Directory unavailable" });
|
|
},
|
|
);
|
|
|
|
test.each(["agent", "workspace"] as const)(
|
|
"a lost legacy %s response is not automatically replayed on reconnect",
|
|
async (kind) => {
|
|
const transport = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "legacy-disconnect",
|
|
transportFactory: () => transport.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const connecting = client.connect();
|
|
transport.triggerOpen({ features: {} });
|
|
await connecting;
|
|
const creation =
|
|
kind === "agent"
|
|
? client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/project",
|
|
initialPrompt: "Start once",
|
|
idempotencyKey: "intent",
|
|
})
|
|
: client.createWorkspace({
|
|
source: { kind: "directory", path: "/project" },
|
|
idempotencyKey: "intent",
|
|
});
|
|
void creation.catch(() => {});
|
|
expect(transport.sent).toHaveLength(1);
|
|
transport.triggerClose({ code: 1006, reason: "Connection lost after dispatch" });
|
|
await expect(creation).rejects.toThrow();
|
|
const reconnecting = client.connect();
|
|
transport.triggerOpen({ features: {} });
|
|
await reconnecting;
|
|
expect(transport.sent).toEqual([]);
|
|
},
|
|
);
|
|
|
|
test("Hub management requires daemon support before dispatching requests", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "hub_feature_gate_unit_test",
|
|
transportFactory: () => mock.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const connecting = client.connect();
|
|
mock.triggerOpen();
|
|
await connecting;
|
|
|
|
await expect(client.getHubStatus()).rejects.toThrow(
|
|
"Update the host to use Hub relationship management.",
|
|
);
|
|
expect(mock.sent).toEqual([]);
|
|
});
|
|
|
|
test("timeline observation consumes broadcasts from a host without selective delivery", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "timeline_legacy",
|
|
transportFactory: () => mock.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const connecting = client.connect();
|
|
mock.triggerOpen({ features: {} });
|
|
await connecting;
|
|
const observation = client.observeTimeline(["agent"]);
|
|
await expect(observation.ready).resolves.toMatchObject({ agentIds: ["agent"] });
|
|
const updates: unknown[] = [];
|
|
observation.subscribe({ snapshot: () => {}, update: (message) => updates.push(message) });
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent_stream",
|
|
payload: {
|
|
agentId: "agent",
|
|
timestamp: "2026-09-11T00:00:00Z",
|
|
event: { type: "turn_completed", provider: "codex" },
|
|
},
|
|
}),
|
|
);
|
|
expect(updates).toEqual([expect.objectContaining({ type: "agent_stream" })]);
|
|
await observation.release();
|
|
expect(mock.sent).toEqual([]);
|
|
});
|
|
|
|
test("normalizes legacy and dedicated agent attention notifications", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "attention_normalization",
|
|
transportFactory: () => mock.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const connect = client.connect();
|
|
mock.triggerOpen();
|
|
await connect;
|
|
const notifications: unknown[] = [];
|
|
client.onAgentAttentionRequired((notification) => notifications.push(notification));
|
|
const payload = {
|
|
agentId: "agent-a",
|
|
reason: "finished",
|
|
timestamp: "2026-07-12T00:00:00.000Z",
|
|
shouldNotify: true,
|
|
} as const;
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent_stream",
|
|
payload: {
|
|
agentId: payload.agentId,
|
|
timestamp: payload.timestamp,
|
|
event: { type: "attention_required", provider: "codex", ...payload },
|
|
},
|
|
}),
|
|
);
|
|
mock.triggerMessage(wrapSessionMessage({ type: "agent_attention_required", payload }));
|
|
|
|
expect(notifications).toEqual([payload, payload]);
|
|
});
|
|
|
|
const noopLogger: Logger = {
|
|
debug: () => {},
|
|
info: () => {},
|
|
warn: () => {},
|
|
error: () => {},
|
|
};
|
|
|
|
type PongMode = { kind: "answer"; delayMs: number } | { kind: "silent" };
|
|
|
|
class FakeDaemon {
|
|
private onMessage: (data: unknown) => void = () => {};
|
|
private onOpen: () => void = () => {};
|
|
private onClose: (event?: unknown) => void = () => {};
|
|
private onError: (event?: unknown) => void = () => {};
|
|
private pongMode: PongMode = { kind: "answer", delayMs: 0 };
|
|
private withheldPongs = 0;
|
|
private pingsSentAt: number[] = [];
|
|
private closeEvents: Array<{ code?: number; reason?: string }> = [];
|
|
|
|
readonly transport: DaemonTransport = {
|
|
send: (data) => {
|
|
if (typeof data !== "string") {
|
|
return;
|
|
}
|
|
const frame = JSON.parse(data) as {
|
|
type?: string;
|
|
message?: { type?: string; requestId: string; clientSentAt: number };
|
|
};
|
|
const sessionPing =
|
|
frame.type === "session" && frame.message?.type === "ping" ? frame.message : null;
|
|
if (frame.type !== "ping" && !sessionPing) {
|
|
return;
|
|
}
|
|
this.pingsSentAt.push(performance.now());
|
|
if (this.withheldPongs > 0) {
|
|
this.withheldPongs -= 1;
|
|
return;
|
|
}
|
|
if (this.pongMode.kind === "silent") {
|
|
return;
|
|
}
|
|
const pong = sessionPing
|
|
? wrapSessionMessage({
|
|
type: "pong",
|
|
payload: { ...sessionPing, serverReceivedAt: Date.now(), serverSentAt: Date.now() },
|
|
})
|
|
: JSON.stringify({ type: "pong" });
|
|
if (this.pongMode.delayMs === 0) {
|
|
this.onMessage(pong);
|
|
return;
|
|
}
|
|
setTimeout(() => {
|
|
this.onMessage(pong);
|
|
}, this.pongMode.delayMs);
|
|
},
|
|
close: (code?: number, reason?: string) => {
|
|
this.closeEvents.push({ code, reason });
|
|
},
|
|
onMessage: (handler) => {
|
|
this.onMessage = (data) => handler(data, typeof data !== "string");
|
|
return () => {};
|
|
},
|
|
onOpen: (handler) => {
|
|
this.onOpen = handler;
|
|
return () => {};
|
|
},
|
|
onClose: (handler) => {
|
|
this.onClose = handler;
|
|
return () => {};
|
|
},
|
|
onError: (handler) => {
|
|
this.onError = handler;
|
|
return () => {};
|
|
},
|
|
};
|
|
|
|
openConnection(): void {
|
|
this.onOpen();
|
|
this.onMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "status",
|
|
payload: {
|
|
status: "server_info",
|
|
serverId: "srv_heartbeat_test",
|
|
hostname: null,
|
|
version: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
daemonAnswersPingsAfter(delay: "fast" | `${number}s`): void {
|
|
this.pongMode = {
|
|
kind: "answer",
|
|
delayMs: delay === "fast" ? 0 : Number.parseFloat(delay) * 1000,
|
|
};
|
|
}
|
|
|
|
daemonGoesSilent(): void {
|
|
this.pongMode = { kind: "silent" };
|
|
}
|
|
|
|
daemonWithholdsNextPongThenAnswersFast(): void {
|
|
this.withheldPongs += 1;
|
|
this.daemonAnswersPingsAfter("fast");
|
|
}
|
|
|
|
daemonClosesWith(reason: string): void {
|
|
this.onClose({ reason });
|
|
}
|
|
|
|
triggerError(event?: unknown): void {
|
|
this.onError(event);
|
|
}
|
|
|
|
pingTimestamps(): string[] {
|
|
return this.pingsSentAt.map((timestamp) => `${timestamp / 1000}s`);
|
|
}
|
|
|
|
teardownCount(): number {
|
|
return this.closeEvents.filter((event) => event.reason === "Liveness check timed out").length;
|
|
}
|
|
|
|
closesFromClient(): Array<{ code?: number; reason?: string }> {
|
|
return this.closeEvents;
|
|
}
|
|
}
|
|
|
|
class DaemonClientSession {
|
|
private readonly daemon = new FakeDaemon();
|
|
private readonly client: DaemonClient;
|
|
|
|
constructor() {
|
|
this.client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_heartbeat_test",
|
|
logger: noopLogger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => this.daemon.transport,
|
|
});
|
|
clients.push(this.client);
|
|
}
|
|
|
|
async connect(): Promise<void> {
|
|
const connection = this.client.connect();
|
|
this.daemon.openConnection();
|
|
await connection;
|
|
}
|
|
|
|
async advance(ms: number): Promise<void> {
|
|
await vi.advanceTimersByTimeAsync(ms);
|
|
}
|
|
|
|
daemonAnswersPingsAfter(delay: "fast" | `${number}s`): void {
|
|
this.daemon.daemonAnswersPingsAfter(delay);
|
|
}
|
|
|
|
daemonGoesSilent(): void {
|
|
this.daemon.daemonGoesSilent();
|
|
}
|
|
|
|
daemonWithholdsNextPongThenAnswersFast(): void {
|
|
this.daemon.daemonWithholdsNextPongThenAnswersFast();
|
|
}
|
|
|
|
daemonClosesWith(reason: string): void {
|
|
this.daemon.daemonClosesWith(reason);
|
|
}
|
|
|
|
pingTimestamps(): string[] {
|
|
return this.daemon.pingTimestamps();
|
|
}
|
|
|
|
state(): ReturnType<DaemonClient["getConnectionState"]> {
|
|
return this.client.getConnectionState();
|
|
}
|
|
|
|
lastError(): string | null {
|
|
return this.client.lastError;
|
|
}
|
|
|
|
teardownCount(): number {
|
|
return this.daemon.teardownCount();
|
|
}
|
|
|
|
closesFromClient(): Array<{ code?: number; reason?: string }> {
|
|
return this.daemon.closesFromClient();
|
|
}
|
|
|
|
lastLivenessRttMs(): number | null {
|
|
return this.client.getLastLivenessRttMs();
|
|
}
|
|
|
|
measureLatency(input: { timeoutMs: number }): Promise<number> {
|
|
return this.client.measureLatency(input);
|
|
}
|
|
}
|
|
|
|
function useHeartbeatClock(): void {
|
|
vi.useFakeTimers({
|
|
toFake: ["Date", "setTimeout", "clearTimeout", "setInterval", "clearInterval", "performance"],
|
|
});
|
|
}
|
|
|
|
test("dedupes in-flight checkout status requests per agentId", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const p1 = client.getCheckoutStatus("/tmp/project");
|
|
const p2 = client.getCheckoutStatus("/tmp/project");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
|
|
const response = {
|
|
type: "session",
|
|
message: {
|
|
type: "checkout_status_response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
error: null,
|
|
requestId: request.requestId,
|
|
isGit: false,
|
|
isPaseoOwnedWorktree: false,
|
|
repoRoot: null,
|
|
currentBranch: null,
|
|
isDirty: null,
|
|
baseRef: null,
|
|
aheadBehind: null,
|
|
aheadOfOrigin: null,
|
|
behindOfOrigin: null,
|
|
hasRemote: false,
|
|
remoteUrl: null,
|
|
},
|
|
},
|
|
};
|
|
|
|
mock.triggerMessage(JSON.stringify(response));
|
|
const [r1, r2] = await Promise.all([p1, p2]);
|
|
expect(r1).toMatchObject({
|
|
cwd: "/tmp/project",
|
|
requestId: request.requestId,
|
|
isGit: false,
|
|
});
|
|
expect(r2).toMatchObject({
|
|
cwd: "/tmp/project",
|
|
requestId: request.requestId,
|
|
isGit: false,
|
|
});
|
|
|
|
// After completion, a new call should issue a new request.
|
|
const p3 = client.getCheckoutStatus("/tmp/project");
|
|
expect(mock.sent).toHaveLength(2);
|
|
|
|
const request2 = parseSentFrame(mock.sent[1]);
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
...response,
|
|
message: {
|
|
...response.message,
|
|
payload: { ...response.message.payload, requestId: request2.requestId },
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(p3).resolves.toMatchObject({
|
|
cwd: "/tmp/project",
|
|
requestId: request2.requestId,
|
|
isGit: false,
|
|
});
|
|
});
|
|
|
|
test("passes password as HTTP bearer header and WebSocket subprotocol", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const transportFactory = vi.fn(() => mock.transport);
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
password: "shared-secret",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
expect(transportFactory).toHaveBeenCalledWith({
|
|
url: "ws://test",
|
|
headers: { Authorization: "Bearer shared-secret" },
|
|
protocols: ["paseo.bearer.shared-secret"],
|
|
});
|
|
});
|
|
|
|
test("keeps relay upgrade credentials out of the socket request", async () => {
|
|
const mock = createMockTransport();
|
|
const requests: Array<{ url: string; headers?: Record<string, string>; protocols?: string[] }> =
|
|
[];
|
|
const client = new DaemonClient({
|
|
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
|
|
clientId: "clsk_relay_auth_test",
|
|
password: "shared-secret",
|
|
authHeader: "Bearer shared-secret",
|
|
e2ee: { enabled: true, daemonPublicKeyB64: "daemon-public-key" },
|
|
reconnect: { enabled: false },
|
|
transportFactory: (request) => {
|
|
requests.push(request);
|
|
return mock.transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
void client.connect();
|
|
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
|
expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" });
|
|
});
|
|
|
|
test("refuses relay password auth without an encrypted hello", async () => {
|
|
const requests: unknown[] = [];
|
|
const client = new DaemonClient({
|
|
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
|
|
clientId: "clsk_unencrypted_relay_test",
|
|
password: "shared-secret",
|
|
connectTimeoutMs: 50,
|
|
reconnect: { enabled: false },
|
|
transportFactory: (request) => {
|
|
requests.push(request);
|
|
return createMockTransport().transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
await expect(client.connect()).rejects.toThrow("Relay credentials require E2EE");
|
|
expect(requests).toEqual([]);
|
|
});
|
|
|
|
test("stops reconnecting after a password rejection on an established connection", async () => {
|
|
const socket = createMockTransport();
|
|
let attempts = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_reconnect_auth_test",
|
|
password: "old-secret",
|
|
reconnect: { enabled: true, baseDelayMs: 1, maxDelayMs: 1 },
|
|
transportFactory: () => {
|
|
attempts += 1;
|
|
return socket.transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
socket.triggerOpen();
|
|
await connected;
|
|
socket.triggerMessage(JSON.stringify({ type: "hello.rejected", reason: "incorrect_password" }));
|
|
socket.triggerClose({ code: 4003, reason: "Incorrect password" });
|
|
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
expect(client.authFailureReason).toBe("incorrect_password");
|
|
expect(attempts).toBe(1);
|
|
});
|
|
|
|
test("sends a password containing spaces in hello without an invalid WebSocket subprotocol", async () => {
|
|
const mock = createMockTransport();
|
|
const transportFactory = vi.fn(() => mock.transport);
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
password: "two words",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory,
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connected;
|
|
expect(transportFactory).toHaveBeenCalledWith({
|
|
url: "ws://test",
|
|
headers: {},
|
|
});
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
|
|
type: "hello",
|
|
auth: { kind: "password", password: "two words" },
|
|
});
|
|
});
|
|
|
|
test("uses a local credential over a saved password when the desktop bridge provides one", async () => {
|
|
const mock = createMockTransport();
|
|
const transportFactory = vi.fn(() => mock.transport);
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "desktop-managed",
|
|
password: "stale-password",
|
|
localCredential: async () => "current-local-token",
|
|
reconnect: { enabled: false },
|
|
transportFactory,
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
await vi.waitFor(() => expect(transportFactory).toHaveBeenCalled());
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connected;
|
|
expect(transportFactory).toHaveBeenCalledWith({ url: "ws://test", headers: {} });
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
|
|
type: "hello",
|
|
auth: { kind: "localCredential", token: "current-local-token" },
|
|
});
|
|
});
|
|
|
|
test("uses the saved host password when the desktop bridge has no credential for the target", async () => {
|
|
const mock = createMockTransport();
|
|
const transportFactory = vi.fn(() => mock.transport);
|
|
const localCredential = vi.fn(async () => undefined);
|
|
const client = new DaemonClient({
|
|
url: "ws://remote-host",
|
|
clientId: "remote-saved-host",
|
|
password: "saved-password",
|
|
localCredential,
|
|
reconnect: { enabled: false },
|
|
transportFactory,
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
await vi.waitFor(() => expect(transportFactory).toHaveBeenCalled());
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connected;
|
|
expect(localCredential).toHaveBeenCalledOnce();
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toMatchObject({
|
|
type: "hello",
|
|
auth: { kind: "password", password: "saved-password" },
|
|
});
|
|
});
|
|
|
|
test("advertises client capabilities in hello", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
capabilities: {
|
|
browser_host: {
|
|
supportedCommands: ["list_tabs"],
|
|
hostKind: "desktop app",
|
|
},
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connectPromise;
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "hello",
|
|
clientId: "clsk_unit_test",
|
|
clientType: "cli",
|
|
protocolVersion: 1,
|
|
capabilities: {
|
|
all_providers: true,
|
|
selective_agent_timeline: true,
|
|
timeline_replacement_invalidation: true,
|
|
provider_snapshot_references: true,
|
|
explicit_event_subscriptions: true,
|
|
owned_subscriptions: true,
|
|
compact_provider_snapshots: true,
|
|
custom_mode_icons: true,
|
|
project_updates: true,
|
|
provider_subagents: true,
|
|
projected_subagent_timeline: true,
|
|
reasoning_merge_enum: true,
|
|
terminal_reflowable_snapshot: true,
|
|
timeline_notifications: true,
|
|
plugin_timeline_items: true,
|
|
workspace_setup_blocked: true,
|
|
hello_rejection: true,
|
|
browser_host: {
|
|
supportedCommands: ["list_tabs"],
|
|
hostKind: "desktop app",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test("allows callers to disable default client capabilities", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_capability_override_test",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
capabilities: {
|
|
[CLIENT_CAPS.projectUpdates]: false,
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connectPromise;
|
|
|
|
const hello = z
|
|
.object({
|
|
type: z.literal("hello"),
|
|
capabilities: z.record(z.string(), z.unknown()),
|
|
})
|
|
.parse(JSON.parse(assertStr(mock.sent[0])));
|
|
expect(hello.capabilities[CLIENT_CAPS.projectUpdates]).toBe(false);
|
|
});
|
|
|
|
test("sends new-agent run options when creating schedules", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.scheduleCreate({
|
|
requestId: "request-1",
|
|
prompt: "Run the task",
|
|
cadence: { type: "cron", expression: "* * * * *" },
|
|
target: {
|
|
type: "new-agent",
|
|
config: {
|
|
provider: "claude",
|
|
cwd: "/tmp/project",
|
|
thinkingOptionId: "think-hard",
|
|
archiveOnFinish: false,
|
|
isolation: "worktree",
|
|
},
|
|
},
|
|
});
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "schedule/create",
|
|
requestId: "request-1",
|
|
prompt: "Run the task",
|
|
cadence: { type: "cron", expression: "* * * * *" },
|
|
target: {
|
|
type: "new-agent",
|
|
config: {
|
|
provider: "claude",
|
|
cwd: "/tmp/project",
|
|
thinkingOptionId: "think-hard",
|
|
archiveOnFinish: false,
|
|
isolation: "worktree",
|
|
},
|
|
},
|
|
});
|
|
|
|
respondToScheduleRequest(mock, request);
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: "request-1",
|
|
schedule: null,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("sends new-agent run options when updating schedules", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const updatePromise = client.scheduleUpdate({
|
|
id: "schedule-1",
|
|
requestId: "request-1",
|
|
newAgentConfig: {
|
|
thinkingOptionId: "think-hard",
|
|
archiveOnFinish: false,
|
|
isolation: "worktree",
|
|
},
|
|
});
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "schedule/update",
|
|
requestId: "request-1",
|
|
scheduleId: "schedule-1",
|
|
newAgentConfig: {
|
|
thinkingOptionId: "think-hard",
|
|
archiveOnFinish: false,
|
|
isolation: "worktree",
|
|
},
|
|
});
|
|
|
|
respondToScheduleRequest(mock, request);
|
|
await expect(updatePromise).resolves.toEqual({
|
|
requestId: "request-1",
|
|
schedule: null,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("sends typed browser automation execute responses", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
client.sendBrowserAutomationExecuteResponse({
|
|
type: "browser.automation.execute.response",
|
|
payload: {
|
|
requestId: "req-1",
|
|
ok: true,
|
|
result: { command: "list_tabs", tabs: [] },
|
|
},
|
|
});
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "browser.automation.execute.response",
|
|
payload: {
|
|
requestId: "req-1",
|
|
ok: true,
|
|
result: { command: "list_tabs", tabs: [] },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("does not reconnect after close when ensureConnected is called", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
|
|
await client.close();
|
|
expect(client.getConnectionState().status).toBe("disposed");
|
|
|
|
client.ensureConnected();
|
|
expect(client.getConnectionState().status).toBe("disposed");
|
|
});
|
|
|
|
test("ensureConnected reconnects immediately without leaving the scheduled retry armed", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const first = createMockTransport();
|
|
const second = createMockTransport();
|
|
const third = createMockTransport();
|
|
const transports = [first, second, third];
|
|
let transportIndex = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_immediate_reconnect",
|
|
reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 },
|
|
transportFactory: () => {
|
|
const transport = transports[transportIndex];
|
|
if (!transport) throw new Error("unexpected extra reconnect");
|
|
transportIndex += 1;
|
|
return transport.transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const initialConnect = client.connect();
|
|
first.triggerOpen();
|
|
await initialConnect;
|
|
first.triggerClose({ code: 1001, reason: "app resumed" });
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
|
|
client.ensureConnected();
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
expect(transportIndex).toBe(2);
|
|
|
|
second.triggerOpen();
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
await vi.advanceTimersByTimeAsync(1_500);
|
|
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
expect(transportIndex).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("connect during a scheduled retry reconnects immediately without leaving the retry armed", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const first = createMockTransport();
|
|
const second = createMockTransport();
|
|
const third = createMockTransport();
|
|
const transports = [first, second, third];
|
|
let transportIndex = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_explicit_reconnect",
|
|
reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 },
|
|
transportFactory: () => {
|
|
const transport = transports[transportIndex];
|
|
if (!transport) throw new Error("unexpected extra reconnect");
|
|
transportIndex += 1;
|
|
return transport.transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const initialConnect = client.connect();
|
|
first.triggerOpen();
|
|
await initialConnect;
|
|
first.triggerClose({ code: 1001, reason: "daemon restarted" });
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
|
|
const reconnect = client.connect();
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
expect(transportIndex).toBe(2);
|
|
|
|
second.triggerOpen();
|
|
await reconnect;
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
await vi.advanceTimersByTimeAsync(1_500);
|
|
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
expect(transportIndex).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("disabling reconnect cancels a pending retry until explicitly resumed", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const first = createMockTransport();
|
|
const second = createMockTransport();
|
|
const transports = [first, second];
|
|
let transportIndex = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_paused_reconnect",
|
|
reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 },
|
|
transportFactory: () => {
|
|
const transport = transports[transportIndex];
|
|
if (!transport) throw new Error("unexpected extra reconnect");
|
|
transportIndex += 1;
|
|
return transport.transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const initialConnect = client.connect();
|
|
first.triggerOpen();
|
|
await initialConnect;
|
|
first.triggerClose({ code: 1001, reason: "app backgrounded" });
|
|
|
|
client.setReconnectEnabled(false);
|
|
await vi.advanceTimersByTimeAsync(1_500);
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
expect(transportIndex).toBe(1);
|
|
|
|
client.setReconnectEnabled(true);
|
|
client.ensureConnected();
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
expect(transportIndex).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("keeps the transport connected when a session RPC ping times out", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
|
|
await expect(client.ping({ timeoutMs: 1 })).rejects.toThrow("Timeout waiting for message");
|
|
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
});
|
|
|
|
test("waits for the daemon to acknowledge push token revocation", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_push_revocation",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { pushTokenRevocation: true } });
|
|
await connectPromise;
|
|
|
|
const revocation = client.unregisterPushToken("ExponentPushToken[test-device]");
|
|
const request = parseSentFrame(mock.sent.at(-1));
|
|
expect(request).toMatchObject({
|
|
type: "push.unregister.request",
|
|
token: "ExponentPushToken[test-device]",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "push.unregister.response",
|
|
payload: { requestId: request.requestId },
|
|
}),
|
|
);
|
|
|
|
await revocation;
|
|
});
|
|
|
|
test("bounds the wait for push token revocation", async () => {
|
|
useHeartbeatClock();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_push_revocation_timeout",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { pushTokenRevocation: true } });
|
|
await connectPromise;
|
|
|
|
const revocation = client.unregisterPushToken("ExponentPushToken[test-device]");
|
|
const rejection = expect(revocation).rejects.toThrow("Timeout waiting for message (2000ms)");
|
|
await vi.advanceTimersByTimeAsync(1_999);
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await rejection;
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
});
|
|
|
|
test("defaults session RPC waiters to sixty seconds", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.fetchAgent({
|
|
agentId: "agent-1",
|
|
requestId: "req-agent-1",
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agent_request",
|
|
requestId: "req-agent-1",
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(59_999);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (60000ms)");
|
|
});
|
|
|
|
test("honors explicit fetchAgent timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.fetchAgent({
|
|
agentId: "agent-1",
|
|
requestId: "req-agent-1",
|
|
timeout: 5_000,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agent_request",
|
|
requestId: "req-agent-1",
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(4_999);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (5000ms)");
|
|
});
|
|
|
|
test("preserves legacy fetchAgent id overload", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.fetchAgent("agent-1", "req-agent-legacy");
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agent_request",
|
|
requestId: "req-agent-legacy",
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fetch_agent_response",
|
|
payload: {
|
|
requestId: "req-agent-legacy",
|
|
agent: null,
|
|
project: null,
|
|
error: "legacy fetch sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(responsePromise).rejects.toThrow("legacy fetch sentinel");
|
|
});
|
|
|
|
test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.fetchAgentTimeline("agent-1", {
|
|
requestId: "req-timeline-1",
|
|
direction: "tail",
|
|
limit: 0,
|
|
projection: "projected",
|
|
timeout: 2_000,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agent_timeline_request",
|
|
requestId: "req-timeline-1",
|
|
agentId: "agent-1",
|
|
direction: "tail",
|
|
limit: 0,
|
|
projection: "projected",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_999);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2000ms)");
|
|
});
|
|
|
|
test("lists the full agent prompt index", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.listAgentTimelinePrompts("agent-1", {
|
|
requestId: "req-prompts-1",
|
|
});
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "agent.timeline.list_prompts.request",
|
|
requestId: "req-prompts-1",
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent.timeline.list_prompts.response",
|
|
payload: {
|
|
requestId: "req-prompts-1",
|
|
agentId: "agent-1",
|
|
epoch: "epoch-1",
|
|
prompts: [{ seq: 1, timestamp: "2026-01-01T00:00:00.000Z", preview: "First prompt" }],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(responsePromise).resolves.toMatchObject({
|
|
epoch: "epoch-1",
|
|
prompts: [{ seq: 1, preview: "First prompt" }],
|
|
});
|
|
});
|
|
|
|
test("honors explicit fetchAgents timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.fetchAgents({
|
|
requestId: "req-agents-1",
|
|
scope: "active",
|
|
timeout: 1_200,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agents_request",
|
|
requestId: "req-agents-1",
|
|
scope: "active",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_199);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1200ms)");
|
|
});
|
|
|
|
test("honors explicit shutdownServer timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.shutdownServer({
|
|
requestId: "req-shutdown-1",
|
|
timeout: 1_500,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "shutdown_server_request",
|
|
requestId: "req-shutdown-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_499);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
|
|
});
|
|
|
|
test("honors explicit getDaemonStatus timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { daemonStatusRpc: true } });
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.getDaemonStatus({
|
|
requestId: "req-daemon-status-1",
|
|
timeout: 1_500,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "daemon.get_status.request",
|
|
requestId: "req-daemon-status-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_499);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
|
|
});
|
|
|
|
test("honors explicit getDaemonPairingOffer timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.getDaemonPairingOffer({
|
|
requestId: "req-pairing-offer-1",
|
|
timeout: 1_500,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "daemon.get_pairing_offer.request",
|
|
requestId: "req-pairing-offer-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_499);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
|
|
});
|
|
|
|
test("gates config reload on the daemon capability", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
await expect(client.reloadDaemonConfig("reload-old-host")).rejects.toThrow(
|
|
"Update the host to reload daemon configuration.",
|
|
);
|
|
expect(mock.sent).toEqual([]);
|
|
});
|
|
|
|
test("sends and parses daemon config reload", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { daemonConfigReload: true } });
|
|
await connectPromise;
|
|
|
|
const response = client.reloadDaemonConfig("reload-new-host");
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "daemon.config.reload.request",
|
|
requestId: "reload-new-host",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "daemon.config.reload.response",
|
|
payload: {
|
|
requestId: "reload-new-host",
|
|
appliedPaths: ["daemon.browserTools.enabled"],
|
|
restartRequiredPaths: ["daemon.listen"],
|
|
overrideControlledPaths: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(response).resolves.toEqual({
|
|
requestId: "reload-new-host",
|
|
appliedPaths: ["daemon.browserTools.enabled"],
|
|
restartRequiredPaths: ["daemon.listen"],
|
|
overrideControlledPaths: [],
|
|
});
|
|
});
|
|
|
|
test("gets a structured plugin log snapshot", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { pluginLogs: true } });
|
|
await connectPromise;
|
|
|
|
const response = client.getPluginLogs("example");
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({ type: "plugin.logs.get.request", pluginId: "example" });
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "plugin.logs.get.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
pluginId: "example",
|
|
entries: [
|
|
{
|
|
sequence: 3,
|
|
timestamp: "2026-08-16T12:00:00.000Z",
|
|
stream: "stdout",
|
|
message: "ready",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(response).resolves.toEqual([
|
|
{
|
|
sequence: 3,
|
|
timestamp: "2026-08-16T12:00:00.000Z",
|
|
stream: "stdout",
|
|
message: "ready",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const waitPromise = client.waitForAgentUpsert("agent-1", () => false, 5_000);
|
|
let settled = false;
|
|
void waitPromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agent_request",
|
|
requestId: expect.any(String),
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(4_999);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(waitPromise).rejects.toThrow("Timed out waiting for agent agent-1");
|
|
});
|
|
|
|
test("keeps default connect timeout shorter than session RPC waiters", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
let settled = false;
|
|
const pendingConnect = client.connect().then(
|
|
() => {
|
|
settled = true;
|
|
return { ok: true as const };
|
|
},
|
|
(error) => {
|
|
settled = true;
|
|
return { ok: false as const, error };
|
|
},
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(14_999);
|
|
expect(settled).toBe(false);
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
const result = await pendingConnect;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toBeInstanceOf(Error);
|
|
if (result.error instanceof Error) {
|
|
expect(result.error.message).toContain("Connection timed out");
|
|
}
|
|
}
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("foreground verification replaces a silently broken socket without waiting for heartbeats", async () => {
|
|
useHeartbeatClock();
|
|
const first = new FakeDaemon();
|
|
const second = new FakeDaemon();
|
|
first.daemonGoesSilent();
|
|
let attempts = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "foreground-recovery",
|
|
logger: noopLogger,
|
|
transportFactory: () => (++attempts === 1 ? first : second).transport,
|
|
});
|
|
clients.push(client);
|
|
const connection = client.connect();
|
|
first.openConnection();
|
|
await connection;
|
|
|
|
client.ensureConnected({ verify: true });
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(attempts).toBe(2);
|
|
second.openConnection();
|
|
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
|
});
|
|
|
|
test("foreground verification preserves a healthy socket and deduplicates simultaneous checks", async () => {
|
|
useHeartbeatClock();
|
|
const daemon = new FakeDaemon();
|
|
daemon.daemonAnswersPingsAfter("0.1s");
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "healthy-resume",
|
|
logger: noopLogger,
|
|
transportFactory: () => daemon.transport,
|
|
});
|
|
clients.push(client);
|
|
const connection = client.connect();
|
|
daemon.openConnection();
|
|
await connection;
|
|
client.ensureConnected({ verify: true });
|
|
client.ensureConnected({ verify: true });
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
|
expect(daemon.pingTimestamps()).toEqual(["0s"]);
|
|
expect(daemon.closesFromClient()).toEqual([]);
|
|
});
|
|
|
|
test("an obsolete foreground probe cannot close a replacement connection", async () => {
|
|
useHeartbeatClock();
|
|
const first = new FakeDaemon();
|
|
const second = new FakeDaemon();
|
|
first.daemonGoesSilent();
|
|
let attempts = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "resume-race",
|
|
logger: noopLogger,
|
|
transportFactory: () => (++attempts === 1 ? first : second).transport,
|
|
});
|
|
clients.push(client);
|
|
const connection = client.connect();
|
|
first.openConnection();
|
|
await connection;
|
|
client.ensureConnected({ verify: true });
|
|
first.daemonClosesWith("network changed");
|
|
client.ensureConnected();
|
|
second.openConnection();
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(attempts).toBe(2);
|
|
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
|
expect(second.closesFromClient()).toEqual([]);
|
|
});
|
|
|
|
test("stays online through ten minutes of pongs that arrive five seconds late", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("5.5s");
|
|
|
|
await session.connect();
|
|
await session.advance(10 * 60 * 1000);
|
|
|
|
expect(session.state()).toEqual({ status: "connected" });
|
|
expect(session.teardownCount()).toBe(0);
|
|
expect(session.lastError()).toBeNull();
|
|
expect(session.pingTimestamps().length).toBeGreaterThan(0);
|
|
expect(session.lastLivenessRttMs()).toBe(5500);
|
|
});
|
|
|
|
test("tears down and reports a liveness timeout after the daemon goes silent for two cycles", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonGoesSilent();
|
|
|
|
await session.connect();
|
|
await session.advance(51_000);
|
|
|
|
expect(session.state()).toEqual({
|
|
status: "disconnected",
|
|
reason: "Liveness check timed out (15000ms)",
|
|
});
|
|
expect(session.teardownCount()).toBe(1);
|
|
});
|
|
|
|
test("survives a single missed pong when answers resume", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonWithholdsNextPongThenAnswersFast();
|
|
|
|
await session.connect();
|
|
await session.advance(40_000);
|
|
|
|
expect(session.state()).toEqual({ status: "connected" });
|
|
expect(session.teardownCount()).toBe(0);
|
|
});
|
|
|
|
test("keeps the connection proven online from heartbeats alone when no other traffic flows", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("fast");
|
|
|
|
await session.connect();
|
|
await session.advance(35_000);
|
|
|
|
expect(session.state()).toEqual({ status: "connected" });
|
|
expect(session.pingTimestamps()).toEqual(["10s", "20s", "30s"]);
|
|
});
|
|
|
|
test("starts pinging one interval after connecting and holds the cadence", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("fast");
|
|
|
|
await session.connect();
|
|
await session.advance(9_999);
|
|
expect(session.pingTimestamps()).toEqual([]);
|
|
|
|
await session.advance(20_001);
|
|
expect(session.pingTimestamps()).toEqual(["10s", "20s", "30s"]);
|
|
});
|
|
|
|
test("stops pinging once the connection is gone", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("fast");
|
|
|
|
await session.connect();
|
|
await session.advance(10_000);
|
|
session.daemonClosesWith("daemon shutting down");
|
|
await session.advance(30_000);
|
|
|
|
expect(session.pingTimestamps()).toEqual(["10s"]);
|
|
});
|
|
|
|
test("sends only one ping while a slow pong is still outstanding", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("15.1s");
|
|
|
|
await session.connect();
|
|
await session.advance(20_000);
|
|
|
|
expect(session.pingTimestamps()).toEqual(["10s"]);
|
|
});
|
|
|
|
test("reports the round-trip time of the last successful heartbeat", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("2.3s");
|
|
|
|
await session.connect();
|
|
await session.advance(12_300);
|
|
|
|
expect(session.lastLivenessRttMs()).toBe(2300);
|
|
});
|
|
|
|
test("treats a pong just under the timeout as alive and just over as a miss", async () => {
|
|
useHeartbeatClock();
|
|
const alive = new DaemonClientSession();
|
|
alive.daemonAnswersPingsAfter("14.9s");
|
|
|
|
await alive.connect();
|
|
await alive.advance(24_900);
|
|
|
|
expect(alive.state()).toEqual({ status: "connected" });
|
|
|
|
const missed = new DaemonClientSession();
|
|
missed.daemonAnswersPingsAfter("15.1s");
|
|
|
|
await missed.connect();
|
|
await missed.advance(25_000);
|
|
|
|
expect(missed.state()).toEqual({ status: "connected" });
|
|
expect(missed.lastLivenessRttMs()).toBeNull();
|
|
});
|
|
|
|
test("goes red with the daemon's reason when the connection is closed", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
|
|
await session.connect();
|
|
session.daemonClosesWith("Control unresponsive");
|
|
|
|
expect(session.state()).toEqual({
|
|
status: "disconnected",
|
|
reason: "Control unresponsive",
|
|
});
|
|
expect(session.lastError()).toBe("Control unresponsive");
|
|
expect(session.closesFromClient()).toEqual([]);
|
|
});
|
|
|
|
test("two candidate latency timeouts do not tear down the live connection", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonGoesSilent();
|
|
|
|
await session.connect();
|
|
const firstMeasurement = session.measureLatency({ timeoutMs: 1000 });
|
|
const firstMeasurementError = firstMeasurement.then(
|
|
() => null,
|
|
(error) => error,
|
|
);
|
|
await session.advance(1000);
|
|
|
|
await expect(firstMeasurementError).resolves.toEqual(
|
|
expect.objectContaining({
|
|
message: "Latency measurement timed out (1000ms)",
|
|
}),
|
|
);
|
|
|
|
const secondMeasurement = session.measureLatency({ timeoutMs: 1000 });
|
|
const secondMeasurementError = secondMeasurement.then(
|
|
() => null,
|
|
(error) => error,
|
|
);
|
|
await session.advance(1000);
|
|
|
|
await expect(secondMeasurementError).resolves.toEqual(
|
|
expect.objectContaining({
|
|
message: "Latency measurement timed out (1000ms)",
|
|
}),
|
|
);
|
|
expect(session.state()).toEqual({ status: "connected" });
|
|
expect(session.teardownCount()).toBe(0);
|
|
expect(session.pingTimestamps()).toEqual(["0s", "1s"]);
|
|
});
|
|
|
|
test("a candidate measurement that times out under a heartbeat tick does not count toward teardown", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonGoesSilent();
|
|
|
|
await session.connect();
|
|
|
|
// A candidate measurement is still in flight when the +10s heartbeat tick lands,
|
|
// so the heartbeat shares the in-flight ping. Let the measurement time out.
|
|
await session.advance(9_000);
|
|
const measurement = session.measureLatency({ timeoutMs: 5_000 });
|
|
const measurementError = measurement.then(
|
|
() => null,
|
|
(error) => error,
|
|
);
|
|
await session.advance(5_500);
|
|
await expect(measurementError).resolves.toEqual(
|
|
expect.objectContaining({ message: "Latency measurement timed out (5000ms)" }),
|
|
);
|
|
|
|
// The measurement timeout must not have been recorded as a liveness failure: a
|
|
// single genuine heartbeat miss after it must still leave the connection up.
|
|
await session.advance(25_000);
|
|
|
|
expect(session.state()).toEqual({ status: "connected" });
|
|
expect(session.teardownCount()).toBe(0);
|
|
});
|
|
|
|
test("file context action RPCs correlate success and error responses", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_context_actions",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createFileEntry({
|
|
cwd: "/tmp/project",
|
|
parentPath: "src",
|
|
name: "new.ts",
|
|
kind: "file",
|
|
});
|
|
const createRequest = parseSentFrame(mock.sent.at(-1));
|
|
expect(createRequest).toEqual({
|
|
type: "fs.entry.create.request",
|
|
cwd: "/tmp/project",
|
|
parentPath: "src",
|
|
name: "new.ts",
|
|
kind: "file",
|
|
requestId: expect.any(String),
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fs.entry.create.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
parentPath: "src",
|
|
path: "src/new.ts",
|
|
success: true,
|
|
error: null,
|
|
requestId: createRequest.requestId,
|
|
},
|
|
}),
|
|
);
|
|
await expect(createPromise).resolves.toMatchObject({
|
|
path: "src/new.ts",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
|
|
const renamePromise = client.renameFileEntry({
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
name: "existing.ts",
|
|
});
|
|
const renameRequest = parseSentFrame(mock.sent.at(-1));
|
|
expect(renameRequest).toMatchObject({
|
|
type: "fs.entry.rename.request",
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
name: "existing.ts",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fs.entry.rename.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
renamedPath: null,
|
|
success: false,
|
|
error: '"existing.ts" already exists',
|
|
requestId: renameRequest.requestId,
|
|
},
|
|
}),
|
|
);
|
|
await expect(renamePromise).resolves.toMatchObject({
|
|
renamedPath: null,
|
|
success: false,
|
|
error: '"existing.ts" already exists',
|
|
});
|
|
|
|
const duplicatePromise = client.duplicateFileEntry({
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
});
|
|
const duplicateRequest = parseSentFrame(mock.sent.at(-1));
|
|
expect(duplicateRequest).toMatchObject({
|
|
type: "fs.entry.duplicate.request",
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fs.entry.duplicate.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
duplicatedPath: "src/new copy.ts",
|
|
success: true,
|
|
error: null,
|
|
requestId: duplicateRequest.requestId,
|
|
},
|
|
}),
|
|
);
|
|
await expect(duplicatePromise).resolves.toMatchObject({
|
|
duplicatedPath: "src/new copy.ts",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
|
|
const deletePromise = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/new.ts" });
|
|
const deleteRequest = parseSentFrame(mock.sent.at(-1));
|
|
expect(deleteRequest).toMatchObject({
|
|
type: "fs.entry.delete.request",
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fs.entry.delete.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
success: true,
|
|
error: null,
|
|
requestId: deleteRequest.requestId,
|
|
},
|
|
}),
|
|
);
|
|
await expect(deletePromise).resolves.toMatchObject({ success: true, error: null });
|
|
|
|
const discardPromise = client.checkoutDiscardChanges("/tmp/project", { paths: ["src"] });
|
|
const discardRequest = parseSentFrame(mock.sent.at(-1));
|
|
expect(discardRequest).toMatchObject({
|
|
type: "checkout.discard_changes.request",
|
|
cwd: "/tmp/project",
|
|
paths: ["src"],
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "checkout.discard_changes.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
success: false,
|
|
error: { code: "NOT_GIT_REPO", message: "Not a git repository" },
|
|
requestId: discardRequest.requestId,
|
|
},
|
|
}),
|
|
);
|
|
await expect(discardPromise).resolves.toMatchObject({
|
|
success: false,
|
|
error: { code: "NOT_GIT_REPO", message: "Not a git repository" },
|
|
});
|
|
});
|
|
|
|
test("sends plugin source identifiers unchanged for daemon-host resolution", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_plugin_source",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { pluginSourceInstallation: true } });
|
|
await connectPromise;
|
|
|
|
const installPromise = client.installPluginSource({
|
|
source: "owner/repository:plugins/review",
|
|
});
|
|
const request = parseSentFrame(mock.sent.at(-1));
|
|
expect(request).toEqual({
|
|
type: "plugin.source.install.request",
|
|
requestId: expect.any(String),
|
|
source: "owner/repository:plugins/review",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "plugin.source.install.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
plugin: {
|
|
id: "review",
|
|
path: "/plugins/review",
|
|
enabled: true,
|
|
status: "running",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(installPromise).resolves.toMatchObject({ id: "review", status: "running" });
|
|
});
|
|
|
|
test("a connection loss rejects an in-flight file context action", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_context_disconnect",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const pending = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/file.ts" });
|
|
mock.triggerClose({ code: 1006, reason: "network lost" });
|
|
|
|
await expect(pending).rejects.toThrow(/network lost|disconnected|closed/i);
|
|
});
|
|
|
|
test("listDirectory sends a list file explorer request and returns directory entries", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.listDirectory("/tmp/project", "src", "req-list");
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
cwd: "/tmp/project",
|
|
path: "src",
|
|
mode: "list",
|
|
requestId: "req-list",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "file_explorer_response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
path: "src",
|
|
mode: "list",
|
|
directory: {
|
|
path: "src",
|
|
entries: [
|
|
{
|
|
name: "index.ts",
|
|
path: "src/index.ts",
|
|
kind: "file",
|
|
size: 12,
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
],
|
|
},
|
|
file: null,
|
|
error: null,
|
|
requestId: "req-list",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(responsePromise).resolves.toEqual({
|
|
path: "src",
|
|
entries: [
|
|
{
|
|
name: "index.ts",
|
|
path: "src/index.ts",
|
|
kind: "file",
|
|
size: 12,
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("readFile hides legacy base64 behind bytes", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-file");
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
cwd: "/tmp/project",
|
|
path: "logo.png",
|
|
mode: "file",
|
|
acceptBinary: true,
|
|
requestId: "req-file",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "file_explorer_response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
path: "logo.png",
|
|
mode: "file",
|
|
directory: null,
|
|
file: {
|
|
path: "logo.png",
|
|
kind: "image",
|
|
encoding: "base64",
|
|
content: "aGVsbG8=",
|
|
mimeType: "image/png",
|
|
size: 5,
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
error: null,
|
|
requestId: "req-file",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await responsePromise;
|
|
expect(result).toMatchObject({
|
|
mime: "image/png",
|
|
size: 5,
|
|
path: "logo.png",
|
|
kind: "image",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
});
|
|
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
|
});
|
|
|
|
test("readFile resolves from binary file frames when the daemon supports them", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-binary");
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
cwd: "/tmp/project",
|
|
path: "logo.png",
|
|
mode: "file",
|
|
acceptBinary: true,
|
|
requestId: "req-binary",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileBegin,
|
|
requestId: "req-binary",
|
|
metadata: {
|
|
mime: "image/png",
|
|
size: 5,
|
|
encoding: "binary",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-binary",
|
|
payload: new TextEncoder().encode("hello"),
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileEnd,
|
|
requestId: "req-binary",
|
|
}),
|
|
);
|
|
|
|
const result = await responsePromise;
|
|
expect(result).toMatchObject({
|
|
mime: "image/png",
|
|
size: 5,
|
|
path: "logo.png",
|
|
kind: "image",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
});
|
|
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
|
});
|
|
|
|
test("readFile drops an old daemon's over-budget binary chunks and reports the refusal", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_file_budget_compat",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.readFile("/tmp/project", "large.txt", "req-budget", 10);
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
cwd: "/tmp/project",
|
|
path: "large.txt",
|
|
mode: "file",
|
|
acceptBinary: true,
|
|
maxBytes: 10,
|
|
requestId: "req-budget",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileBegin,
|
|
requestId: "req-budget",
|
|
metadata: {
|
|
mime: "text/plain",
|
|
size: 100,
|
|
encoding: "utf-8",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-budget",
|
|
payload: new Uint8Array(100),
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-budget" }),
|
|
);
|
|
|
|
await expect(responsePromise).rejects.toThrow("File is too large to display");
|
|
});
|
|
|
|
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.uploadFile({
|
|
fileName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
bytes: new TextEncoder().encode("hello world"),
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
requestId: "req-upload",
|
|
chunkSize: 5,
|
|
});
|
|
|
|
// Other tasks must run before a multi-chunk upload has queued all its bytes.
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const framesDuringUpload = mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame);
|
|
expect(framesDuringUpload.some((frame) => frame.opcode === FileTransferOpcode.FileEnd)).toBe(
|
|
false,
|
|
);
|
|
await vi.waitFor(() => {
|
|
const frames = mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame);
|
|
expect(frames.at(-1)?.opcode).toBe(FileTransferOpcode.FileEnd);
|
|
});
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "file.upload.request",
|
|
fileName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
size: 11,
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
requestId: "req-upload",
|
|
},
|
|
});
|
|
expect(mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame)).toEqual([
|
|
{
|
|
opcode: FileTransferOpcode.FileBegin,
|
|
requestId: "req-upload",
|
|
metadata: {
|
|
mime: "text/plain",
|
|
size: 11,
|
|
encoding: "binary",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
fileName: "notes.txt",
|
|
},
|
|
payload: new Uint8Array(),
|
|
},
|
|
{
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-upload",
|
|
payload: new TextEncoder().encode("hello"),
|
|
},
|
|
{
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-upload",
|
|
payload: new TextEncoder().encode(" worl"),
|
|
},
|
|
{
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-upload",
|
|
payload: new TextEncoder().encode("d"),
|
|
},
|
|
{
|
|
opcode: FileTransferOpcode.FileEnd,
|
|
requestId: "req-upload",
|
|
payload: new Uint8Array(),
|
|
},
|
|
]);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "file.upload.response",
|
|
payload: {
|
|
requestId: "req-upload",
|
|
file: {
|
|
type: "uploaded_file",
|
|
id: "upload_req-upload",
|
|
fileName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
size: 11,
|
|
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
|
|
},
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(responsePromise).resolves.toEqual({
|
|
requestId: "req-upload",
|
|
file: {
|
|
type: "uploaded_file",
|
|
id: "upload_req-upload",
|
|
fileName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
size: 11,
|
|
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
|
|
},
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const events: Array<Parameters<Parameters<typeof client.subscribe>[0]>[0]> = [];
|
|
client.subscribe((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "workspace_setup_progress",
|
|
payload: {
|
|
workspaceId: "ws-feature-a",
|
|
status: "running",
|
|
detail: {
|
|
type: "worktree_setup",
|
|
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
|
|
branchName: "feature-a",
|
|
log: "phase-one\n",
|
|
commands: [
|
|
{
|
|
index: 1,
|
|
command: "npm install",
|
|
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
|
log: "phase-one\n",
|
|
status: "running",
|
|
exitCode: null,
|
|
},
|
|
],
|
|
},
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(events).toContainEqual({
|
|
type: "workspace_setup_progress",
|
|
workspaceId: "ws-feature-a",
|
|
payload: {
|
|
workspaceId: "ws-feature-a",
|
|
status: "running",
|
|
detail: {
|
|
type: "worktree_setup",
|
|
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
|
|
branchName: "feature-a",
|
|
log: "phase-one\n",
|
|
commands: [
|
|
{
|
|
index: 1,
|
|
command: "npm install",
|
|
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
|
log: "phase-one\n",
|
|
status: "running",
|
|
exitCode: null,
|
|
},
|
|
],
|
|
},
|
|
error: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("sends create_agent_request with workspace and caller identity", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { agentRequestReceipts: true } });
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
idempotencyKey: "one-creation",
|
|
provider: "codex",
|
|
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
|
workspaceId: "ws-feature-a",
|
|
callerAgentId: "parent-agent",
|
|
title: "Compat agent",
|
|
modeId: "default",
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual(
|
|
expect.objectContaining({
|
|
type: "create_agent_request",
|
|
idempotencyKey: "one-creation",
|
|
workspaceId: "ws-feature-a",
|
|
callerAgentId: "parent-agent",
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: request.requestId,
|
|
error: "compat test sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("compat test sentinel");
|
|
});
|
|
|
|
test("sends worktree target and autoArchive in create_agent_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
worktree: {
|
|
mode: "branch-off",
|
|
newBranch: "agent-lifecycle-dispatch",
|
|
base: "main",
|
|
},
|
|
autoArchive: true,
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual(
|
|
expect.objectContaining({
|
|
type: "create_agent_request",
|
|
worktree: {
|
|
mode: "branch-off",
|
|
newBranch: "agent-lifecycle-dispatch",
|
|
base: "main",
|
|
},
|
|
autoArchive: true,
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: request.requestId,
|
|
error: "worktree auto archive sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
|
|
});
|
|
|
|
test("sends structured attachments with create_agent_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
initialPrompt: "Review this PR",
|
|
attachments: [
|
|
{
|
|
type: "github_pr",
|
|
mimeType: "application/github-pr",
|
|
number: 123,
|
|
title: "Fix race in worktree setup",
|
|
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
baseRefName: "main",
|
|
headRefName: "fix/worktree-race",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.attachments).toEqual([
|
|
{
|
|
type: "github_pr",
|
|
mimeType: "application/github-pr",
|
|
number: 123,
|
|
title: "Fix race in worktree setup",
|
|
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
baseRefName: "main",
|
|
headRefName: "fix/worktree-race",
|
|
},
|
|
]);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: request.requestId,
|
|
error: "attachment test sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("attachment test sentinel");
|
|
});
|
|
|
|
test("sends worktree base-ref fields in create_agent_request git options", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
requestId: "req-agent-ref",
|
|
git: {
|
|
createWorktree: true,
|
|
worktreeSlug: "review-pr-123",
|
|
refName: "feature/worktree-base-ref",
|
|
action: "checkout",
|
|
githubPrNumber: 123,
|
|
},
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.git).toEqual({
|
|
createWorktree: true,
|
|
worktreeSlug: "review-pr-123",
|
|
refName: "feature/worktree-base-ref",
|
|
action: "checkout",
|
|
githubPrNumber: 123,
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: request.requestId,
|
|
error: "git ref fields sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("git ref fields sentinel");
|
|
});
|
|
|
|
test("omitting create_agent_request worktree base-ref fields preserves legacy wire shape", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
requestId: "req-agent-legacy",
|
|
git: {
|
|
createWorktree: true,
|
|
worktreeSlug: "feature-a",
|
|
},
|
|
});
|
|
|
|
expect(assertStr(mock.sent[0])).toBe(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "create_agent_request",
|
|
config: {
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
},
|
|
git: {
|
|
createWorktree: true,
|
|
worktreeSlug: "feature-a",
|
|
},
|
|
labels: {},
|
|
requestId: "req-agent-legacy",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: "req-agent-legacy",
|
|
error: "legacy git shape sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("legacy git shape sentinel");
|
|
});
|
|
|
|
test("sends structured first-agent context attachments with create_paseo_worktree_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createPaseoWorktree({
|
|
cwd: "/tmp/project",
|
|
worktreeSlug: "review-pr-123",
|
|
firstAgentContext: {
|
|
attachments: [
|
|
{
|
|
type: "github_pr",
|
|
mimeType: "application/github-pr",
|
|
number: 123,
|
|
title: "Fix race in worktree setup",
|
|
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
const firstAgentContext = z
|
|
.object({ attachments: z.array(z.unknown()) })
|
|
.parse(request.firstAgentContext);
|
|
expect(firstAgentContext.attachments).toEqual([
|
|
{
|
|
type: "github_pr",
|
|
mimeType: "application/github-pr",
|
|
number: 123,
|
|
title: "Fix race in worktree setup",
|
|
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
},
|
|
]);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "create_paseo_worktree_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: "worktree attachment sentinel",
|
|
setupTerminalId: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: "worktree attachment sentinel",
|
|
setupTerminalId: null,
|
|
});
|
|
});
|
|
|
|
test("sends project.add.request without creating a workspace", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const addPromise = client.addProject("/tmp/project", "req-add-project");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "project.add.request",
|
|
requestId: "req-add-project",
|
|
cwd: "/tmp/project",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "project.add.response",
|
|
payload: {
|
|
requestId: "req-add-project",
|
|
project: {
|
|
projectId: "/tmp/project",
|
|
projectDisplayName: "project",
|
|
projectCustomName: null,
|
|
projectRootPath: "/tmp/project",
|
|
projectKind: "git",
|
|
},
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(addPromise).resolves.toEqual({
|
|
requestId: "req-add-project",
|
|
project: {
|
|
projectId: "/tmp/project",
|
|
projectDisplayName: "project",
|
|
projectCustomName: null,
|
|
projectRootPath: "/tmp/project",
|
|
projectKind: "git",
|
|
},
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("marks a workspace unread through the dotted RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const markPromise = client.markWorkspaceUnread("workspace-1", "req-mark-unread");
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "workspace.mark_unread.request",
|
|
workspaceId: "workspace-1",
|
|
requestId: "req-mark-unread",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "workspace.mark_unread.response",
|
|
payload: {
|
|
requestId: "req-mark-unread",
|
|
workspaceId: "workspace-1",
|
|
markedAgentId: "agent-1",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(markPromise).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("searches GitHub repositories through the dotted RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const searchPromise = client.searchGithubRepositories(
|
|
{ query: "paseo", limit: 10 },
|
|
"req-repositories",
|
|
);
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "workspace.github.search_repositories.request",
|
|
query: "paseo",
|
|
limit: 10,
|
|
requestId: "req-repositories",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "workspace.github.search_repositories.response",
|
|
payload: {
|
|
status: "success",
|
|
requestId: "req-repositories",
|
|
repositories: [
|
|
{
|
|
id: "R_paseo",
|
|
name: "paseo",
|
|
nameWithOwner: "getpaseo/paseo",
|
|
description: "Development environment in your pocket",
|
|
visibility: "public",
|
|
updatedAt: "2026-07-15T10:00:00Z",
|
|
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
|
},
|
|
],
|
|
available: true,
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(searchPromise).resolves.toEqual({
|
|
status: "success",
|
|
requestId: "req-repositories",
|
|
repositories: [
|
|
{
|
|
id: "R_paseo",
|
|
name: "paseo",
|
|
nameWithOwner: "getpaseo/paseo",
|
|
description: "Development environment in your pocket",
|
|
visibility: "public",
|
|
updatedAt: "2026-07-15T10:00:00Z",
|
|
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
|
},
|
|
],
|
|
available: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("creates and registers a project directory through the dotted RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createProjectDirectory(
|
|
{ parentPath: "/tmp/projects", name: "new-project" },
|
|
"req-create-project-directory",
|
|
);
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "project.create_directory.request",
|
|
parentPath: "/tmp/projects",
|
|
name: "new-project",
|
|
requestId: "req-create-project-directory",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "project.create_directory.response",
|
|
payload: {
|
|
requestId: "req-create-project-directory",
|
|
directoryPath: "/tmp/projects/new-project",
|
|
project: {
|
|
projectId: "directory:/tmp/projects/new-project",
|
|
projectDisplayName: "new-project",
|
|
projectCustomName: null,
|
|
projectRootPath: "/tmp/projects/new-project",
|
|
projectKind: "non_git",
|
|
},
|
|
error: null,
|
|
errorCode: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: "req-create-project-directory",
|
|
directoryPath: "/tmp/projects/new-project",
|
|
project: {
|
|
projectId: "directory:/tmp/projects/new-project",
|
|
projectDisplayName: "new-project",
|
|
projectCustomName: null,
|
|
projectRootPath: "/tmp/projects/new-project",
|
|
projectKind: "non_git",
|
|
},
|
|
error: null,
|
|
errorCode: null,
|
|
});
|
|
});
|
|
|
|
test("sends first-agent prompt context with workspace.create.request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createWorkspace(
|
|
{
|
|
source: {
|
|
kind: "directory",
|
|
path: "/tmp/project",
|
|
projectId: "local:/tmp/project",
|
|
},
|
|
firstAgentContext: {
|
|
prompt: "Fix login bug",
|
|
attachments: [],
|
|
},
|
|
},
|
|
"req-local-title",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "workspace.create.request",
|
|
requestId: "req-local-title",
|
|
source: {
|
|
kind: "directory",
|
|
path: "/tmp/project",
|
|
projectId: "local:/tmp/project",
|
|
},
|
|
firstAgentContext: {
|
|
prompt: "Fix login bug",
|
|
attachments: [],
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "workspace.create.response",
|
|
payload: {
|
|
requestId: "req-local-title",
|
|
workspace: null,
|
|
error: "local title sentinel",
|
|
setupTerminalId: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: "req-local-title",
|
|
workspace: null,
|
|
error: "local title sentinel",
|
|
setupTerminalId: null,
|
|
});
|
|
});
|
|
|
|
test("sends project.remove.request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const removePromise = client.removeProject("remote:github.com/acme/app", "req-remove-project");
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "project.remove.request",
|
|
requestId: "req-remove-project",
|
|
projectId: "remote:github.com/acme/app",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "project.remove.response",
|
|
payload: {
|
|
requestId: "req-remove-project",
|
|
projectId: "remote:github.com/acme/app",
|
|
accepted: true,
|
|
removedWorkspaceIds: ["ws-main"],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(removePromise).resolves.toEqual({ removedWorkspaceIds: ["ws-main"] });
|
|
});
|
|
|
|
test("sends worktree base-ref fields in create_paseo_worktree_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createPaseoWorktree(
|
|
{
|
|
cwd: "/tmp/project",
|
|
projectId: "remote:github.com/acme/project",
|
|
worktreeSlug: "review-pr-123",
|
|
refName: "feature/worktree-base-ref",
|
|
action: "checkout",
|
|
githubPrNumber: 123,
|
|
},
|
|
"req-worktree-ref",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "create_paseo_worktree_request",
|
|
cwd: "/tmp/project",
|
|
projectId: "remote:github.com/acme/project",
|
|
worktreeSlug: "review-pr-123",
|
|
refName: "feature/worktree-base-ref",
|
|
action: "checkout",
|
|
githubPrNumber: 123,
|
|
requestId: "req-worktree-ref",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "create_paseo_worktree_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: "worktree ref fields sentinel",
|
|
setupTerminalId: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: "worktree ref fields sentinel",
|
|
setupTerminalId: null,
|
|
});
|
|
});
|
|
|
|
test("omitting create_paseo_worktree_request worktree base-ref fields preserves legacy wire shape", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createPaseoWorktree(
|
|
{
|
|
cwd: "/tmp/project",
|
|
worktreeSlug: "feature-a",
|
|
},
|
|
"req-worktree-legacy",
|
|
);
|
|
|
|
expect(assertStr(mock.sent[0])).toBe(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "create_paseo_worktree_request",
|
|
cwd: "/tmp/project",
|
|
worktreeSlug: "feature-a",
|
|
requestId: "req-worktree-legacy",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "create_paseo_worktree_response",
|
|
payload: {
|
|
requestId: "req-worktree-legacy",
|
|
workspace: null,
|
|
error: "legacy worktree shape sentinel",
|
|
setupTerminalId: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: "req-worktree-legacy",
|
|
workspace: null,
|
|
error: "legacy worktree shape sentinel",
|
|
setupTerminalId: null,
|
|
});
|
|
});
|
|
|
|
test("sends explicit shutdown_server_request via shutdownServer", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
expect(typeof client.shutdownServer).toBe("function");
|
|
const promise = client.shutdownServer({ requestId: "req-shutdown-1" });
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "shutdown_server_request",
|
|
requestId: "req-shutdown-1",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "shutdown_requested",
|
|
clientId: "clsk_unit_test",
|
|
requestId: "req-shutdown-1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
status: "shutdown_requested",
|
|
clientId: "clsk_unit_test",
|
|
requestId: "req-shutdown-1",
|
|
});
|
|
});
|
|
|
|
test("restartServer remains restart-only and sends restart_server_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.restartServer("settings_update", "req-restart-1");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "restart_server_request",
|
|
reason: "settings_update",
|
|
requestId: "req-restart-1",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "restart_requested",
|
|
clientId: "clsk_unit_test",
|
|
reason: "settings_update",
|
|
requestId: "req-restart-1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
status: "restart_requested",
|
|
clientId: "clsk_unit_test",
|
|
reason: "settings_update",
|
|
requestId: "req-restart-1",
|
|
});
|
|
});
|
|
|
|
test("transitions out of connecting when connect timeout elapses", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
connectTimeoutMs: 100,
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const pendingConnect = client.connect().then(
|
|
() => ({ ok: true as const }),
|
|
(error) => ({ ok: false as const, error }),
|
|
);
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
|
|
await vi.advanceTimersByTimeAsync(120);
|
|
const result = await pendingConnect;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toBeInstanceOf(Error);
|
|
if (result.error instanceof Error) {
|
|
expect(result.error.message).toContain("Connection timed out");
|
|
}
|
|
}
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("reconnects after relay close with replaced-by-new-connection reason", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const logger = createMockLogger();
|
|
const first = createMockTransport();
|
|
const second = createMockTransport();
|
|
const transports = [first, second];
|
|
let transportIndex = 0;
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
|
|
clientId: "clsk_test",
|
|
logger,
|
|
reconnect: {
|
|
enabled: true,
|
|
baseDelayMs: 5,
|
|
maxDelayMs: 5,
|
|
},
|
|
transportFactory: () => {
|
|
const next = transports[Math.min(transportIndex, transports.length - 1)];
|
|
transportIndex += 1;
|
|
return next.transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
first.triggerOpen();
|
|
await connectPromise;
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
|
|
first.triggerClose({ code: 1008, reason: "Replaced by new connection" });
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
|
|
second.triggerOpen();
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("requires non-empty clientId", () => {
|
|
expect(() => {
|
|
const _client = new DaemonClient({
|
|
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
|
|
clientId: "",
|
|
reconnect: { enabled: false },
|
|
});
|
|
void _client;
|
|
}).toThrow("Daemon client requires a non-empty clientId");
|
|
});
|
|
|
|
test("requires non-empty clientId for direct connections", () => {
|
|
expect(() => {
|
|
const _client = new DaemonClient({
|
|
url: "ws://127.0.0.1:6767/ws",
|
|
clientId: " ",
|
|
reconnect: { enabled: false },
|
|
});
|
|
void _client;
|
|
}).toThrow("Daemon client requires a non-empty clientId");
|
|
});
|
|
|
|
test("logs configured runtime generation in connection transition events", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
runtimeGeneration: 7,
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const transitionPayloads = logger.debug.mock.calls.filter(
|
|
([, message]) => message === "DaemonClientTransition",
|
|
);
|
|
expect(transitionPayloads.length).toBeGreaterThan(0);
|
|
for (const [payload] of transitionPayloads) {
|
|
expect(
|
|
z.object({ generation: z.number().nullable().optional() }).parse(payload).generation,
|
|
).toBe(7);
|
|
}
|
|
});
|
|
|
|
test("subscribes to checkout diff updates via RPC handshake", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const subscription = client.observeCheckoutDiff("/tmp/project", { mode: "uncommitted" });
|
|
const promise = subscription.ready;
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("subscribe_checkout_diff_request");
|
|
expect(request.subscriptionId).toBeUndefined();
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.compare).toEqual({ mode: "uncommitted" });
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "subscribe_checkout_diff_response",
|
|
payload: {
|
|
subscriptionId: "checkout-sub-1",
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
requestId: request.requestId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
subscriptionId: "checkout-sub-1",
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
requestId: request.requestId,
|
|
});
|
|
});
|
|
|
|
test("getCheckoutDiff reads a snapshot without creating a subscription", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.getCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" });
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const subscribeRequest = parseSentFrame(mock.sent[0]);
|
|
expect(subscribeRequest.type).toBe("checkout.diff.get.request");
|
|
expect(subscribeRequest.cwd).toBe("/tmp/project");
|
|
expect(subscribeRequest.compare).toEqual({
|
|
mode: "base",
|
|
baseRef: "main",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.diff.get.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
requestId: subscribeRequest.requestId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
requestId: subscribeRequest.requestId,
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
});
|
|
|
|
test("requests branch suggestions via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.getBranchSuggestions(
|
|
{ cwd: "/tmp/project", query: "mai", limit: 5 },
|
|
"req-branches",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("branch_suggestions_request");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.query).toBe("mai");
|
|
expect(request.limit).toBe(5);
|
|
expect(request.requestId).toBe("req-branches");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "branch_suggestions_response",
|
|
payload: {
|
|
branches: ["main"],
|
|
error: null,
|
|
requestId: "req-branches",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
branches: ["main"],
|
|
error: null,
|
|
requestId: "req-branches",
|
|
});
|
|
});
|
|
|
|
test("reads project config via correlated RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.readProjectConfig("/repo/app", "read-project-config-1");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "read_project_config_request",
|
|
requestId: "read-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "read_project_config_response",
|
|
payload: {
|
|
requestId: "read-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
ok: true,
|
|
config: { worktree: { setup: "npm install" } },
|
|
revision: { mtimeMs: 10, size: 20 },
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: "read-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
ok: true,
|
|
config: { worktree: { setup: "npm install" } },
|
|
revision: { mtimeMs: 10, size: 20 },
|
|
});
|
|
});
|
|
|
|
test("writes project config via correlated RPC and returns inline failures", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.writeProjectConfig({
|
|
requestId: "write-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
config: { worktree: { setup: ["npm install"] } },
|
|
expectedRevision: { mtimeMs: 10, size: 20 },
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "write_project_config_request",
|
|
requestId: "write-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
config: { worktree: { setup: ["npm install"] } },
|
|
expectedRevision: { mtimeMs: 10, size: 20 },
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "write_project_config_response",
|
|
payload: {
|
|
requestId: "write-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
ok: false,
|
|
error: {
|
|
code: "stale_project_config",
|
|
currentRevision: { mtimeMs: 11, size: 21 },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: "write-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
ok: false,
|
|
error: {
|
|
code: "stale_project_config",
|
|
currentRevision: { mtimeMs: 11, size: 21 },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("requests directory suggestions via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.getDirectorySuggestions(
|
|
{
|
|
query: "proj",
|
|
limit: 10,
|
|
cwd: "/tmp/project",
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
matchMode: "suffix",
|
|
},
|
|
"req-directories",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("directory_suggestions_request");
|
|
expect(request.query).toBe("proj");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.includeFiles).toBe(true);
|
|
expect(request.includeDirectories).toBe(true);
|
|
expect(request.matchMode).toBe("suffix");
|
|
expect(request.limit).toBe(10);
|
|
expect(request.requestId).toBe("req-directories");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "directory_suggestions_response",
|
|
payload: {
|
|
directories: ["/Users/test/projects/paseo"],
|
|
entries: [{ path: "README.md", kind: "file" }],
|
|
error: null,
|
|
requestId: "req-directories",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
directories: ["/Users/test/projects/paseo"],
|
|
entries: [{ path: "README.md", kind: "file" }],
|
|
error: null,
|
|
requestId: "req-directories",
|
|
});
|
|
});
|
|
|
|
test("requests checkout merge from base via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.checkoutMergeFromBase(
|
|
"/tmp/project",
|
|
{ baseRef: "main", requireCleanTarget: true },
|
|
"req-merge-from-base",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("checkout_merge_from_base_request");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.baseRef).toBe("main");
|
|
expect(request.requireCleanTarget).toBe(true);
|
|
expect(request.requestId).toBe("req-merge-from-base");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout_merge_from_base_response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
requestId: "req-merge-from-base",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
requestId: "req-merge-from-base",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("requests GitHub auto-merge enable via namespaced RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.checkoutGithubSetAutoMerge(
|
|
"/tmp/project",
|
|
{ enabled: true, method: "squash" },
|
|
"req-enable-auto-merge",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("checkout.github.set_auto_merge.request");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.enabled).toBe(true);
|
|
expect(request.mergeMethod).toBe("squash");
|
|
expect(request.requestId).toBe("req-enable-auto-merge");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.github.set_auto_merge.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
enabled: true,
|
|
requestId: "req-enable-auto-merge",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
enabled: true,
|
|
requestId: "req-enable-auto-merge",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("requests GitHub auto-merge disable via namespaced RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.checkoutGithubSetAutoMerge(
|
|
"/tmp/project",
|
|
{ enabled: false },
|
|
"req-disable-auto-merge",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("checkout.github.set_auto_merge.request");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.enabled).toBe(false);
|
|
expect(request.mergeMethod).toBeUndefined();
|
|
expect(request.requestId).toBe("req-disable-auto-merge");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.github.set_auto_merge.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
enabled: false,
|
|
requestId: "req-disable-auto-merge",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
enabled: false,
|
|
requestId: "req-disable-auto-merge",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("requests GitHub check details via namespaced RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.checkoutGithubGetCheckDetails(
|
|
{
|
|
cwd: "/tmp/project",
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
checkRunId: 12345,
|
|
workflowRunId: 456,
|
|
},
|
|
"req-check-details",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "checkout.github.get_check_details.request",
|
|
cwd: "/tmp/project",
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
checkRunId: 12345,
|
|
workflowRunId: 456,
|
|
requestId: "req-check-details",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.github.get_check_details.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
requestId: "req-check-details",
|
|
success: true,
|
|
details: {
|
|
checkRunId: 12345,
|
|
workflowRunId: 456,
|
|
name: "server-tests",
|
|
status: "completed",
|
|
conclusion: "failure",
|
|
annotations: [],
|
|
failedJobs: [],
|
|
truncated: false,
|
|
},
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
requestId: "req-check-details",
|
|
success: true,
|
|
details: {
|
|
checkRunId: 12345,
|
|
workflowRunId: 456,
|
|
name: "server-tests",
|
|
status: "completed",
|
|
conclusion: "failure",
|
|
annotations: [],
|
|
failedJobs: [],
|
|
truncated: false,
|
|
},
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("requests checkout pull via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.checkoutPull("/tmp/project", "req-pull");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("checkout_pull_request");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.requestId).toBe("req-pull");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout_pull_response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
requestId: "req-pull",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
requestId: "req-pull",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("renames a branch via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.renameBranch({
|
|
cwd: "/tmp/project",
|
|
branch: "feature/new-name",
|
|
requestId: "req-rename-branch",
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = JSON.parse(assertStr(mock.sent[0])) as {
|
|
type: "session";
|
|
message: {
|
|
type: "checkout.rename_branch.request";
|
|
cwd: string;
|
|
branch: string;
|
|
requestId: string;
|
|
};
|
|
};
|
|
expect(request.message.type).toBe("checkout.rename_branch.request");
|
|
expect(request.message.cwd).toBe("/tmp/project");
|
|
expect(request.message.branch).toBe("feature/new-name");
|
|
expect(request.message.requestId).toBe("req-rename-branch");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.rename_branch.response",
|
|
payload: {
|
|
requestId: "req-rename-branch",
|
|
success: true,
|
|
cwd: "/tmp/project",
|
|
currentBranch: "feature/new-name",
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: "req-rename-branch",
|
|
success: true,
|
|
cwd: "/tmp/project",
|
|
currentBranch: "feature/new-name",
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("returns renameBranch business failures", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.renameBranch({
|
|
cwd: "/tmp/project",
|
|
branch: "already-exists",
|
|
requestId: "req-rename-branch-fail",
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.rename_branch.response",
|
|
payload: {
|
|
requestId: "req-rename-branch-fail",
|
|
success: false,
|
|
cwd: "/tmp/project",
|
|
currentBranch: null,
|
|
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: "req-rename-branch-fail",
|
|
success: false,
|
|
cwd: "/tmp/project",
|
|
currentBranch: null,
|
|
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
|
|
});
|
|
});
|
|
|
|
test("resubscribes checkout diff streams after reconnect", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const observation = client.observeCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" });
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
|
|
const first = parseSentFrame(mock.sent[0]);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_checkout_diff_response",
|
|
payload: {
|
|
requestId: first.requestId,
|
|
subscriptionId: "server-first",
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
await observation.ready;
|
|
mock.triggerClose();
|
|
const reconnect = client.connect();
|
|
mock.triggerOpen();
|
|
await reconnect;
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "subscribe_checkout_diff_request",
|
|
cwd: "/tmp/project",
|
|
compare: { mode: "base", baseRef: "main" },
|
|
});
|
|
expect(request.subscriptionId).toBeUndefined();
|
|
expect(request.requestId).not.toBe(first.requestId);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_checkout_diff_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
subscriptionId: "server-second",
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
await vi.waitFor(() => expect(observation.subscriptionId).toBe("server-second"));
|
|
});
|
|
|
|
test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.fetchAgents({
|
|
filter: { labels: { surface: "workspace" } },
|
|
sort: [
|
|
{ key: "status_priority", direction: "asc" },
|
|
{ key: "created_at", direction: "desc" },
|
|
],
|
|
page: { limit: 25, cursor: "cursor-1" },
|
|
subscribe: {},
|
|
});
|
|
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("fetch_agents_request");
|
|
expect(request.sort).toEqual([
|
|
{ key: "status_priority", direction: "asc" },
|
|
{ key: "created_at", direction: "desc" },
|
|
]);
|
|
expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
|
expect(request.subscribe).toEqual({});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "fetch_agents_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
subscriptionId: "sub-1",
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: "cursor-1",
|
|
hasMore: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: request.requestId,
|
|
subscriptionId: "sub-1",
|
|
subscription: expect.objectContaining({
|
|
subscriptionId: "sub-1",
|
|
release: expect.any(Function),
|
|
}),
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: "cursor-1",
|
|
hasMore: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("detaches an agent through the namespaced detach RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.detachAgent("child-agent");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "agent.detach.request",
|
|
agentId: "child-agent",
|
|
});
|
|
expect(typeof request.requestId).toBe("string");
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent.detach.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
agentId: "child-agent",
|
|
accepted: true,
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("sends active-scoped fetch_agents_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.fetchAgents({
|
|
scope: "active",
|
|
page: { limit: 50 },
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "fetch_agents_request",
|
|
scope: "active",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "fetch_agents_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: null,
|
|
hasMore: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toMatchObject({
|
|
requestId: request.requestId,
|
|
entries: [],
|
|
});
|
|
});
|
|
|
|
test("fetches paginated agent history separately from active agents", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.fetchAgentHistory({
|
|
page: { limit: 25, cursor: "cursor-1" },
|
|
sort: [{ key: "updated_at", direction: "desc" }],
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("fetch_agent_history_request");
|
|
expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "fetch_agent_history_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: "cursor-1",
|
|
hasMore: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: request.requestId,
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: "cursor-1",
|
|
hasMore: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("fetches scoped recent provider sessions", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.fetchRecentProviderSessions({
|
|
cwd: "/tmp/repo",
|
|
providers: ["my-claude"],
|
|
since: "2026-04-30T00:00:00.000Z",
|
|
limit: 25,
|
|
query: "invoice",
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = JSON.parse(String(mock.sent[0])) as {
|
|
type: "session";
|
|
message: {
|
|
type: "fetch_recent_provider_sessions_request";
|
|
requestId: string;
|
|
cwd?: string;
|
|
providers?: string[];
|
|
since?: string;
|
|
limit?: number;
|
|
query?: string;
|
|
};
|
|
};
|
|
expect(request.message).toMatchObject({
|
|
type: "fetch_recent_provider_sessions_request",
|
|
cwd: "/tmp/repo",
|
|
providers: ["my-claude"],
|
|
since: "2026-04-30T00:00:00.000Z",
|
|
limit: 25,
|
|
query: "invoice",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "fetch_recent_provider_sessions_response",
|
|
payload: {
|
|
requestId: request.message.requestId,
|
|
entries: [
|
|
{
|
|
providerId: "codex",
|
|
providerLabel: "Codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
title: "Import me",
|
|
firstPromptPreview: "first prompt",
|
|
lastPromptPreview: "last prompt",
|
|
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: request.message.requestId,
|
|
entries: [
|
|
{
|
|
providerId: "codex",
|
|
providerLabel: "Codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
title: "Import me",
|
|
firstPromptPreview: "first prompt",
|
|
lastPromptPreview: "last prompt",
|
|
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("imports an agent by provider handle id", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.importAgent({
|
|
providerId: "custom-codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = JSON.parse(String(mock.sent[0])) as {
|
|
type: "session";
|
|
message: {
|
|
type: "import_agent_request";
|
|
requestId: string;
|
|
providerId?: string;
|
|
providerHandleId?: string;
|
|
sessionId?: string;
|
|
cwd?: string;
|
|
};
|
|
};
|
|
expect(request.message).toMatchObject({
|
|
type: "import_agent_request",
|
|
providerId: "custom-codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
});
|
|
expect(request.message).not.toHaveProperty("sessionId");
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_resumed",
|
|
requestId: request.message.requestId,
|
|
agentId: "agent-1",
|
|
timelineSize: 0,
|
|
agent: {
|
|
id: "agent-1",
|
|
provider: "custom-codex",
|
|
cwd: "/tmp/repo",
|
|
model: null,
|
|
features: [],
|
|
thinkingOptionId: null,
|
|
effectiveThinkingOptionId: null,
|
|
createdAt: "2026-04-30T00:00:00.000Z",
|
|
updatedAt: "2026-04-30T00:00:00.000Z",
|
|
lastUserMessageAt: null,
|
|
status: "idle",
|
|
capabilities: {
|
|
supportsStreaming: false,
|
|
supportsSessionPersistence: false,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: false,
|
|
supportsReasoningStream: false,
|
|
supportsToolInvocations: false,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: {
|
|
provider: "custom-codex",
|
|
sessionId: "thread-1",
|
|
nativeHandle: "thread-1",
|
|
},
|
|
title: null,
|
|
labels: {},
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toMatchObject({
|
|
id: "agent-1",
|
|
provider: "custom-codex",
|
|
});
|
|
});
|
|
|
|
test("uses server-provided dictation finish timeout budget", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const finishPromise = client.finishDictationStream("dict-1", 0);
|
|
const finishError = finishPromise.then(
|
|
() => null,
|
|
(error) => error,
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "dictation_stream_finish_accepted",
|
|
payload: {
|
|
dictationId: "dict-1",
|
|
timeoutMs: 100,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(5_101);
|
|
const error = await finishError;
|
|
expect(error).toBeInstanceOf(Error);
|
|
if (error instanceof Error) {
|
|
expect(error.message).toContain("Timeout waiting for dictation finalization (5100ms)");
|
|
}
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test("resolves dictation finish when final arrives after finish accepted", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const finishPromise = client.finishDictationStream("dict-2", 1);
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "dictation_stream_finish_accepted",
|
|
payload: {
|
|
dictationId: "dict-2",
|
|
timeoutMs: 1000,
|
|
},
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "dictation_stream_final",
|
|
payload: {
|
|
dictationId: "dict-2",
|
|
text: "hello",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(finishPromise).resolves.toEqual({
|
|
dictationId: "dict-2",
|
|
text: "hello",
|
|
});
|
|
});
|
|
|
|
test("cancels waiters when send fails (no leaked timeouts)", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
let sendCount = 0;
|
|
|
|
const transportFactory = () => ({
|
|
...mock.transport,
|
|
send: () => {
|
|
sendCount += 1;
|
|
if (sendCount > 1) {
|
|
throw new Error("boom");
|
|
}
|
|
},
|
|
});
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.getCheckoutStatus("/tmp/project");
|
|
await expect(promise).rejects.toThrow("boom");
|
|
|
|
// Ensure we didn't leave a waiter behind that will reject later.
|
|
const internal = client as unknown as { waiters: Set<unknown> };
|
|
expect(internal.waiters.size).toBe(0);
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test("lists available providers via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.listAvailableProviders();
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("list_available_providers_request");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "list_available_providers_response",
|
|
payload: {
|
|
providers: [
|
|
{ provider: "claude", available: true, error: null },
|
|
{ provider: "codex", available: false, error: "Missing binary" },
|
|
],
|
|
error: null,
|
|
fetchedAt: "2026-02-12T00:00:00.000Z",
|
|
requestId: request.requestId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
providers: [
|
|
{ provider: "claude", available: true, error: null },
|
|
{ provider: "codex", available: false, error: "Missing binary" },
|
|
],
|
|
error: null,
|
|
fetchedAt: "2026-02-12T00:00:00.000Z",
|
|
requestId: request.requestId,
|
|
});
|
|
});
|
|
|
|
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();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.listCommands({
|
|
agentId: "__new_agent__",
|
|
draftConfig: {
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
modeId: "bypassPermissions",
|
|
model: "gpt-5",
|
|
thinkingOptionId: "off",
|
|
},
|
|
});
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("list_commands_request");
|
|
expect(request.agentId).toBe("__new_agent__");
|
|
expect(request.draftConfig).toEqual({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
modeId: "bypassPermissions",
|
|
model: "gpt-5",
|
|
thinkingOptionId: "off",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "list_commands_response",
|
|
payload: {
|
|
agentId: "__new_agent__",
|
|
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
|
error: null,
|
|
requestId: request.requestId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
agentId: "__new_agent__",
|
|
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
|
error: null,
|
|
requestId: request.requestId,
|
|
});
|
|
});
|
|
|
|
test("lists commands with explicit requestId via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.listCommands({
|
|
agentId: "agent-1",
|
|
requestId: "req-commands",
|
|
});
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("list_commands_request");
|
|
expect(request.agentId).toBe("agent-1");
|
|
expect(request.requestId).toBe("req-commands");
|
|
expect(request.draftConfig).toBeUndefined();
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "list_commands_response",
|
|
payload: {
|
|
agentId: "agent-1",
|
|
commands: [],
|
|
error: null,
|
|
requestId: "req-commands",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
agentId: "agent-1",
|
|
commands: [],
|
|
error: null,
|
|
requestId: "req-commands",
|
|
});
|
|
});
|
|
|
|
test("preserves legacy listCommands id overload", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.listCommands("agent-1", "req-commands-legacy");
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "list_commands_request",
|
|
requestId: "req-commands-legacy",
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "list_commands_response",
|
|
payload: {
|
|
agentId: "agent-1",
|
|
commands: [],
|
|
error: null,
|
|
requestId: "req-commands-legacy",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
agentId: "agent-1",
|
|
commands: [],
|
|
error: null,
|
|
requestId: "req-commands-legacy",
|
|
});
|
|
});
|
|
|
|
test("preserves legacy listCommands options overload", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.listCommands("__new_agent__", {
|
|
requestId: "req-commands-draft-legacy",
|
|
draftConfig: {
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
modeId: "bypassPermissions",
|
|
model: "gpt-5",
|
|
thinkingOptionId: "off",
|
|
},
|
|
});
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "list_commands_request",
|
|
requestId: "req-commands-draft-legacy",
|
|
agentId: "__new_agent__",
|
|
draftConfig: {
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
modeId: "bypassPermissions",
|
|
model: "gpt-5",
|
|
thinkingOptionId: "off",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "list_commands_response",
|
|
payload: {
|
|
agentId: "__new_agent__",
|
|
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
|
error: null,
|
|
requestId: "req-commands-draft-legacy",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
agentId: "__new_agent__",
|
|
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
|
error: null,
|
|
requestId: "req-commands-draft-legacy",
|
|
});
|
|
});
|
|
|
|
test("emits output events for the active terminal stream", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const seen: string[] = [];
|
|
const unsubscribe = client.onTerminalStreamEvent((event) => {
|
|
if (event.type !== "output") {
|
|
return;
|
|
}
|
|
seen.push(new TextDecoder().decode(event.data));
|
|
});
|
|
|
|
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-1" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-1",
|
|
slot: 11,
|
|
error: null,
|
|
requestId: "sub-1",
|
|
},
|
|
}),
|
|
);
|
|
await subscribePromise;
|
|
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot: 11,
|
|
payload: new TextEncoder().encode("hello"),
|
|
}),
|
|
);
|
|
|
|
expect(seen).toEqual(["hello"]);
|
|
expect(mock.sent).toHaveLength(1);
|
|
unsubscribe();
|
|
});
|
|
|
|
test("emits snapshot events for the subscribed terminal stream", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const snapshots: unknown[] = [];
|
|
client.onTerminalStreamEvent((event) => {
|
|
if (event.type !== "snapshot") {
|
|
return;
|
|
}
|
|
snapshots.push(event.state);
|
|
});
|
|
|
|
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-1",
|
|
slot: 12,
|
|
error: null,
|
|
requestId: "sub-2",
|
|
},
|
|
}),
|
|
);
|
|
await subscribePromise;
|
|
|
|
const state = {
|
|
rows: 1,
|
|
cols: 5,
|
|
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
|
|
scrollback: [],
|
|
cursor: { row: 0, col: 5 },
|
|
};
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Snapshot,
|
|
slot: 12,
|
|
payload: encodeTerminalSnapshotPayload(state),
|
|
}),
|
|
);
|
|
|
|
expect(snapshots).toEqual([state]);
|
|
});
|
|
|
|
test("sends explicit terminal input and resize commands", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-3" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-1",
|
|
slot: 13,
|
|
error: null,
|
|
requestId: "sub-3",
|
|
},
|
|
}),
|
|
);
|
|
await subscribePromise;
|
|
mock.sent.length = 0;
|
|
|
|
client.sendTerminalInput("term-1", {
|
|
type: "input",
|
|
data: "echo hello\r",
|
|
});
|
|
client.sendTerminalInput("term-1", {
|
|
type: "resize",
|
|
rows: 24,
|
|
cols: 80,
|
|
intent: "update",
|
|
});
|
|
|
|
expect(mock.sent.map(parseSentFrame)).toEqual([
|
|
{
|
|
type: "terminal_input",
|
|
terminalId: "term-1",
|
|
message: { type: "input", data: "echo hello\r" },
|
|
},
|
|
{
|
|
type: "terminal_input",
|
|
terminalId: "term-1",
|
|
message: { type: "resize", rows: 24, cols: 80, intent: "update" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("routes concurrent terminal stream frames by slot", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const seen: string[] = [];
|
|
client.onTerminalStreamEvent((event) => {
|
|
if (event.type !== "output") {
|
|
return;
|
|
}
|
|
seen.push(`${event.terminalId}:${new TextDecoder().decode(event.data)}`);
|
|
});
|
|
|
|
const subscribeFirstPromise = client.subscribeTerminal("term-1", { requestId: "sub-multi-1" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-1",
|
|
slot: 21,
|
|
error: null,
|
|
requestId: "sub-multi-1",
|
|
},
|
|
}),
|
|
);
|
|
await subscribeFirstPromise;
|
|
|
|
const subscribeSecondPromise = client.subscribeTerminal("term-2", { requestId: "sub-multi-2" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-2",
|
|
slot: 22,
|
|
error: null,
|
|
requestId: "sub-multi-2",
|
|
},
|
|
}),
|
|
);
|
|
await subscribeSecondPromise;
|
|
mock.sent.length = 0;
|
|
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot: 22,
|
|
payload: new TextEncoder().encode("beta"),
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot: 21,
|
|
payload: new TextEncoder().encode("alpha"),
|
|
}),
|
|
);
|
|
|
|
client.sendTerminalInput("term-2", {
|
|
type: "input",
|
|
data: "echo beta\r",
|
|
});
|
|
client.sendTerminalInput("term-1", {
|
|
type: "resize",
|
|
rows: 10,
|
|
cols: 20,
|
|
});
|
|
|
|
expect(seen).toEqual(["term-2:beta", "term-1:alpha"]);
|
|
expect(mock.sent.map(parseSentFrame)).toEqual([
|
|
{
|
|
type: "terminal_input",
|
|
terminalId: "term-2",
|
|
message: { type: "input", data: "echo beta\r" },
|
|
},
|
|
{
|
|
type: "terminal_input",
|
|
terminalId: "term-1",
|
|
message: { type: "resize", rows: 10, cols: 20 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("ignores terminal stream frames after terminal_stream_exit", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const seen: string[] = [];
|
|
const unsubscribe = client.onTerminalStreamEvent((event) => {
|
|
if (event.type !== "output") {
|
|
return;
|
|
}
|
|
seen.push(new TextDecoder().decode(event.data));
|
|
});
|
|
|
|
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-4" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-1",
|
|
slot: 14,
|
|
error: null,
|
|
requestId: "sub-4",
|
|
},
|
|
}),
|
|
);
|
|
await subscribePromise;
|
|
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot: 14,
|
|
payload: new TextEncoder().encode("before-exit"),
|
|
}),
|
|
);
|
|
expect(seen).toEqual(["before-exit"]);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "terminal_stream_exit",
|
|
payload: {
|
|
subscriptionId: "server-sub-4",
|
|
terminalId: "term-1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot: 14,
|
|
payload: new TextEncoder().encode("after-exit"),
|
|
}),
|
|
);
|
|
|
|
expect(seen).toEqual(["before-exit"]);
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscription.release.request"),
|
|
);
|
|
const release = parseSentFrame(mock.sent.at(-1));
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscription.release.response",
|
|
payload: {
|
|
requestId: release.requestId,
|
|
subscriptionId: "server-sub-4",
|
|
released: true,
|
|
},
|
|
}),
|
|
);
|
|
await Promise.resolve();
|
|
unsubscribe();
|
|
});
|
|
|
|
test("parses canonical agent_stream tool_call payloads without crashing", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const received: unknown[] = [];
|
|
const unsubscribe = client.on("agent_stream", (msg) => {
|
|
received.push(msg);
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent_stream",
|
|
payload: {
|
|
agentId: "agent_cli",
|
|
timestamp: "2026-02-08T20:20:00.000Z",
|
|
event: {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call_cli_stream",
|
|
name: "shell",
|
|
status: "running",
|
|
detail: {
|
|
type: "shell",
|
|
command: "pwd",
|
|
},
|
|
error: null,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
unsubscribe();
|
|
|
|
expect(received).toHaveLength(1);
|
|
expect(received[0]).toMatchObject({
|
|
payload: {
|
|
event: {
|
|
item: {
|
|
status: "running",
|
|
error: null,
|
|
detail: { type: "shell" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("drops legacy agent_stream tool_call payloads and logs validation warning", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const received: unknown[] = [];
|
|
const unsubscribe = client.on("agent_stream", (msg) => {
|
|
received.push(msg);
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent_stream",
|
|
payload: {
|
|
agentId: "agent_cli",
|
|
timestamp: "2026-02-08T20:20:00.000Z",
|
|
event: {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call_cli_stream_legacy",
|
|
name: "shell",
|
|
status: "inProgress",
|
|
detail: {
|
|
type: "unknown",
|
|
input: { command: "pwd" },
|
|
output: null,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
unsubscribe();
|
|
|
|
expect(received).toHaveLength(0);
|
|
expect(logger.warn).toHaveBeenCalled();
|
|
});
|
|
|
|
test("parses canonical fetch_agent_timeline_response payloads without crashing", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const received: unknown[] = [];
|
|
const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => {
|
|
received.push(msg);
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fetch_agent_timeline_response",
|
|
payload: {
|
|
requestId: "req-1",
|
|
agentId: "agent_cli",
|
|
agent: null,
|
|
direction: "tail",
|
|
projection: "projected",
|
|
epoch: "epoch-1",
|
|
reset: false,
|
|
staleCursor: false,
|
|
gap: false,
|
|
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
|
startCursor: { epoch: "epoch-1", seq: 1 },
|
|
endCursor: { epoch: "epoch-1", seq: 1 },
|
|
hasOlder: false,
|
|
hasNewer: false,
|
|
entries: [
|
|
{
|
|
timestamp: "2026-02-08T20:20:00.000Z",
|
|
provider: "codex",
|
|
seqStart: 1,
|
|
seqEnd: 1,
|
|
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
|
collapsed: [],
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call_cli_snapshot",
|
|
name: "shell",
|
|
status: "running",
|
|
detail: {
|
|
type: "shell",
|
|
command: "pwd",
|
|
},
|
|
error: null,
|
|
},
|
|
},
|
|
],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
unsubscribe();
|
|
|
|
expect(received).toHaveLength(1);
|
|
expect(received[0]).toMatchObject({
|
|
payload: {
|
|
entries: [
|
|
{
|
|
item: {
|
|
type: "tool_call",
|
|
status: "running",
|
|
error: null,
|
|
detail: { type: "shell" },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects and logs a correlated response that violates the protocol schema", async () => {
|
|
const mock = createMockTransport();
|
|
const warnings: string[] = [];
|
|
const logger: Logger = {
|
|
...noopLogger,
|
|
warn: (_fields, message) => warnings.push(message ?? ""),
|
|
};
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const response = client.fetchAgentTimeline("agent_cli", {
|
|
requestId: "req-invalid",
|
|
timeout: 1,
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fetch_agent_timeline_response",
|
|
payload: {
|
|
requestId: "req-invalid",
|
|
agentId: "agent_cli",
|
|
agent: null,
|
|
direction: "tail",
|
|
projection: "projected",
|
|
epoch: "epoch-1",
|
|
reset: false,
|
|
staleCursor: false,
|
|
gap: false,
|
|
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
|
startCursor: { epoch: "epoch-1", seq: 1 },
|
|
endCursor: { epoch: "epoch-1", seq: 1 },
|
|
hasOlder: false,
|
|
hasNewer: false,
|
|
entries: [
|
|
{
|
|
timestamp: "2026-02-08T20:20:00.000Z",
|
|
provider: "codex",
|
|
seqStart: 1,
|
|
seqEnd: 1,
|
|
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
|
collapsed: [],
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call_cli_invalid",
|
|
name: "shell",
|
|
status: "inProgress",
|
|
detail: {
|
|
type: "unknown",
|
|
input: { command: "pwd" },
|
|
output: null,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(response).rejects.toMatchObject({
|
|
requestId: "req-invalid",
|
|
message: expect.stringMatching(/validation/i),
|
|
});
|
|
expect(warnings).toEqual(["Message validation failed"]);
|
|
});
|
|
|
|
test("does not reject a request for an invalid correlated progress event", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: noopLogger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const response = client.updateDaemon("req-update");
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "daemon.update.progress",
|
|
payload: {
|
|
requestId: "req-update",
|
|
phase: "verifying",
|
|
},
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "daemon.update.response",
|
|
payload: {
|
|
requestId: "req-update",
|
|
success: true,
|
|
error: null,
|
|
previousVersion: "0.1.106",
|
|
newVersion: "0.1.107",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(response).resolves.toMatchObject({
|
|
requestId: "req-update",
|
|
success: true,
|
|
});
|
|
});
|
|
|
|
test("sends subscribe/unsubscribe terminals messages", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const observation = client.observeTerminals({ cwd: "/tmp/project" });
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({ type: "subscribe_terminals_request", cwd: "/tmp/project" });
|
|
expect(request.subscriptionId).toBeUndefined();
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "terminals_changed",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
subscriptionId: "server-terminals",
|
|
cwd: "/tmp/project",
|
|
terminals: [],
|
|
},
|
|
}),
|
|
);
|
|
await observation.ready;
|
|
const release = observation.release();
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(2));
|
|
const requestRelease = parseSentFrame(mock.sent[1]);
|
|
expect(requestRelease).toMatchObject({
|
|
type: "subscription.release.request",
|
|
subscriptionId: "server-terminals",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscription.release.response",
|
|
payload: {
|
|
requestId: requestRelease.requestId,
|
|
subscriptionId: "server-terminals",
|
|
released: true,
|
|
},
|
|
}),
|
|
);
|
|
await release;
|
|
});
|
|
|
|
test("dispatches terminals_changed events to typed listeners", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const received: Array<{ cwd: string; names: string[] }> = [];
|
|
const unsubscribe = client.on("terminals_changed", (message) => {
|
|
received.push({
|
|
cwd: message.payload.cwd,
|
|
names: message.payload.terminals.map((terminal) => terminal.name),
|
|
});
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "terminals_changed",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
terminals: [
|
|
{
|
|
id: "term-1",
|
|
name: "Dev Server",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
unsubscribe();
|
|
|
|
expect(received).toEqual([
|
|
{
|
|
cwd: "/tmp/project",
|
|
names: ["Dev Server"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("sends provider.usage.list.request and resolves provider.usage.list.response", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const usagePromise = client.listProviderUsage({ requestId: "usage-1" });
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "provider.usage.list.request",
|
|
requestId: "usage-1",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "provider.usage.list.response",
|
|
payload: {
|
|
requestId: "usage-1",
|
|
fetchedAt: "2026-06-19T00:00:00.000Z",
|
|
providers: [
|
|
{
|
|
providerId: "glm",
|
|
displayName: "GLM coding plan",
|
|
status: "available",
|
|
planLabel: "GLM coding plan",
|
|
windows: [
|
|
{
|
|
id: "biweekly",
|
|
label: "Biweekly",
|
|
usedPct: 23,
|
|
remainingPct: 77,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(usagePromise).resolves.toEqual({
|
|
requestId: "usage-1",
|
|
fetchedAt: "2026-06-19T00:00:00.000Z",
|
|
providers: [
|
|
{
|
|
providerId: "glm",
|
|
displayName: "GLM coding plan",
|
|
status: "available",
|
|
planLabel: "GLM coding plan",
|
|
windows: [
|
|
{
|
|
id: "biweekly",
|
|
label: "Biweekly",
|
|
usedPct: 23,
|
|
remainingPct: 77,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test.each([
|
|
{
|
|
status: "available",
|
|
report: {
|
|
status: "available",
|
|
windows: [],
|
|
balances: undefined,
|
|
details: undefined,
|
|
planLabel: undefined,
|
|
},
|
|
},
|
|
{ status: "error", report: { status: "error", error: "" } },
|
|
{
|
|
status: "unavailable",
|
|
report: { status: "unavailable", problem: { kind: "no_quota", detail: "" } },
|
|
},
|
|
] as const)(
|
|
"maps released-host $status usage and filters report IDs",
|
|
async ({ status, report }) => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
mock.triggerOpen({ features: { providerUsageList: true } });
|
|
await connected;
|
|
const result = client.listUsageReports({
|
|
requestId: "legacy-usage",
|
|
reportIds: ["claude"],
|
|
forceRefresh: true,
|
|
});
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "provider.usage.list.request",
|
|
requestId: "legacy-usage",
|
|
});
|
|
const provider = {
|
|
providerId: "claude",
|
|
displayName: "Claude",
|
|
status,
|
|
windows: [],
|
|
planLabel: null,
|
|
fetchedAt: null,
|
|
error: null,
|
|
};
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "provider.usage.list.response",
|
|
payload: {
|
|
requestId: "legacy-usage",
|
|
fetchedAt: "2026-09-30T00:00:00.000Z",
|
|
providers: [provider, { ...provider, providerId: "codex" }],
|
|
},
|
|
}),
|
|
);
|
|
expect(await result).toStrictEqual({
|
|
requestId: "legacy-usage",
|
|
reports: [
|
|
{
|
|
id: "claude",
|
|
sourceId: "claude",
|
|
sourceLabel: "Claude",
|
|
icon: readFileSync(
|
|
new URL("../../../plugins/claude-usage-source/icon.svg", import.meta.url),
|
|
"utf8",
|
|
),
|
|
account: {},
|
|
fetchedAt: "2026-09-30T00:00:00.000Z",
|
|
report,
|
|
},
|
|
],
|
|
});
|
|
},
|
|
);
|
|
|
|
test("sends close_items_request and resolves close_items_response", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.closeItems(
|
|
{
|
|
agentIds: ["agent-1"],
|
|
terminalIds: ["term-1"],
|
|
},
|
|
"req-close-items",
|
|
);
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "close_items_request",
|
|
agentIds: ["agent-1"],
|
|
terminalIds: ["term-1"],
|
|
requestId: "req-close-items",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "close_items_response",
|
|
payload: {
|
|
agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }],
|
|
terminals: [{ terminalId: "term-1", success: true }],
|
|
requestId: "req-close-items",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(responsePromise).resolves.toEqual({
|
|
agents: [{ agentId: "agent-1", archivedAt: "2026-04-01T00:00:00.000Z" }],
|
|
terminals: [{ terminalId: "term-1", success: true }],
|
|
requestId: "req-close-items",
|
|
});
|
|
});
|
|
|
|
test("waitForFinish with timeout=0 omits timeoutMs and has no client deadline", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const waitPromise = client.waitForFinish("agent-wait-zero-timeout", 0);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("wait_for_finish_request");
|
|
expect(request.agentId).toBe("agent-wait-zero-timeout");
|
|
expect(request).not.toHaveProperty("timeoutMs");
|
|
|
|
let settled: "pending" | "resolved" | "rejected" = "pending";
|
|
void waitPromise.then(
|
|
() => {
|
|
settled = "resolved";
|
|
return null;
|
|
},
|
|
() => {
|
|
settled = "rejected";
|
|
return null;
|
|
},
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
|
expect(settled).toBe("pending");
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "wait_for_finish_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
status: "idle",
|
|
final: null,
|
|
error: null,
|
|
lastMessage: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(waitPromise).resolves.toEqual({
|
|
status: "idle",
|
|
final: null,
|
|
error: null,
|
|
lastMessage: null,
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("wire snapshot callers own expansion and receive hash references unchanged", async () => {
|
|
const transport = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "wire-catalog",
|
|
providerSnapshots: "wire",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => transport.transport,
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
transport.triggerOpen({ preserveSent: true });
|
|
await connected;
|
|
expect(JSON.parse(assertStr(transport.sent[0])).capabilities.provider_snapshot_references).toBe(
|
|
true,
|
|
);
|
|
const received: unknown[] = [];
|
|
client.on("providers_snapshot_update", (message) => received.push(message.payload));
|
|
const payload = {
|
|
entries: [],
|
|
snapshotHash: "content",
|
|
fetchedAt: { codex: "2026-09-06T12:00:00.000Z" },
|
|
generatedAt: "2026-09-06T12:00:00.000Z",
|
|
};
|
|
transport.triggerMessage(wrapSessionMessage({ type: "providers_snapshot_update", payload }));
|
|
expect(received).toEqual([payload]);
|
|
const request = client.getProvidersSnapshot();
|
|
const sent = parseSentFrame(transport.sent.at(-1)!);
|
|
const body = {
|
|
...payload,
|
|
compactSnapshot: {
|
|
entries: [
|
|
{
|
|
provider: "codex",
|
|
enabled: true,
|
|
status: "ready",
|
|
models: [{ id: "astra", label: "Astra" }],
|
|
},
|
|
],
|
|
thinkingSets: [],
|
|
},
|
|
requestId: sent.requestId,
|
|
};
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({ type: "get_providers_snapshot_response", payload: body }),
|
|
);
|
|
expect(await request).toEqual(body);
|
|
});
|
|
|
|
test("creation lifecycle sends the keyed agent and initial prompt as one intent", async () => {
|
|
const transport = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "creation-contract",
|
|
transportFactory: () => transport.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
transport.triggerOpen({ features: { creationLifecycle: true, agentRequestReceipts: true } });
|
|
await connected;
|
|
const creation = client.createAgent({
|
|
idempotencyKey: "draft-one",
|
|
provider: "codex",
|
|
cwd: "/project",
|
|
workspaceId: "wks_0123456789abcdef",
|
|
initialPrompt: "Start once",
|
|
clientMessageId: "first-message",
|
|
});
|
|
void creation.catch(() => undefined);
|
|
const request = parseSentFrame(transport.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "agent.create.request",
|
|
idempotencyKey: "draft-one",
|
|
initialPrompt: "Start once",
|
|
clientMessageId: "first-message",
|
|
});
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent.create.response",
|
|
payload: { requestId: request.requestId, agent: null, error: "provider unavailable" },
|
|
}),
|
|
);
|
|
await expect(creation).rejects.toThrow("provider unavailable");
|
|
expect(transport.sent).toHaveLength(1);
|
|
});
|
|
|
|
test("creation lifecycle acknowledgement reaches the observer before the final response", async () => {
|
|
const transport = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "creation-progress",
|
|
transportFactory: () => transport.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
transport.triggerOpen({ features: { creationLifecycle: true } });
|
|
await connected;
|
|
const phases: string[] = [];
|
|
const creation = client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/project",
|
|
idempotencyKey: "observe-one",
|
|
onEvent: (snapshot) => phases.push(snapshot.phase),
|
|
});
|
|
void creation.catch(() => undefined);
|
|
const request = parseSentFrame(transport.sent[0]);
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent.create.update",
|
|
payload: {
|
|
kind: "agent",
|
|
idempotencyKey: "observe-one",
|
|
revision: 0,
|
|
phase: "accepted",
|
|
workspaceId: null,
|
|
agentId: "00000000-0000-4000-8000-000000000001",
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
expect(phases).toEqual(["accepted"]);
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent.create.response",
|
|
payload: { requestId: request.requestId, agent: null, error: "provider unavailable" },
|
|
}),
|
|
);
|
|
await expect(creation).rejects.toThrow("provider unavailable");
|
|
});
|
|
|
|
test("creation reconnect observation uses connection-owned subscriptions and releases on completion", async () => {
|
|
const transport = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "creation-reconnect",
|
|
transportFactory: () => transport.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const features = { creationLifecycle: true, ownedSubscriptions: true };
|
|
const connect = client.connect();
|
|
transport.triggerOpen({ features });
|
|
await connect;
|
|
const phases: string[] = [];
|
|
const creation = client.createWorkspace({
|
|
idempotencyKey: "reconnect-one",
|
|
source: { kind: "directory", path: "/project" },
|
|
onEvent: (snapshot) => phases.push(snapshot.phase),
|
|
});
|
|
void creation.catch(() => undefined);
|
|
const snapshot = {
|
|
kind: "workspace",
|
|
idempotencyKey: "reconnect-one",
|
|
phase: "accepted",
|
|
revision: 0,
|
|
workspaceId: "wks_0123456789abcdef",
|
|
agentId: null,
|
|
error: null,
|
|
};
|
|
for (const subscriptionId of ["first-connection", "second-connection"]) {
|
|
transport.triggerClose();
|
|
const reconnected = client.connect();
|
|
transport.triggerOpen({ features });
|
|
await reconnected;
|
|
await expect.poll(() => transport.sent.length).toBe(1);
|
|
const request = parseSentFrame(transport.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "creation.subscribe.request",
|
|
idempotencyKey: "reconnect-one",
|
|
});
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "creation.subscribe.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
subscriptionId,
|
|
snapshot,
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "workspace.create.update",
|
|
payload: {
|
|
...snapshot,
|
|
subscriptionId: "second-connection",
|
|
revision: 1,
|
|
phase: "failed",
|
|
error: "Provisioning failed",
|
|
},
|
|
}),
|
|
);
|
|
expect(await creation).toMatchObject({ error: "Provisioning failed" });
|
|
await expect.poll(() => transport.sent.length).toBe(2);
|
|
const release = parseSentFrame(transport.sent.at(-1));
|
|
expect(release).toMatchObject({
|
|
type: "subscription.release.request",
|
|
subscriptionId: "second-connection",
|
|
});
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscription.release.response",
|
|
payload: {
|
|
requestId: release.requestId,
|
|
subscriptionId: "second-connection",
|
|
},
|
|
}),
|
|
);
|
|
expect(phases).toEqual(["accepted", "failed"]);
|
|
});
|
|
|
|
test("uploadFile started while connecting waits for the connection and uploads", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "upload-while-connecting",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
suppressSendErrors: true,
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connection = client.connect();
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
|
|
const upload = client.uploadFile({
|
|
fileName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
bytes: new TextEncoder().encode("hello world"),
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
requestId: "req-upload-connecting",
|
|
chunkSize: 5,
|
|
});
|
|
void upload.catch(() => undefined);
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
mock.triggerOpen();
|
|
await connection;
|
|
|
|
await vi.waitFor(() => {
|
|
const frames = mock.sent
|
|
.filter((frame) => typeof frame !== "string")
|
|
.map(assertUint8Array)
|
|
.map(decodeFileTransferFrame);
|
|
expect(frames.map((frame) => frame.opcode)).toEqual([
|
|
FileTransferOpcode.FileBegin,
|
|
FileTransferOpcode.FileChunk,
|
|
FileTransferOpcode.FileChunk,
|
|
FileTransferOpcode.FileChunk,
|
|
FileTransferOpcode.FileEnd,
|
|
]);
|
|
});
|
|
const requestIndex = mock.sent.findIndex(
|
|
(frame) =>
|
|
typeof frame === "string" && parseSentFrame(frame).requestId === "req-upload-connecting",
|
|
);
|
|
expect(parseSentFrame(mock.sent[requestIndex])).toMatchObject({ type: "file.upload.request" });
|
|
expect(requestIndex).toBeLessThan(mock.sent.findIndex((frame) => typeof frame !== "string"));
|
|
|
|
const file = {
|
|
type: "uploaded_file" as const,
|
|
id: "upload_req-upload-connecting",
|
|
fileName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
size: 11,
|
|
path: "/tmp/paseo-uploads/upload_req-upload-connecting/notes.txt",
|
|
};
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "file.upload.response",
|
|
payload: { requestId: "req-upload-connecting", file, error: null },
|
|
}),
|
|
);
|
|
await expect(upload).resolves.toEqual({ requestId: "req-upload-connecting", file, error: null });
|
|
});
|
|
|
|
test("uploadFile stops sending chunks when the connection closes between sends", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "upload-interrupted",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connection = client.connect();
|
|
mock.triggerOpen();
|
|
await connection;
|
|
const upload = client.uploadFile({
|
|
fileName: "test.bin",
|
|
mimeType: "application/octet-stream",
|
|
bytes: new Uint8Array(1024 * 1024),
|
|
});
|
|
const rejection = expect(upload).rejects.toThrow();
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
const beforeClose = mock.sent.length;
|
|
mock.triggerClose();
|
|
await rejection;
|
|
await new Promise((resolve) => setTimeout(resolve, 10));
|
|
expect(mock.sent).toHaveLength(beforeClose);
|
|
expect(
|
|
mock.sent
|
|
.filter((frame) => typeof frame !== "string")
|
|
.map(assertUint8Array)
|
|
.map(decodeFileTransferFrame)
|
|
.some((frame) => frame.opcode === FileTransferOpcode.FileEnd),
|
|
).toBe(false);
|
|
});
|
|
|
|
test("rejects source installation on an older host before sending a request", async () => {
|
|
const transport = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "source-gate",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => transport.transport,
|
|
});
|
|
clients.push(client);
|
|
const connecting = client.connect();
|
|
transport.triggerOpen({ features: { pluginGitManagement: true } });
|
|
await connecting;
|
|
const sentBefore = transport.sent.length;
|
|
await expect(client.installPluginSource({ source: "npm:review" })).rejects.toThrow(
|
|
"Update the host",
|
|
);
|
|
expect(transport.sent.length).toBe(sentBefore);
|
|
});
|
|
|
|
test("reviewed plugin updates gate before requests and preserve exact proposal data", async () => {
|
|
for (const supported of [false, true]) {
|
|
const transport = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "reviewed-updates",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => transport.transport,
|
|
});
|
|
clients.push(client);
|
|
const connecting = client.connect();
|
|
transport.triggerOpen({
|
|
features: {
|
|
pluginGitManagement: true,
|
|
pluginSourceInstallation: true,
|
|
pluginSourceUpdates: supported,
|
|
},
|
|
});
|
|
await connecting;
|
|
const proposal = {
|
|
id: "review",
|
|
expected: {
|
|
identity: { kind: "npm" as const, packageName: "review", pluginPath: "." },
|
|
installationRoot: "/plugins/review/version-root",
|
|
revision: "1.0.0",
|
|
},
|
|
target: {
|
|
kind: "npm" as const,
|
|
version: "1.1.0",
|
|
resolved: "https://registry.npmjs.org/review/-/review-1.1.0.tgz",
|
|
integrity: "sha512-test",
|
|
},
|
|
};
|
|
if (!supported) {
|
|
const sent = transport.sent.length;
|
|
await expect(client.previewPluginUpdates()).rejects.toThrow("Update the host");
|
|
await expect(client.applyPluginUpdates([proposal])).rejects.toThrow("Update the host");
|
|
expect(transport.sent.length).toBe(sent);
|
|
continue;
|
|
}
|
|
const checking = client.previewPluginUpdates({ pluginId: "review" });
|
|
const check = parseSentFrame(transport.sent.at(-1));
|
|
expect(check).toMatchObject({
|
|
type: "plugin.source.update.preview.request",
|
|
pluginId: "review",
|
|
});
|
|
const preview = { id: "review", outcome: "update", links: [], proposal };
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "plugin.source.update.preview.response",
|
|
payload: { requestId: check.requestId, plugins: [preview] },
|
|
}),
|
|
);
|
|
await expect(checking).resolves.toEqual([preview]);
|
|
const applying = client.applyPluginUpdates([proposal]);
|
|
const apply = parseSentFrame(transport.sent.at(-1));
|
|
expect(apply).toEqual({
|
|
type: "plugin.source.update.apply.request",
|
|
requestId: expect.any(String),
|
|
proposals: [proposal],
|
|
});
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "plugin.source.update.apply.response",
|
|
payload: {
|
|
requestId: apply.requestId,
|
|
plugins: [{ id: "review", outcome: "error", error: "changed since review" }],
|
|
},
|
|
}),
|
|
);
|
|
await expect(applying).resolves.toEqual([
|
|
{ id: "review", outcome: "error", error: "changed since review" },
|
|
]);
|
|
}
|
|
});
|
|
|
|
test.each([
|
|
[undefined, false],
|
|
[{}, false],
|
|
[{ usageSources: false, providerUsageList: false }, false],
|
|
[{ usageSources: true }, true],
|
|
[{ providerUsageList: true }, true],
|
|
[{ usageSources: true, providerUsageList: true }, true],
|
|
] as const)("usage support for features %j is %s", (features, expected) => {
|
|
expect(supportsUsageReports(features)).toBe(expected);
|
|
});
|
|
|
|
test("uses modern usage RPC when both capabilities are advertised", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
mock.triggerOpen({ features: { usageSources: true, providerUsageList: true } });
|
|
await connected;
|
|
const updates: unknown[] = [];
|
|
const result = client.listUsageReports(
|
|
{
|
|
requestId: "modern-usage",
|
|
reportIds: ["claude:work"],
|
|
forceRefresh: true,
|
|
},
|
|
(report) => updates.push(report),
|
|
);
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "usage.list_reports.request",
|
|
requestId: "modern-usage",
|
|
reportIds: ["claude:work"],
|
|
forceRefresh: true,
|
|
});
|
|
const payload = {
|
|
requestId: "modern-usage",
|
|
reports: [
|
|
{
|
|
id: "claude:work",
|
|
sourceId: "claude",
|
|
sourceLabel: "Claude",
|
|
account: { label: "Work" },
|
|
fetchedAt: "2026-09-30T00:00:00.000Z",
|
|
report: { status: "available", windows: [] },
|
|
},
|
|
],
|
|
};
|
|
const report = payload.reports[0];
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "usage.list_reports.update",
|
|
payload: { requestId: "foreign", report },
|
|
}),
|
|
);
|
|
expect(updates).toEqual([]);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "usage.list_reports.update",
|
|
payload: { requestId: payload.requestId, report },
|
|
}),
|
|
);
|
|
expect(updates).toEqual([report]);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "usage.list_reports.response",
|
|
payload: { requestId: payload.requestId, error: null },
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "usage.list_reports.update",
|
|
payload: { requestId: payload.requestId, report },
|
|
}),
|
|
);
|
|
expect(await result).toStrictEqual(payload);
|
|
expect(updates).toEqual([report]);
|
|
});
|
|
|
|
test("rejects usage requests when the host has neither capability", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
mock.triggerOpen({ features: {} });
|
|
await connected;
|
|
await expect(client.listUsageReports()).rejects.toThrow("Update the host to see usage.");
|
|
expect(mock.sent).toEqual([]);
|
|
});
|
|
|
|
test("usage request timeout detaches its update listener", async () => {
|
|
vi.useFakeTimers();
|
|
try {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
mock.triggerOpen({ features: { usageSources: true } });
|
|
await connected;
|
|
const updates: unknown[] = [];
|
|
const result = client.listUsageReports({ requestId: "timeout-usage" }, (report) =>
|
|
updates.push(report),
|
|
);
|
|
const rejected = expect(result).rejects.toThrow("Timeout waiting for message (60000ms)");
|
|
await vi.advanceTimersByTimeAsync(60_001);
|
|
await rejected;
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "usage.list_reports.update",
|
|
payload: {
|
|
requestId: "timeout-usage",
|
|
report: {
|
|
id: "source:late",
|
|
account: {},
|
|
sourceId: "source",
|
|
sourceLabel: "Source",
|
|
fetchedAt: "2026-01-01T00:00:00.000Z",
|
|
report: { status: "available", windows: [] },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
expect(updates).toEqual([]);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|