mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-29 12:42:57 +00:00
* feat(hosts): connect to saved hosts through a daemon * fix(hosts): validate safe targets and verify encrypted relay routes * test(hosts): include relay coverage in existing CI contracts * fix(cli): preserve validated addresses in daemon command hints * fix(hosts): share daemon links across isolated routed sessions Multiplex logical sessions over the monitored daemon connection and wrap original binary bytes in routing headers. Admit channels under their authenticated parent and current grants; preserve scoped delivery and physical send completion. * fix(hosts): bound encrypted overload and initial acquisition Check encrypted wire capacity before the async channel converts a resource limit into a physical connection error. Bound initial routed acquisition with the existing client timeout, then enable reconnect after admission. * fix(hosts): keep compact row actions clear of host labels Give compact connected-host rows separate text and action widths so long names and producer labels cannot compete with primary controls. * refactor(hosts): remove management settings UI from phase one Keep connected-host management in the CLI and shared SDK while preserving app saved connections and plugin access. * test(hosts): control physical write backpressure across platforms Hold real TCP writes until assertions finish instead of assuming a paused receiver exhausts every platform receive buffer. Preserve physical send settlement, byte identity, capacity and sibling checks. * refactor(hosts): use the ordinary client contract over channel transports Keep queuing, permission codes and reconnection independent of transport selection. Track submitted request outcomes generically while retaining connection error codes and structured causes. * test(e2e): keep transient developer toasts from stalling checks Concurrent locator handlers can wait for a dismiss button another handler removed. Dismiss existing toast DOM at document lifetime, including its open shadow root, without intercepting app actions. * Create, prompt, and monitor agents across connected hosts (#7507) * feat(hosts): delegate agents across connected hosts Resolve MCP host operations through in-process commands for self and the existing routed client transport for other destinations. Keep ordinary parent labels, app sessions and idle finish notifications. * test(app): normalize subagent fixtures through snapshots * docs(protocol): clarify same-host parent labels * fix(hosts): resolve subagents through destination sessions A child address identifies its destination regardless of the connection used. Reuse the existing host candidate selection for discovered routes and direct connections, and keep navigation and row actions independent of the parent transport. * fix(hosts): keep routed destinations distinct during discovery A routed candidate names only the origin daemon. Match it within the named destination profile so two destinations discovered through the same origin retain separate identities and connections, including existing saved routes. * feat(hosts): prompt and query agents on selected hosts Extend the existing MCP host operation boundary with prompt, status, activity and list operations. Local calls retain their handlers and notification owner; selected destinations use ordinary client RPCs and the existing idle finish wait. * test(e2e): preserve toast coverage with CSS hiding * docs(hosts): translate connected host references
7901 lines
216 KiB
TypeScript
7901 lines
216 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import {
|
|
DaemonClient,
|
|
DaemonConnectionError,
|
|
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;
|
|
|
|
const response = client.fetchAgents({ requestId: "diagnostic-request", scope: "active" });
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fetch_agents_response",
|
|
payload: {
|
|
requestId: "diagnostic-request",
|
|
entries: [],
|
|
pageInfo: { nextCursor: null, prevCursor: null, hasMore: false },
|
|
},
|
|
}),
|
|
);
|
|
await response;
|
|
const spans = recorder.records.filter((record) => record.phase === "begin");
|
|
expect(spans).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({
|
|
name: "paseo.ws.frame.inbound",
|
|
args: {
|
|
kind: "text",
|
|
size: expect.any(String),
|
|
sizeUnit: "utf16-code-units",
|
|
connectionAttempt: "1",
|
|
},
|
|
}),
|
|
expect.objectContaining({ name: "paseo.ws.json.parse" }),
|
|
expect.objectContaining({ name: "paseo.ws.validate" }),
|
|
expect.objectContaining({
|
|
name: "paseo.ws.message.outbound",
|
|
args: {
|
|
envelopeType: "session",
|
|
messageType: "fetch_agents_request",
|
|
requestId: "diagnostic-request",
|
|
connectionAttempt: "1",
|
|
},
|
|
}),
|
|
expect.objectContaining({
|
|
name: "paseo.ws.message.inbound",
|
|
args: {
|
|
envelopeType: "session",
|
|
messageType: "fetch_agents_response",
|
|
requestId: "diagnostic-request",
|
|
connectionAttempt: "1",
|
|
},
|
|
}),
|
|
expect.objectContaining({
|
|
name: "paseo.ws.dispatch",
|
|
args: {
|
|
messageType: "fetch_agents_response",
|
|
requestId: "diagnostic-request",
|
|
connectionAttempt: "1",
|
|
},
|
|
}),
|
|
]),
|
|
);
|
|
const stack: string[] = [];
|
|
const parentage: [string, string | undefined][] = [];
|
|
for (const record of recorder.records) {
|
|
if (record.phase === "begin") {
|
|
parentage.push([record.name, stack.at(-1)]);
|
|
stack.push(record.name);
|
|
} else {
|
|
expect(stack.length).toBeGreaterThan(0);
|
|
stack.pop();
|
|
}
|
|
}
|
|
expect(stack).toEqual([]);
|
|
expect(parentage).toContainEqual(["paseo.ws.frame.outbound", "paseo.ws.message.outbound"]);
|
|
expect(parentage).toContainEqual(["paseo.ws.dispatch", "paseo.ws.frame.inbound"]);
|
|
expect(JSON.stringify(recorder.records)).not.toContain("trace_unit_test");
|
|
});
|
|
|
|
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);
|
|
const connecting = client.connect();
|
|
void connecting.catch(() => {});
|
|
await vi.waitFor(() => expect(requests).toHaveLength(1));
|
|
expect(requests[0]).toEqual({ url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2" });
|
|
await client.close();
|
|
await expect(connecting).rejects.toThrow("Daemon client closed");
|
|
});
|
|
|
|
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,
|
|
workspace_setup_observation: 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 a plugin reload pending past the session RPC deadline", async () => {
|
|
useHeartbeatClock();
|
|
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 connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const reload = client.reloadPlugin("example");
|
|
let settled: "pending" | "resolved" | "rejected" = "pending";
|
|
void reload.then(
|
|
() => {
|
|
settled = "resolved";
|
|
return null;
|
|
},
|
|
() => {
|
|
settled = "rejected";
|
|
return null;
|
|
},
|
|
);
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "plugin.reload.request",
|
|
requestId: expect.any(String),
|
|
pluginId: "example",
|
|
});
|
|
|
|
// A daemon that reloads for almost two minutes is still reloading, not a failed request.
|
|
await vi.advanceTimersByTimeAsync(114_034);
|
|
expect(settled).toBe("pending");
|
|
|
|
// Another plugin's reload finishing early must not settle this request.
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "plugin.reload.response",
|
|
payload: {
|
|
requestId: "req-other-plugin",
|
|
plugin: { id: "other", path: "/plugins/other", enabled: true, status: "running" },
|
|
},
|
|
}),
|
|
);
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(settled).toBe("pending");
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "plugin.reload.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
plugin: { id: "example", path: "/plugins/example", enabled: true, status: "running" },
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(reload).resolves.toEqual({
|
|
id: "example",
|
|
path: "/plugins/example",
|
|
enabled: true,
|
|
status: "running",
|
|
});
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("keeps enable and disable inside the session RPC deadline", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
for (const action of ["enable", "disable"] as const) {
|
|
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;
|
|
|
|
const pending =
|
|
action === "enable" ? client.enablePlugin("example") : client.disablePlugin("example");
|
|
let settled = false;
|
|
void pending.then(
|
|
() => {
|
|
settled = true;
|
|
return null;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return null;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: `plugin.${action}.request`,
|
|
requestId: expect.any(String),
|
|
pluginId: "example",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(59_999);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(pending).rejects.toThrow("Timeout waiting for message (60000ms)");
|
|
}
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("reports a plugin reload failure that arrives after the session RPC deadline", async () => {
|
|
useHeartbeatClock();
|
|
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 connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const reload = client.reloadPlugin("example");
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
|
|
await vi.advanceTimersByTimeAsync(114_034);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "rpc_error",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
requestType: "plugin.reload.request",
|
|
error: "Request failed: Plugin failed to start: example",
|
|
code: "handler_error",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(reload).rejects.toThrow(
|
|
"Request failed: Plugin failed to start: example requestType=plugin.reload.request code=handler_error",
|
|
);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("rejects a pending plugin reload when the connection closes", 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;
|
|
|
|
const reload = client.reloadPlugin("example");
|
|
expect(parseSentFrame(mock.sent[0])).toMatchObject({ type: "plugin.reload.request" });
|
|
|
|
mock.triggerClose({ code: 1006, reason: "network lost" });
|
|
|
|
await expect(reload).rejects.toThrow(/network lost|disconnected|closed/i);
|
|
|
|
// Reconnecting must not resend the lost reload: only the caller decides to reload again.
|
|
const reconnecting = client.connect();
|
|
mock.triggerOpen();
|
|
await reconnecting;
|
|
expect(mock.sent).toEqual([]);
|
|
});
|
|
|
|
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");
|
|
});
|
|
|
|
async function connectFileClient(clientId: string) {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId,
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
return { mock, client };
|
|
}
|
|
|
|
function sendBinaryFile(
|
|
mock: ReturnType<typeof createMockTransport>,
|
|
requestId: string,
|
|
announcedSize: number,
|
|
chunks: string[],
|
|
) {
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileBegin,
|
|
requestId,
|
|
metadata: {
|
|
mime: "application/octet-stream",
|
|
size: announcedSize,
|
|
encoding: "binary",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
}),
|
|
);
|
|
for (const chunk of chunks) {
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId,
|
|
payload: new TextEncoder().encode(chunk),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
|
|
test("readFile with timeout 0 outlives the default request timeout", async () => {
|
|
useHeartbeatClock();
|
|
const { mock, client } = await connectFileClient("clsk_file_no_timeout");
|
|
|
|
const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0);
|
|
await vi.advanceTimersByTimeAsync(60_001);
|
|
|
|
sendBinaryFile(mock, "req-slow", 5, ["hello"]);
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }),
|
|
);
|
|
|
|
const result = await responsePromise;
|
|
expect(result.size).toBe(5);
|
|
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
|
});
|
|
|
|
test("readFile rejects a binary transfer that ends before the announced size", async () => {
|
|
const { mock, client } = await connectFileClient("clsk_file_short");
|
|
|
|
const responsePromise = client.readFile("/tmp/project", "short.bin", "req-short");
|
|
sendBinaryFile(mock, "req-short", 10, ["hello"]);
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-short" }),
|
|
);
|
|
|
|
await expect(responsePromise).rejects.toThrow(
|
|
"File transfer incomplete: expected 10 bytes, received 5.",
|
|
);
|
|
});
|
|
|
|
test("readFile rejects a binary transfer that exceeds the announced size", async () => {
|
|
const { mock, client } = await connectFileClient("clsk_file_long");
|
|
|
|
const responsePromise = client.readFile("/tmp/project", "long.bin", "req-long");
|
|
const settled = responsePromise.then(
|
|
() => null,
|
|
(error: unknown) => error,
|
|
);
|
|
sendBinaryFile(mock, "req-long", 4, ["hello!"]);
|
|
|
|
expect(() =>
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-long" }),
|
|
),
|
|
).not.toThrow();
|
|
expect(await settled).toMatchObject({
|
|
message: "File transfer incomplete: expected 4 bytes, received 6.",
|
|
});
|
|
});
|
|
|
|
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 internal in create_agent_request when the host advertises internal 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({ features: { backgroundWorkspaces: true } });
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
background: true,
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual(
|
|
expect.objectContaining({
|
|
type: "create_agent_request",
|
|
config: { provider: "codex", cwd: "/tmp/project" },
|
|
background: true,
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: request.requestId,
|
|
error: "internal agent sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("internal agent sentinel");
|
|
});
|
|
|
|
test("rejects an internal create before sending when the host predates internal 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({ features: {} });
|
|
await connectPromise;
|
|
|
|
await expect(
|
|
client.createAgent({ provider: "codex", cwd: "/tmp/project", background: true }),
|
|
).rejects.toThrow("Update the host to use background workspaces.");
|
|
expect(mock.sent).toHaveLength(0);
|
|
});
|
|
|
|
test("sends internal workspace creation and internal listings only to hosts that support 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();
|
|
// workspaceMultiplicity keeps the fetch on the real workspace directory RPC.
|
|
mock.triggerOpen({
|
|
features: { workspaceMultiplicity: true, backgroundWorkspaces: true },
|
|
});
|
|
await connectPromise;
|
|
|
|
void client
|
|
.createWorkspace({ source: { kind: "directory", path: "/tmp/project" }, background: true })
|
|
.catch(() => undefined);
|
|
void client.fetchWorkspaces({ filter: { includeBackground: true } }).catch(() => undefined);
|
|
void client.fetchAgents({ filter: { includeBackground: true } }).catch(() => undefined);
|
|
|
|
expect(mock.sent.map((frame) => parseSentFrame(frame))).toEqual(
|
|
expect.arrayContaining([
|
|
expect.objectContaining({ type: "workspace.create.request", background: true }),
|
|
expect.objectContaining({
|
|
type: "fetch_workspaces_request",
|
|
filter: { includeBackground: true },
|
|
}),
|
|
expect.objectContaining({
|
|
type: "fetch_agents_request",
|
|
filter: { includeBackground: true },
|
|
}),
|
|
]),
|
|
);
|
|
});
|
|
|
|
test("rejects internal workspace creation and internal listings before sending on older hosts", 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: {} });
|
|
await connectPromise;
|
|
|
|
await expect(
|
|
client.createWorkspace({
|
|
source: { kind: "directory", path: "/tmp/project" },
|
|
background: true,
|
|
}),
|
|
).rejects.toThrow("Update the host to use background workspaces.");
|
|
await expect(client.fetchWorkspaces({ filter: { includeBackground: true } })).rejects.toThrow(
|
|
"Update the host to use background workspaces.",
|
|
);
|
|
expect(mock.sent).toHaveLength(0);
|
|
|
|
// Agent listings gate on the older internalAgents feature.
|
|
const olderMock = createMockTransport();
|
|
const olderClient = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => olderMock.transport,
|
|
});
|
|
clients.push(olderClient);
|
|
const olderConnect = olderClient.connect();
|
|
olderMock.triggerOpen({ features: {} });
|
|
await olderConnect;
|
|
await expect(olderClient.fetchAgents({ filter: { includeBackground: true } })).rejects.toThrow(
|
|
"Update the host to use background workspaces.",
|
|
);
|
|
expect(olderMock.sent).toHaveLength(0);
|
|
});
|
|
|
|
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 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();
|
|
}
|
|
});
|
|
|
|
test.each([false, true])(
|
|
"agent provenance requires host support=%s while human sends remain compatible",
|
|
async (supported) => {
|
|
const transport = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "provenance",
|
|
transportFactory: () => transport.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const connecting = client.connect();
|
|
transport.triggerOpen({ features: { agentMessageProvenance: supported } });
|
|
await connecting;
|
|
if (!supported) {
|
|
await expect(
|
|
client.sendAgentMessage("recipient", "review", { sourceAgentId: "server::sender" }),
|
|
).rejects.toThrow("Update the Paseo host");
|
|
expect(transport.sent).toHaveLength(0);
|
|
}
|
|
const send = client.sendAgentMessage("recipient", "review", {
|
|
messageId: "delivery",
|
|
...(supported ? { sourceAgentId: "server::sender" } : {}),
|
|
});
|
|
const request = parseSentFrame(transport.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "send_agent_message_request",
|
|
text: "review",
|
|
messageId: "delivery",
|
|
});
|
|
expect(request.sourceAgentId).toBe(supported ? "server::sender" : undefined);
|
|
transport.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "send_agent_message_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
agentId: "recipient",
|
|
accepted: true,
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
await send;
|
|
},
|
|
);
|
|
|
|
test("measures an outbound message in UTF-8 bytes against the daemon's message limit", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "message_limit_unit_test",
|
|
transportFactory: () => mock.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
const mib = 1024 * 1024;
|
|
|
|
// "é" is one UTF-16 unit but two UTF-8 bytes, so this text is ~98 MiB on the wire.
|
|
void client.sendMessage("agent-1", "é".repeat(49 * mib)).catch(() => undefined);
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
// ~60M characters is under the 100 MiB limit; ~120 MiB of UTF-8 is over it.
|
|
await expect(client.sendMessage("agent-1", "é".repeat(60 * mib))).rejects.toThrow(
|
|
"Message is too large to send (120.0 MiB). The limit is 100.0 MiB",
|
|
);
|
|
expect(mock.sent).toHaveLength(1);
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
});
|
|
|
|
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("ordinary lifecycle queues new calls, distinguishes submitted outcomes, and never resends lost requests", async () => {
|
|
const transports = [createMockTransport(), createMockTransport(), createMockTransport()];
|
|
let attempt = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://transport",
|
|
clientId: "ordinary-lifecycle",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => transports[attempt++]!.transport,
|
|
});
|
|
clients.push(client);
|
|
const first = transports[0]!;
|
|
const connecting = client.connect();
|
|
const initial = client.getDaemonConfig("initial").catch((error: unknown) => error);
|
|
expect(first.sent).toEqual([]);
|
|
first.triggerOpen();
|
|
await connecting;
|
|
expect(parseSentFrame(first.sent[0]).requestId).toBe("initial");
|
|
first.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "rpc_error",
|
|
payload: { requestId: "initial", error: "Denied", code: "access_denied" },
|
|
}),
|
|
);
|
|
expect(await initial).toMatchObject({ code: "access_denied", serverId: "srv_test_1" });
|
|
const submitted = client
|
|
.patchDaemonConfig({ pluginsEnabled: false }, "submitted")
|
|
.catch((error: unknown) => error);
|
|
first.triggerClose({ code: 1001, reason: "physical loss" });
|
|
const unknown = await submitted;
|
|
expect(unknown).toBeInstanceOf(DaemonConnectionError);
|
|
expect(unknown).toMatchObject({
|
|
code: "DAEMON_CONNECTION_LOST",
|
|
outcome: "unknown",
|
|
serverId: "srv_test_1",
|
|
});
|
|
|
|
expect(() => client.ensureConnected()).not.toThrow();
|
|
const second = transports[1]!;
|
|
const neverSent = client
|
|
.patchDaemonConfig({ pluginsEnabled: true }, "never-sent")
|
|
.catch((error: unknown) => error);
|
|
expect(second.sent).toEqual([]);
|
|
second.triggerClose({ code: 1001, reason: "handshake lost" });
|
|
const unavailable = await neverSent;
|
|
expect(unavailable).toBeInstanceOf(DaemonConnectionError);
|
|
expect(unavailable).toMatchObject({ code: "DAEMON_CONNECTION_LOST", serverId: "srv_test_1" });
|
|
expect((unavailable as DaemonConnectionError).outcome).toBeUndefined();
|
|
|
|
client.ensureConnected();
|
|
const third = transports[2]!;
|
|
const recovery = client.getDaemonConfig("recovery").catch((error: unknown) => error);
|
|
expect(third.sent).toEqual([]);
|
|
third.triggerOpen();
|
|
expect(third.sent.map(parseSentFrame).map((frame) => frame.requestId)).toEqual(["recovery"]);
|
|
third.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "rpc_error",
|
|
payload: { requestId: "recovery", error: "Denied", code: "access_denied" },
|
|
}),
|
|
);
|
|
expect(await recovery).toMatchObject({ code: "access_denied" });
|
|
});
|
|
|
|
test.each([false, true])(
|
|
"synchronous send rejection leaves no unknown outcome after close (close during handoff: %s)",
|
|
async (closeDuringSend) => {
|
|
const first = createMockTransport();
|
|
const second = createMockTransport();
|
|
const rejected = Object.assign(new Error("Transport rejected this send"), {
|
|
code: "resource_limit",
|
|
});
|
|
const originalSend = first.transport.send;
|
|
first.transport.send = (data) => {
|
|
if (typeof data === "string" && data.includes("set_daemon_config_request")) {
|
|
if (closeDuringSend) first.triggerClose({ code: 1001, reason: "closed during send" });
|
|
throw rejected;
|
|
}
|
|
originalSend(data);
|
|
};
|
|
let attempt = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://transport",
|
|
clientId: "rejected-send",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => [first, second][attempt++]!.transport,
|
|
});
|
|
clients.push(client);
|
|
const connected = client.connect();
|
|
first.triggerOpen();
|
|
await connected;
|
|
await expect(client.patchDaemonConfig({ pluginsEnabled: false }, "rejected")).rejects.toBe(
|
|
rejected,
|
|
);
|
|
first.triggerClose({ code: 1001, reason: "lost connection" });
|
|
client.ensureConnected();
|
|
const queued = client.getDaemonConfig("not-submitted").catch((error: unknown) => error);
|
|
second.triggerClose({ code: 1001, reason: "lost handshake" });
|
|
const failure = (await queued) as DaemonConnectionError;
|
|
expect(failure).toBeInstanceOf(DaemonConnectionError);
|
|
expect(failure.outcome).toBeUndefined();
|
|
expect((rejected as Error & { outcome?: string }).outcome).toBeUndefined();
|
|
},
|
|
);
|
|
|
|
test("initial acquisition preserves structured transport failures and ordinary offline errors retain their cause", async () => {
|
|
const transport = createMockTransport();
|
|
const failure = Object.assign(new Error("Destination identity changed"), {
|
|
code: "server_id_mismatch",
|
|
serverId: "srv_expected",
|
|
});
|
|
const client = new DaemonClient({
|
|
url: "ws://transport",
|
|
clientId: "structured-acquisition",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => transport.transport,
|
|
});
|
|
clients.push(client);
|
|
const connecting = client.connect();
|
|
const rejected = expect(connecting).rejects.toBe(failure);
|
|
transport.triggerError(failure);
|
|
await rejected;
|
|
await expect(client.getDaemonConfig()).rejects.toMatchObject({
|
|
code: "DAEMON_CONNECTION_LOST",
|
|
cause: failure,
|
|
});
|
|
});
|