mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-14 13:22:37 +00:00
* chore(ci): audit packaging costs and repeated test execution Read-only audit: Linux packaging and smokes consume 12-14 minutes; all three CLI shards repeat the 294-test source suite and build the server twice. Preserve the 17 required contexts, move packaging to main, run CLI source tests once, and consolidate repeated E2E setup into journeys. Historical logs and the complete audit were reported outside the repository. * ci: move packaging to main and remove repeated CLI suites PR runners were rebuilding distribution artifacts and executing the entire CLI source suite on all three E2E shards. Keep packaging coverage after merge, let the local E2E runner own its dependency build, and execute source tests once in an existing required check. Root typecheck already validates plugin examples. * test(ci): consolidate journeys and remove repeated startup work Run user journeys with shared setup, retain one Hub CLI contract, and weight the existing Playwright shards using observed costs. Remove implementation-only and ad-hoc measurement checks. Await root-watch closure and publish creation failure commands atomically so teardown and failure injection have completion boundaries. * test(hub): remove incidental observation and speculative shutdown changes Hub control RPCs need a connected client without an agent observation. Preserve the failed-create subscription contract and remove product shutdown changes whose Windows cleanup cause was not reproduced. * fix(client): clear the scheduled retry when a connect attempt opens a transport An explicit connect() during reconnect backoff opened a new transport but left the scheduled retry armed. When it fired on the healthy connection it disposed the transport and reconnected, dropping requests in flight. This is why owned-subscriptions-old-daemon could still fail after #4858 added the explicit reconnect. attemptConnect now owns the rule ensureConnected already applied; the new unit test fails on the old client. * test(e2e): keep the completed-block, font-readiness, and live-page guards inside journeys - agent-stream-ui: the auto-scroll journey anchors on the completed intro block by content and requires its Markdown root to stay the same mounted node while later blocks stream (#1989 guard, reshaped off the tail block). - changes-pane: the code-typography journey holds font loads and requires no diff geometry before they resolve, then applies the font change. - agent-timeline-pagination: the live-output page load runs against a thirty-minute mock stream and gates the release on the running turn, so the status assertion no longer races a ten-second turn. * test(desktop): install the Electron binary once before the unit suite Electron 44 downloads its binary on the first Node require("electron") instead of at npm ci. Vitest workers importing electron-log/main raced that download and extraction (File exists on dist/electron, then "Electron failed to install correctly" in a sibling worker) on both Ubuntu and Windows desktop-tests jobs. Run the package's idempotent install-electron in one process before vitest forks. * ci(desktop-packages): pin actions and smoke packaging-path pull requests Pin checkout, setup-node, and upload-artifact to full commit SHAs. Keep packaging off ordinary PR CI, but run the Linux package smoke on pull requests that touch packages/desktop, nix, or the workflow so a packaging or sandbox regression cannot land on main unchecked. The desktop release workflow already builds and smokes its own artifacts on all platforms.
6618 lines
173 KiB
TypeScript
6618 lines
173 KiB
TypeScript
import { afterEach, expect, expectTypeOf, test, vi } from "vitest";
|
|
import { z } from "zod";
|
|
import {
|
|
DaemonClient,
|
|
type DaemonClientTrace,
|
|
type DaemonTransport,
|
|
type Logger,
|
|
} from "./daemon-client";
|
|
import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
|
|
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
|
|
import {
|
|
decodeFileTransferFrame,
|
|
encodeFileTransferFrame,
|
|
FileTransferOpcode,
|
|
} from "@getpaseo/protocol/binary-frames/index";
|
|
import {
|
|
encodeTerminalSnapshotPayload,
|
|
encodeTerminalStreamFrame,
|
|
TerminalStreamOpcode,
|
|
} from "@getpaseo/protocol/terminal-stream-protocol";
|
|
|
|
expectTypeOf<"getGitDiff" extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>();
|
|
expectTypeOf<
|
|
"getHighlightedDiff" extends keyof DaemonClient ? true : false
|
|
>().toEqualTypeOf<false>();
|
|
expectTypeOf<
|
|
"exploreFileSystem" extends keyof DaemonClient ? true : false
|
|
>().toEqualTypeOf<false>();
|
|
|
|
function createMockLogger() {
|
|
return {
|
|
debug: vi.fn(),
|
|
info: vi.fn(),
|
|
warn: vi.fn(),
|
|
error: vi.fn(),
|
|
};
|
|
}
|
|
|
|
interface TraceRecord {
|
|
phase: "begin" | "end";
|
|
name?: string;
|
|
args?: Record<string, string>;
|
|
}
|
|
|
|
function createTraceRecorder(): { trace: DaemonClientTrace; records: TraceRecord[] } {
|
|
const records: TraceRecord[] = [];
|
|
return {
|
|
trace: {
|
|
isEnabled: () => true,
|
|
beginSection: (name, args) => records.push({ phase: "begin", name, args }),
|
|
endSection: () => records.push({ phase: "end" }),
|
|
},
|
|
records,
|
|
};
|
|
}
|
|
|
|
function createMockTransport() {
|
|
const sent: Array<string | Uint8Array | ArrayBuffer> = [];
|
|
|
|
let onMessage: (data: unknown) => void = () => {};
|
|
let onOpen: () => void = () => {};
|
|
let onClose: (_event?: unknown) => void = () => {};
|
|
let onError: (_event?: unknown) => void = () => {};
|
|
let serverInfoOrdinal = 1;
|
|
|
|
const transport: DaemonTransport = {
|
|
send: (data) => {
|
|
sent.push(data);
|
|
if (typeof data !== "string") {
|
|
return;
|
|
}
|
|
const frame = JSON.parse(data) as { type?: string };
|
|
if (frame.type === "ping") {
|
|
onMessage(JSON.stringify({ type: "pong" }));
|
|
}
|
|
},
|
|
close: () => {},
|
|
onMessage: (handler) => {
|
|
onMessage = (data) => handler(data, typeof data !== "string");
|
|
return () => {};
|
|
},
|
|
onOpen: (handler) => {
|
|
onOpen = handler;
|
|
return () => {};
|
|
},
|
|
onClose: (handler) => {
|
|
onClose = handler;
|
|
return () => {};
|
|
},
|
|
onError: (handler) => {
|
|
onError = handler;
|
|
return () => {};
|
|
},
|
|
};
|
|
|
|
return {
|
|
transport,
|
|
sent,
|
|
triggerOpen: (options?: { preserveSent?: boolean; features?: Record<string, boolean> }) => {
|
|
onOpen();
|
|
if (!options?.preserveSent) {
|
|
// Ignore HELLO handshake payloads in assertions.
|
|
sent.length = 0;
|
|
}
|
|
onMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "status",
|
|
payload: {
|
|
status: "server_info",
|
|
serverId: `srv_test_${serverInfoOrdinal++}`,
|
|
hostname: null,
|
|
version: null,
|
|
features: options?.features ?? { ownedSubscriptions: true },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
},
|
|
triggerClose: (event?: unknown) => onClose(event),
|
|
triggerError: (event?: unknown) => onError(event),
|
|
triggerMessage: (data: unknown) => onMessage(data),
|
|
};
|
|
}
|
|
|
|
function wrapSessionMessage(message: unknown): string {
|
|
return JSON.stringify({
|
|
type: "session",
|
|
message,
|
|
});
|
|
}
|
|
|
|
function assertStr(data: string | Uint8Array | ArrayBuffer | undefined): string {
|
|
if (typeof data !== "string") throw new Error("Expected string frame");
|
|
return data;
|
|
}
|
|
|
|
function assertUint8Array(data: string | Uint8Array | ArrayBuffer | undefined): Uint8Array {
|
|
if (data instanceof Uint8Array) return data;
|
|
if (data instanceof ArrayBuffer) return new Uint8Array(data);
|
|
throw new Error("Expected binary frame");
|
|
}
|
|
|
|
function parseSentFrame(
|
|
data: string | Uint8Array | ArrayBuffer | undefined,
|
|
): Record<string, unknown> {
|
|
return z
|
|
.object({
|
|
type: z.literal("session"),
|
|
message: z.record(z.string(), z.unknown()),
|
|
})
|
|
.parse(JSON.parse(assertStr(data))).message;
|
|
}
|
|
|
|
function respondToScheduleRequest(
|
|
mock: ReturnType<typeof createMockTransport>,
|
|
request: Record<string, unknown>,
|
|
): void {
|
|
const responseType =
|
|
request.type === "schedule/create" ? "schedule/create/response" : "schedule/update/response";
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: responseType,
|
|
payload: {
|
|
requestId: request.requestId,
|
|
schedule: null,
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
}
|
|
|
|
const clients: DaemonClient[] = [];
|
|
|
|
afterEach(async () => {
|
|
await Promise.all(clients.map((client) => client.close()));
|
|
clients.length = 0;
|
|
vi.useRealTimers();
|
|
vi.unstubAllGlobals();
|
|
});
|
|
|
|
test("traces WebSocket frames, message types, and JSON parse duration", async () => {
|
|
const mock = createMockTransport();
|
|
const recorder = createTraceRecorder();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "trace_unit_test",
|
|
transportFactory: () => mock.transport,
|
|
reconnect: { enabled: false },
|
|
trace: recorder.trace,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connectPromise;
|
|
|
|
expect(recorder.records).toEqual([
|
|
{
|
|
phase: "begin",
|
|
name: "paseo.ws.message.outbound",
|
|
args: { envelopeType: "hello", messageType: "hello" },
|
|
},
|
|
{ phase: "end" },
|
|
{
|
|
phase: "begin",
|
|
name: "paseo.ws.frame.outbound",
|
|
args: { kind: "text", size: expect.any(String) },
|
|
},
|
|
{ phase: "end" },
|
|
{
|
|
phase: "begin",
|
|
name: "paseo.ws.frame.inbound",
|
|
args: { kind: "text", size: expect.any(String) },
|
|
},
|
|
{
|
|
phase: "begin",
|
|
name: "paseo.ws.json.parse",
|
|
args: { size: expect.any(String) },
|
|
},
|
|
{ phase: "end" },
|
|
{
|
|
phase: "begin",
|
|
name: "paseo.ws.message.inbound",
|
|
args: { envelopeType: "session", messageType: "status" },
|
|
},
|
|
{ phase: "end" },
|
|
{ phase: "end" },
|
|
]);
|
|
});
|
|
|
|
test("does not infer browser automation capabilities from Electron runtime", async () => {
|
|
vi.stubGlobal("navigator", {
|
|
userAgent:
|
|
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) Paseo/0.1.89 Chrome/146 Electron/41.2.0 Safari/537.36",
|
|
});
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "electron_unit_test",
|
|
transportFactory: () => mock.transport,
|
|
reconnect: { enabled: false },
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connectPromise;
|
|
|
|
const hello = z
|
|
.object({
|
|
type: z.literal("hello"),
|
|
capabilities: z.record(z.string(), z.unknown()),
|
|
})
|
|
.parse(JSON.parse(assertStr(mock.sent[0])));
|
|
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toBeUndefined();
|
|
expect(hello.capabilities[CLIENT_CAPS.selectiveAgentTimeline]).toBe(true);
|
|
});
|
|
|
|
test("advertises consumer-provided browser automation capabilities", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "browser_capability_unit_test",
|
|
transportFactory: () => mock.transport,
|
|
reconnect: { enabled: false },
|
|
capabilities: {
|
|
[CLIENT_CAPS.browserHost]: {
|
|
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
|
|
hostKind: "desktop app",
|
|
},
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connectPromise;
|
|
|
|
const hello = z
|
|
.object({
|
|
type: z.literal("hello"),
|
|
capabilities: z.record(z.string(), z.unknown()),
|
|
})
|
|
.parse(JSON.parse(assertStr(mock.sent[0])));
|
|
expect(hello.capabilities[CLIENT_CAPS.browserHost]).toEqual({
|
|
supportedCommands: [...BROWSER_AUTOMATION_COMMAND_NAMES],
|
|
hostKind: "desktop app",
|
|
});
|
|
});
|
|
|
|
test("retry-safe creation rejects older hosts before sending any request", async () => {
|
|
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();
|
|
await connecting;
|
|
await expect(
|
|
client.createAgent({ provider: "codex", cwd: "/project", idempotencyKey: "creation" }),
|
|
).rejects.toThrow("Update the host to use retry-safe agent creation.");
|
|
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("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,
|
|
browser_host: {
|
|
supportedCommands: ["list_tabs"],
|
|
hostKind: "desktop app",
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
test("allows callers to disable default client capabilities", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_capability_override_test",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
capabilities: {
|
|
[CLIENT_CAPS.projectUpdates]: false,
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ preserveSent: true });
|
|
await connectPromise;
|
|
|
|
const hello = z
|
|
.object({
|
|
type: z.literal("hello"),
|
|
capabilities: z.record(z.string(), z.unknown()),
|
|
})
|
|
.parse(JSON.parse(assertStr(mock.sent[0])));
|
|
expect(hello.capabilities[CLIENT_CAPS.projectUpdates]).toBe(false);
|
|
});
|
|
|
|
test("sends new-agent run options when creating schedules", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.scheduleCreate({
|
|
requestId: "request-1",
|
|
prompt: "Run the task",
|
|
cadence: { type: "cron", expression: "* * * * *" },
|
|
target: {
|
|
type: "new-agent",
|
|
config: {
|
|
provider: "claude",
|
|
cwd: "/tmp/project",
|
|
thinkingOptionId: "think-hard",
|
|
archiveOnFinish: false,
|
|
isolation: "worktree",
|
|
},
|
|
},
|
|
});
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "schedule/create",
|
|
requestId: "request-1",
|
|
prompt: "Run the task",
|
|
cadence: { type: "cron", expression: "* * * * *" },
|
|
target: {
|
|
type: "new-agent",
|
|
config: {
|
|
provider: "claude",
|
|
cwd: "/tmp/project",
|
|
thinkingOptionId: "think-hard",
|
|
archiveOnFinish: false,
|
|
isolation: "worktree",
|
|
},
|
|
},
|
|
});
|
|
|
|
respondToScheduleRequest(mock, request);
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: "request-1",
|
|
schedule: null,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("sends new-agent run options when updating schedules", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const updatePromise = client.scheduleUpdate({
|
|
id: "schedule-1",
|
|
requestId: "request-1",
|
|
newAgentConfig: {
|
|
thinkingOptionId: "think-hard",
|
|
archiveOnFinish: false,
|
|
isolation: "worktree",
|
|
},
|
|
});
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "schedule/update",
|
|
requestId: "request-1",
|
|
scheduleId: "schedule-1",
|
|
newAgentConfig: {
|
|
thinkingOptionId: "think-hard",
|
|
archiveOnFinish: false,
|
|
isolation: "worktree",
|
|
},
|
|
});
|
|
|
|
respondToScheduleRequest(mock, request);
|
|
await expect(updatePromise).resolves.toEqual({
|
|
requestId: "request-1",
|
|
schedule: null,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("sends typed browser automation execute responses", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
client.sendBrowserAutomationExecuteResponse({
|
|
type: "browser.automation.execute.response",
|
|
payload: {
|
|
requestId: "req-1",
|
|
ok: true,
|
|
result: { command: "list_tabs", tabs: [] },
|
|
},
|
|
});
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "browser.automation.execute.response",
|
|
payload: {
|
|
requestId: "req-1",
|
|
ok: true,
|
|
result: { command: "list_tabs", tabs: [] },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("does not reconnect after close when ensureConnected is called", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
|
|
await client.close();
|
|
expect(client.getConnectionState().status).toBe("disposed");
|
|
|
|
client.ensureConnected();
|
|
expect(client.getConnectionState().status).toBe("disposed");
|
|
});
|
|
|
|
test("ensureConnected reconnects immediately without leaving the scheduled retry armed", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const first = createMockTransport();
|
|
const second = createMockTransport();
|
|
const third = createMockTransport();
|
|
const transports = [first, second, third];
|
|
let transportIndex = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_immediate_reconnect",
|
|
reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 },
|
|
transportFactory: () => {
|
|
const transport = transports[transportIndex];
|
|
if (!transport) throw new Error("unexpected extra reconnect");
|
|
transportIndex += 1;
|
|
return transport.transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const initialConnect = client.connect();
|
|
first.triggerOpen();
|
|
await initialConnect;
|
|
first.triggerClose({ code: 1001, reason: "app resumed" });
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
|
|
client.ensureConnected();
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
expect(transportIndex).toBe(2);
|
|
|
|
second.triggerOpen();
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
await vi.advanceTimersByTimeAsync(1_500);
|
|
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
expect(transportIndex).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("connect during a scheduled retry reconnects immediately without leaving the retry armed", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const first = createMockTransport();
|
|
const second = createMockTransport();
|
|
const third = createMockTransport();
|
|
const transports = [first, second, third];
|
|
let transportIndex = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_explicit_reconnect",
|
|
reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 },
|
|
transportFactory: () => {
|
|
const transport = transports[transportIndex];
|
|
if (!transport) throw new Error("unexpected extra reconnect");
|
|
transportIndex += 1;
|
|
return transport.transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const initialConnect = client.connect();
|
|
first.triggerOpen();
|
|
await initialConnect;
|
|
first.triggerClose({ code: 1001, reason: "daemon restarted" });
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
|
|
const reconnect = client.connect();
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
expect(transportIndex).toBe(2);
|
|
|
|
second.triggerOpen();
|
|
await reconnect;
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
await vi.advanceTimersByTimeAsync(1_500);
|
|
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
expect(transportIndex).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("disabling reconnect cancels a pending retry until explicitly resumed", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const first = createMockTransport();
|
|
const second = createMockTransport();
|
|
const transports = [first, second];
|
|
let transportIndex = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_paused_reconnect",
|
|
reconnect: { enabled: true, baseDelayMs: 1_500, maxDelayMs: 1_500 },
|
|
transportFactory: () => {
|
|
const transport = transports[transportIndex];
|
|
if (!transport) throw new Error("unexpected extra reconnect");
|
|
transportIndex += 1;
|
|
return transport.transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const initialConnect = client.connect();
|
|
first.triggerOpen();
|
|
await initialConnect;
|
|
first.triggerClose({ code: 1001, reason: "app backgrounded" });
|
|
|
|
client.setReconnectEnabled(false);
|
|
await vi.advanceTimersByTimeAsync(1_500);
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
expect(transportIndex).toBe(1);
|
|
|
|
client.setReconnectEnabled(true);
|
|
client.ensureConnected();
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
expect(transportIndex).toBe(2);
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("keeps the transport connected when a session RPC ping times out", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
|
|
await expect(client.ping({ timeoutMs: 1 })).rejects.toThrow("Timeout waiting for message");
|
|
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
});
|
|
|
|
test("waits for the daemon to acknowledge push token revocation", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_push_revocation",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { pushTokenRevocation: true } });
|
|
await connectPromise;
|
|
|
|
const revocation = client.unregisterPushToken("ExponentPushToken[test-device]");
|
|
const request = parseSentFrame(mock.sent.at(-1));
|
|
expect(request).toMatchObject({
|
|
type: "push.unregister.request",
|
|
token: "ExponentPushToken[test-device]",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "push.unregister.response",
|
|
payload: { requestId: request.requestId },
|
|
}),
|
|
);
|
|
|
|
await revocation;
|
|
});
|
|
|
|
test("bounds the wait for push token revocation", async () => {
|
|
useHeartbeatClock();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_push_revocation_timeout",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { pushTokenRevocation: true } });
|
|
await connectPromise;
|
|
|
|
const revocation = client.unregisterPushToken("ExponentPushToken[test-device]");
|
|
const rejection = expect(revocation).rejects.toThrow("Timeout waiting for message (2000ms)");
|
|
await vi.advanceTimersByTimeAsync(1_999);
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await rejection;
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
});
|
|
|
|
test("defaults session RPC waiters to sixty seconds", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.fetchAgent({
|
|
agentId: "agent-1",
|
|
requestId: "req-agent-1",
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agent_request",
|
|
requestId: "req-agent-1",
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(59_999);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (60000ms)");
|
|
});
|
|
|
|
test("honors explicit fetchAgent timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.fetchAgent({
|
|
agentId: "agent-1",
|
|
requestId: "req-agent-1",
|
|
timeout: 5_000,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agent_request",
|
|
requestId: "req-agent-1",
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(4_999);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (5000ms)");
|
|
});
|
|
|
|
test("preserves legacy fetchAgent id overload", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.fetchAgent("agent-1", "req-agent-legacy");
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agent_request",
|
|
requestId: "req-agent-legacy",
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fetch_agent_response",
|
|
payload: {
|
|
requestId: "req-agent-legacy",
|
|
agent: null,
|
|
project: null,
|
|
error: "legacy fetch sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(responsePromise).rejects.toThrow("legacy fetch sentinel");
|
|
});
|
|
|
|
test("honors explicit fetchAgentTimeline timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.fetchAgentTimeline("agent-1", {
|
|
requestId: "req-timeline-1",
|
|
direction: "tail",
|
|
limit: 0,
|
|
projection: "projected",
|
|
timeout: 2_000,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agent_timeline_request",
|
|
requestId: "req-timeline-1",
|
|
agentId: "agent-1",
|
|
direction: "tail",
|
|
limit: 0,
|
|
projection: "projected",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_999);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (2000ms)");
|
|
});
|
|
|
|
test("lists the full agent prompt index", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.listAgentTimelinePrompts("agent-1", {
|
|
requestId: "req-prompts-1",
|
|
});
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "agent.timeline.list_prompts.request",
|
|
requestId: "req-prompts-1",
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent.timeline.list_prompts.response",
|
|
payload: {
|
|
requestId: "req-prompts-1",
|
|
agentId: "agent-1",
|
|
epoch: "epoch-1",
|
|
prompts: [{ seq: 1, timestamp: "2026-01-01T00:00:00.000Z", preview: "First prompt" }],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(responsePromise).resolves.toMatchObject({
|
|
epoch: "epoch-1",
|
|
prompts: [{ seq: 1, preview: "First prompt" }],
|
|
});
|
|
});
|
|
|
|
test("honors explicit fetchAgents timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.fetchAgents({
|
|
requestId: "req-agents-1",
|
|
scope: "active",
|
|
timeout: 1_200,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agents_request",
|
|
requestId: "req-agents-1",
|
|
scope: "active",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_199);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1200ms)");
|
|
});
|
|
|
|
test("honors explicit shutdownServer timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.shutdownServer({
|
|
requestId: "req-shutdown-1",
|
|
timeout: 1_500,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "shutdown_server_request",
|
|
requestId: "req-shutdown-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_499);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
|
|
});
|
|
|
|
test("honors explicit getDaemonStatus timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { daemonStatusRpc: true } });
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.getDaemonStatus({
|
|
requestId: "req-daemon-status-1",
|
|
timeout: 1_500,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "daemon.get_status.request",
|
|
requestId: "req-daemon-status-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_499);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
|
|
});
|
|
|
|
test("honors explicit getDaemonPairingOffer timeout below the session RPC default", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.getDaemonPairingOffer({
|
|
requestId: "req-pairing-offer-1",
|
|
timeout: 1_500,
|
|
});
|
|
let settled = false;
|
|
void responsePromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "daemon.get_pairing_offer.request",
|
|
requestId: "req-pairing-offer-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(1_499);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(responsePromise).rejects.toThrow("Timeout waiting for message (1500ms)");
|
|
});
|
|
|
|
test("gates config reload on the daemon capability", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
await expect(client.reloadDaemonConfig("reload-old-host")).rejects.toThrow(
|
|
"Update the host to reload daemon configuration.",
|
|
);
|
|
expect(mock.sent).toEqual([]);
|
|
});
|
|
|
|
test("sends and parses daemon config reload", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { daemonConfigReload: true } });
|
|
await connectPromise;
|
|
|
|
const response = client.reloadDaemonConfig("reload-new-host");
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "daemon.config.reload.request",
|
|
requestId: "reload-new-host",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "daemon.config.reload.response",
|
|
payload: {
|
|
requestId: "reload-new-host",
|
|
appliedPaths: ["daemon.browserTools.enabled"],
|
|
restartRequiredPaths: ["daemon.listen"],
|
|
overrideControlledPaths: [],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(response).resolves.toEqual({
|
|
requestId: "reload-new-host",
|
|
appliedPaths: ["daemon.browserTools.enabled"],
|
|
restartRequiredPaths: ["daemon.listen"],
|
|
overrideControlledPaths: [],
|
|
});
|
|
});
|
|
|
|
test("gets a structured plugin log snapshot", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { pluginLogs: true } });
|
|
await connectPromise;
|
|
|
|
const response = client.getPluginLogs("example");
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({ type: "plugin.logs.get.request", pluginId: "example" });
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "plugin.logs.get.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
pluginId: "example",
|
|
entries: [
|
|
{
|
|
sequence: 3,
|
|
timestamp: "2026-08-16T12:00:00.000Z",
|
|
stream: "stdout",
|
|
message: "ready",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(response).resolves.toEqual([
|
|
{
|
|
sequence: 3,
|
|
timestamp: "2026-08-16T12:00:00.000Z",
|
|
stream: "stdout",
|
|
message: "ready",
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("keeps waitForAgentUpsert initial fetch inside the requested deadline", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const waitPromise = client.waitForAgentUpsert("agent-1", () => false, 5_000);
|
|
let settled = false;
|
|
void waitPromise.then(
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
() => {
|
|
settled = true;
|
|
return undefined;
|
|
},
|
|
);
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "fetch_agent_request",
|
|
requestId: expect.any(String),
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
await vi.advanceTimersByTimeAsync(4_999);
|
|
expect(settled).toBe(false);
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(waitPromise).rejects.toThrow("Timed out waiting for agent agent-1");
|
|
});
|
|
|
|
test("keeps default connect timeout shorter than session RPC waiters", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
let settled = false;
|
|
const pendingConnect = client.connect().then(
|
|
() => {
|
|
settled = true;
|
|
return { ok: true as const };
|
|
},
|
|
(error) => {
|
|
settled = true;
|
|
return { ok: false as const, error };
|
|
},
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(14_999);
|
|
expect(settled).toBe(false);
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
const result = await pendingConnect;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toBeInstanceOf(Error);
|
|
if (result.error instanceof Error) {
|
|
expect(result.error.message).toContain("Connection timed out");
|
|
}
|
|
}
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("foreground verification replaces a silently broken socket without waiting for heartbeats", async () => {
|
|
useHeartbeatClock();
|
|
const first = new FakeDaemon();
|
|
const second = new FakeDaemon();
|
|
first.daemonGoesSilent();
|
|
let attempts = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "foreground-recovery",
|
|
logger: noopLogger,
|
|
transportFactory: () => (++attempts === 1 ? first : second).transport,
|
|
});
|
|
clients.push(client);
|
|
const connection = client.connect();
|
|
first.openConnection();
|
|
await connection;
|
|
|
|
client.ensureConnected({ verify: true });
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(attempts).toBe(2);
|
|
second.openConnection();
|
|
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
|
});
|
|
|
|
test("foreground verification preserves a healthy socket and deduplicates simultaneous checks", async () => {
|
|
useHeartbeatClock();
|
|
const daemon = new FakeDaemon();
|
|
daemon.daemonAnswersPingsAfter("0.1s");
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "healthy-resume",
|
|
logger: noopLogger,
|
|
transportFactory: () => daemon.transport,
|
|
});
|
|
clients.push(client);
|
|
const connection = client.connect();
|
|
daemon.openConnection();
|
|
await connection;
|
|
client.ensureConnected({ verify: true });
|
|
client.ensureConnected({ verify: true });
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
|
expect(daemon.pingTimestamps()).toEqual(["0s"]);
|
|
expect(daemon.closesFromClient()).toEqual([]);
|
|
});
|
|
|
|
test("an obsolete foreground probe cannot close a replacement connection", async () => {
|
|
useHeartbeatClock();
|
|
const first = new FakeDaemon();
|
|
const second = new FakeDaemon();
|
|
first.daemonGoesSilent();
|
|
let attempts = 0;
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "resume-race",
|
|
logger: noopLogger,
|
|
transportFactory: () => (++attempts === 1 ? first : second).transport,
|
|
});
|
|
clients.push(client);
|
|
const connection = client.connect();
|
|
first.openConnection();
|
|
await connection;
|
|
client.ensureConnected({ verify: true });
|
|
first.daemonClosesWith("network changed");
|
|
client.ensureConnected();
|
|
second.openConnection();
|
|
await vi.advanceTimersByTimeAsync(3_000);
|
|
expect(attempts).toBe(2);
|
|
expect(client.getConnectionState()).toEqual({ status: "connected" });
|
|
expect(second.closesFromClient()).toEqual([]);
|
|
});
|
|
|
|
test("stays online through ten minutes of pongs that arrive five seconds late", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("5.5s");
|
|
|
|
await session.connect();
|
|
await session.advance(10 * 60 * 1000);
|
|
|
|
expect(session.state()).toEqual({ status: "connected" });
|
|
expect(session.teardownCount()).toBe(0);
|
|
expect(session.lastError()).toBeNull();
|
|
expect(session.pingTimestamps().length).toBeGreaterThan(0);
|
|
expect(session.lastLivenessRttMs()).toBe(5500);
|
|
});
|
|
|
|
test("tears down and reports a liveness timeout after the daemon goes silent for two cycles", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonGoesSilent();
|
|
|
|
await session.connect();
|
|
await session.advance(51_000);
|
|
|
|
expect(session.state()).toEqual({
|
|
status: "disconnected",
|
|
reason: "Liveness check timed out (15000ms)",
|
|
});
|
|
expect(session.teardownCount()).toBe(1);
|
|
});
|
|
|
|
test("survives a single missed pong when answers resume", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonWithholdsNextPongThenAnswersFast();
|
|
|
|
await session.connect();
|
|
await session.advance(40_000);
|
|
|
|
expect(session.state()).toEqual({ status: "connected" });
|
|
expect(session.teardownCount()).toBe(0);
|
|
});
|
|
|
|
test("keeps the connection proven online from heartbeats alone when no other traffic flows", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("fast");
|
|
|
|
await session.connect();
|
|
await session.advance(35_000);
|
|
|
|
expect(session.state()).toEqual({ status: "connected" });
|
|
expect(session.pingTimestamps()).toEqual(["10s", "20s", "30s"]);
|
|
});
|
|
|
|
test("starts pinging one interval after connecting and holds the cadence", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("fast");
|
|
|
|
await session.connect();
|
|
await session.advance(9_999);
|
|
expect(session.pingTimestamps()).toEqual([]);
|
|
|
|
await session.advance(20_001);
|
|
expect(session.pingTimestamps()).toEqual(["10s", "20s", "30s"]);
|
|
});
|
|
|
|
test("stops pinging once the connection is gone", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("fast");
|
|
|
|
await session.connect();
|
|
await session.advance(10_000);
|
|
session.daemonClosesWith("daemon shutting down");
|
|
await session.advance(30_000);
|
|
|
|
expect(session.pingTimestamps()).toEqual(["10s"]);
|
|
});
|
|
|
|
test("sends only one ping while a slow pong is still outstanding", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("15.1s");
|
|
|
|
await session.connect();
|
|
await session.advance(20_000);
|
|
|
|
expect(session.pingTimestamps()).toEqual(["10s"]);
|
|
});
|
|
|
|
test("reports the round-trip time of the last successful heartbeat", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonAnswersPingsAfter("2.3s");
|
|
|
|
await session.connect();
|
|
await session.advance(12_300);
|
|
|
|
expect(session.lastLivenessRttMs()).toBe(2300);
|
|
});
|
|
|
|
test("treats a pong just under the timeout as alive and just over as a miss", async () => {
|
|
useHeartbeatClock();
|
|
const alive = new DaemonClientSession();
|
|
alive.daemonAnswersPingsAfter("14.9s");
|
|
|
|
await alive.connect();
|
|
await alive.advance(24_900);
|
|
|
|
expect(alive.state()).toEqual({ status: "connected" });
|
|
|
|
const missed = new DaemonClientSession();
|
|
missed.daemonAnswersPingsAfter("15.1s");
|
|
|
|
await missed.connect();
|
|
await missed.advance(25_000);
|
|
|
|
expect(missed.state()).toEqual({ status: "connected" });
|
|
expect(missed.lastLivenessRttMs()).toBeNull();
|
|
});
|
|
|
|
test("goes red with the daemon's reason when the connection is closed", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
|
|
await session.connect();
|
|
session.daemonClosesWith("Control unresponsive");
|
|
|
|
expect(session.state()).toEqual({
|
|
status: "disconnected",
|
|
reason: "Control unresponsive",
|
|
});
|
|
expect(session.lastError()).toBe("Control unresponsive");
|
|
expect(session.closesFromClient()).toEqual([]);
|
|
});
|
|
|
|
test("two candidate latency timeouts do not tear down the live connection", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonGoesSilent();
|
|
|
|
await session.connect();
|
|
const firstMeasurement = session.measureLatency({ timeoutMs: 1000 });
|
|
const firstMeasurementError = firstMeasurement.then(
|
|
() => null,
|
|
(error) => error,
|
|
);
|
|
await session.advance(1000);
|
|
|
|
await expect(firstMeasurementError).resolves.toEqual(
|
|
expect.objectContaining({
|
|
message: "Latency measurement timed out (1000ms)",
|
|
}),
|
|
);
|
|
|
|
const secondMeasurement = session.measureLatency({ timeoutMs: 1000 });
|
|
const secondMeasurementError = secondMeasurement.then(
|
|
() => null,
|
|
(error) => error,
|
|
);
|
|
await session.advance(1000);
|
|
|
|
await expect(secondMeasurementError).resolves.toEqual(
|
|
expect.objectContaining({
|
|
message: "Latency measurement timed out (1000ms)",
|
|
}),
|
|
);
|
|
expect(session.state()).toEqual({ status: "connected" });
|
|
expect(session.teardownCount()).toBe(0);
|
|
expect(session.pingTimestamps()).toEqual(["0s", "1s"]);
|
|
});
|
|
|
|
test("a candidate measurement that times out under a heartbeat tick does not count toward teardown", async () => {
|
|
useHeartbeatClock();
|
|
const session = new DaemonClientSession();
|
|
session.daemonGoesSilent();
|
|
|
|
await session.connect();
|
|
|
|
// A candidate measurement is still in flight when the +10s heartbeat tick lands,
|
|
// so the heartbeat shares the in-flight ping. Let the measurement time out.
|
|
await session.advance(9_000);
|
|
const measurement = session.measureLatency({ timeoutMs: 5_000 });
|
|
const measurementError = measurement.then(
|
|
() => null,
|
|
(error) => error,
|
|
);
|
|
await session.advance(5_500);
|
|
await expect(measurementError).resolves.toEqual(
|
|
expect.objectContaining({ message: "Latency measurement timed out (5000ms)" }),
|
|
);
|
|
|
|
// The measurement timeout must not have been recorded as a liveness failure: a
|
|
// single genuine heartbeat miss after it must still leave the connection up.
|
|
await session.advance(25_000);
|
|
|
|
expect(session.state()).toEqual({ status: "connected" });
|
|
expect(session.teardownCount()).toBe(0);
|
|
});
|
|
|
|
test("file context action RPCs correlate success and error responses", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_context_actions",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createFileEntry({
|
|
cwd: "/tmp/project",
|
|
parentPath: "src",
|
|
name: "new.ts",
|
|
kind: "file",
|
|
});
|
|
const createRequest = parseSentFrame(mock.sent.at(-1));
|
|
expect(createRequest).toEqual({
|
|
type: "fs.entry.create.request",
|
|
cwd: "/tmp/project",
|
|
parentPath: "src",
|
|
name: "new.ts",
|
|
kind: "file",
|
|
requestId: expect.any(String),
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fs.entry.create.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
parentPath: "src",
|
|
path: "src/new.ts",
|
|
success: true,
|
|
error: null,
|
|
requestId: createRequest.requestId,
|
|
},
|
|
}),
|
|
);
|
|
await expect(createPromise).resolves.toMatchObject({
|
|
path: "src/new.ts",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
|
|
const renamePromise = client.renameFileEntry({
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
name: "existing.ts",
|
|
});
|
|
const renameRequest = parseSentFrame(mock.sent.at(-1));
|
|
expect(renameRequest).toMatchObject({
|
|
type: "fs.entry.rename.request",
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
name: "existing.ts",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fs.entry.rename.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
renamedPath: null,
|
|
success: false,
|
|
error: '"existing.ts" already exists',
|
|
requestId: renameRequest.requestId,
|
|
},
|
|
}),
|
|
);
|
|
await expect(renamePromise).resolves.toMatchObject({
|
|
renamedPath: null,
|
|
success: false,
|
|
error: '"existing.ts" already exists',
|
|
});
|
|
|
|
const duplicatePromise = client.duplicateFileEntry({
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
});
|
|
const duplicateRequest = parseSentFrame(mock.sent.at(-1));
|
|
expect(duplicateRequest).toMatchObject({
|
|
type: "fs.entry.duplicate.request",
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fs.entry.duplicate.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
duplicatedPath: "src/new copy.ts",
|
|
success: true,
|
|
error: null,
|
|
requestId: duplicateRequest.requestId,
|
|
},
|
|
}),
|
|
);
|
|
await expect(duplicatePromise).resolves.toMatchObject({
|
|
duplicatedPath: "src/new copy.ts",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
|
|
const deletePromise = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/new.ts" });
|
|
const deleteRequest = parseSentFrame(mock.sent.at(-1));
|
|
expect(deleteRequest).toMatchObject({
|
|
type: "fs.entry.delete.request",
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fs.entry.delete.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
path: "src/new.ts",
|
|
success: true,
|
|
error: null,
|
|
requestId: deleteRequest.requestId,
|
|
},
|
|
}),
|
|
);
|
|
await expect(deletePromise).resolves.toMatchObject({ success: true, error: null });
|
|
|
|
const discardPromise = client.checkoutDiscardChanges("/tmp/project", { paths: ["src"] });
|
|
const discardRequest = parseSentFrame(mock.sent.at(-1));
|
|
expect(discardRequest).toMatchObject({
|
|
type: "checkout.discard_changes.request",
|
|
cwd: "/tmp/project",
|
|
paths: ["src"],
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "checkout.discard_changes.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
success: false,
|
|
error: { code: "NOT_GIT_REPO", message: "Not a git repository" },
|
|
requestId: discardRequest.requestId,
|
|
},
|
|
}),
|
|
);
|
|
await expect(discardPromise).resolves.toMatchObject({
|
|
success: false,
|
|
error: { code: "NOT_GIT_REPO", message: "Not a git repository" },
|
|
});
|
|
});
|
|
|
|
test("serializes plugin source suffixes through the legacy path field", 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();
|
|
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",
|
|
pluginPath: "plugins/review",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "plugin.source.install.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
plugin: {
|
|
id: "review",
|
|
path: "/plugins/review",
|
|
enabled: true,
|
|
status: "running",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(installPromise).resolves.toMatchObject({ id: "review", status: "running" });
|
|
});
|
|
|
|
test("a connection loss rejects an in-flight file context action", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_context_disconnect",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const pending = client.deleteFileEntry({ cwd: "/tmp/project", path: "src/file.ts" });
|
|
mock.triggerClose({ code: 1006, reason: "network lost" });
|
|
|
|
await expect(pending).rejects.toThrow(/network lost|disconnected|closed/i);
|
|
});
|
|
|
|
test("listDirectory sends a list file explorer request and returns directory entries", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.listDirectory("/tmp/project", "src", "req-list");
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
cwd: "/tmp/project",
|
|
path: "src",
|
|
mode: "list",
|
|
requestId: "req-list",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "file_explorer_response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
path: "src",
|
|
mode: "list",
|
|
directory: {
|
|
path: "src",
|
|
entries: [
|
|
{
|
|
name: "index.ts",
|
|
path: "src/index.ts",
|
|
kind: "file",
|
|
size: 12,
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
],
|
|
},
|
|
file: null,
|
|
error: null,
|
|
requestId: "req-list",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(responsePromise).resolves.toEqual({
|
|
path: "src",
|
|
entries: [
|
|
{
|
|
name: "index.ts",
|
|
path: "src/index.ts",
|
|
kind: "file",
|
|
size: 12,
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("readFile hides legacy base64 behind bytes", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-file");
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
cwd: "/tmp/project",
|
|
path: "logo.png",
|
|
mode: "file",
|
|
acceptBinary: true,
|
|
requestId: "req-file",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "file_explorer_response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
path: "logo.png",
|
|
mode: "file",
|
|
directory: null,
|
|
file: {
|
|
path: "logo.png",
|
|
kind: "image",
|
|
encoding: "base64",
|
|
content: "aGVsbG8=",
|
|
mimeType: "image/png",
|
|
size: 5,
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
error: null,
|
|
requestId: "req-file",
|
|
},
|
|
}),
|
|
);
|
|
|
|
const result = await responsePromise;
|
|
expect(result).toMatchObject({
|
|
mime: "image/png",
|
|
size: 5,
|
|
path: "logo.png",
|
|
kind: "image",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
});
|
|
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
|
});
|
|
|
|
test("readFile resolves from binary file frames when the daemon supports them", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.readFile("/tmp/project", "logo.png", "req-binary");
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
cwd: "/tmp/project",
|
|
path: "logo.png",
|
|
mode: "file",
|
|
acceptBinary: true,
|
|
requestId: "req-binary",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileBegin,
|
|
requestId: "req-binary",
|
|
metadata: {
|
|
mime: "image/png",
|
|
size: 5,
|
|
encoding: "binary",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-binary",
|
|
payload: new TextEncoder().encode("hello"),
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileEnd,
|
|
requestId: "req-binary",
|
|
}),
|
|
);
|
|
|
|
const result = await responsePromise;
|
|
expect(result).toMatchObject({
|
|
mime: "image/png",
|
|
size: 5,
|
|
path: "logo.png",
|
|
kind: "image",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
});
|
|
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
|
});
|
|
|
|
test("readFile drops an old daemon's over-budget binary chunks and reports the refusal", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_file_budget_compat",
|
|
logger: createMockLogger(),
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.readFile("/tmp/project", "large.txt", "req-budget", 10);
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "file_explorer_request",
|
|
cwd: "/tmp/project",
|
|
path: "large.txt",
|
|
mode: "file",
|
|
acceptBinary: true,
|
|
maxBytes: 10,
|
|
requestId: "req-budget",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileBegin,
|
|
requestId: "req-budget",
|
|
metadata: {
|
|
mime: "text/plain",
|
|
size: 100,
|
|
encoding: "utf-8",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
},
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-budget",
|
|
payload: new Uint8Array(100),
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-budget" }),
|
|
);
|
|
|
|
await expect(responsePromise).rejects.toThrow("File is too large to display");
|
|
});
|
|
|
|
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const responsePromise = client.uploadFile({
|
|
fileName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
bytes: new TextEncoder().encode("hello world"),
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
requestId: "req-upload",
|
|
chunkSize: 5,
|
|
});
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "file.upload.request",
|
|
fileName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
size: 11,
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
requestId: "req-upload",
|
|
},
|
|
});
|
|
expect(mock.sent.slice(1).map(assertUint8Array).map(decodeFileTransferFrame)).toEqual([
|
|
{
|
|
opcode: FileTransferOpcode.FileBegin,
|
|
requestId: "req-upload",
|
|
metadata: {
|
|
mime: "text/plain",
|
|
size: 11,
|
|
encoding: "binary",
|
|
modifiedAt: "2026-05-02T00:00:00.000Z",
|
|
fileName: "notes.txt",
|
|
},
|
|
payload: new Uint8Array(),
|
|
},
|
|
{
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-upload",
|
|
payload: new TextEncoder().encode("hello"),
|
|
},
|
|
{
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-upload",
|
|
payload: new TextEncoder().encode(" worl"),
|
|
},
|
|
{
|
|
opcode: FileTransferOpcode.FileChunk,
|
|
requestId: "req-upload",
|
|
payload: new TextEncoder().encode("d"),
|
|
},
|
|
{
|
|
opcode: FileTransferOpcode.FileEnd,
|
|
requestId: "req-upload",
|
|
payload: new Uint8Array(),
|
|
},
|
|
]);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "file.upload.response",
|
|
payload: {
|
|
requestId: "req-upload",
|
|
file: {
|
|
type: "uploaded_file",
|
|
id: "upload_req-upload",
|
|
fileName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
size: 11,
|
|
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
|
|
},
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(responsePromise).resolves.toEqual({
|
|
requestId: "req-upload",
|
|
file: {
|
|
type: "uploaded_file",
|
|
id: "upload_req-upload",
|
|
fileName: "notes.txt",
|
|
mimeType: "text/plain",
|
|
size: 11,
|
|
path: "/tmp/paseo-uploads/upload_req-upload/notes.txt",
|
|
},
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("normalizes workspace_setup_progress into a workspace-scoped daemon event", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const events: Array<Parameters<Parameters<typeof client.subscribe>[0]>[0]> = [];
|
|
client.subscribe((event) => {
|
|
events.push(event);
|
|
});
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "workspace_setup_progress",
|
|
payload: {
|
|
workspaceId: "ws-feature-a",
|
|
status: "running",
|
|
detail: {
|
|
type: "worktree_setup",
|
|
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
|
|
branchName: "feature-a",
|
|
log: "phase-one\n",
|
|
commands: [
|
|
{
|
|
index: 1,
|
|
command: "npm install",
|
|
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
|
log: "phase-one\n",
|
|
status: "running",
|
|
exitCode: null,
|
|
},
|
|
],
|
|
},
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
expect(events).toContainEqual({
|
|
type: "workspace_setup_progress",
|
|
workspaceId: "ws-feature-a",
|
|
payload: {
|
|
workspaceId: "ws-feature-a",
|
|
status: "running",
|
|
detail: {
|
|
type: "worktree_setup",
|
|
worktreePath: "/tmp/project/.paseo/worktrees/feature-a",
|
|
branchName: "feature-a",
|
|
log: "phase-one\n",
|
|
commands: [
|
|
{
|
|
index: 1,
|
|
command: "npm install",
|
|
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
|
log: "phase-one\n",
|
|
status: "running",
|
|
exitCode: null,
|
|
},
|
|
],
|
|
},
|
|
error: null,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("sends create_agent_request with workspace and caller identity", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen({ features: { agentRequestReceipts: true } });
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
idempotencyKey: "one-creation",
|
|
provider: "codex",
|
|
cwd: "/tmp/project/.paseo/worktrees/feature-a",
|
|
workspaceId: "ws-feature-a",
|
|
callerAgentId: "parent-agent",
|
|
title: "Compat agent",
|
|
modeId: "default",
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual(
|
|
expect.objectContaining({
|
|
type: "create_agent_request",
|
|
idempotencyKey: "one-creation",
|
|
workspaceId: "ws-feature-a",
|
|
callerAgentId: "parent-agent",
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: request.requestId,
|
|
error: "compat test sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("compat test sentinel");
|
|
});
|
|
|
|
test("sends worktree target and autoArchive in create_agent_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
worktree: {
|
|
mode: "branch-off",
|
|
newBranch: "agent-lifecycle-dispatch",
|
|
base: "main",
|
|
},
|
|
autoArchive: true,
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual(
|
|
expect.objectContaining({
|
|
type: "create_agent_request",
|
|
worktree: {
|
|
mode: "branch-off",
|
|
newBranch: "agent-lifecycle-dispatch",
|
|
base: "main",
|
|
},
|
|
autoArchive: true,
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: request.requestId,
|
|
error: "worktree auto archive sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("worktree auto archive sentinel");
|
|
});
|
|
|
|
test("sends structured attachments with create_agent_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
initialPrompt: "Review this PR",
|
|
attachments: [
|
|
{
|
|
type: "github_pr",
|
|
mimeType: "application/github-pr",
|
|
number: 123,
|
|
title: "Fix race in worktree setup",
|
|
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
baseRefName: "main",
|
|
headRefName: "fix/worktree-race",
|
|
},
|
|
],
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.attachments).toEqual([
|
|
{
|
|
type: "github_pr",
|
|
mimeType: "application/github-pr",
|
|
number: 123,
|
|
title: "Fix race in worktree setup",
|
|
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
baseRefName: "main",
|
|
headRefName: "fix/worktree-race",
|
|
},
|
|
]);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: request.requestId,
|
|
error: "attachment test sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("attachment test sentinel");
|
|
});
|
|
|
|
test("sends worktree base-ref fields in create_agent_request git options", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
requestId: "req-agent-ref",
|
|
git: {
|
|
createWorktree: true,
|
|
worktreeSlug: "review-pr-123",
|
|
refName: "feature/worktree-base-ref",
|
|
action: "checkout",
|
|
githubPrNumber: 123,
|
|
},
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.git).toEqual({
|
|
createWorktree: true,
|
|
worktreeSlug: "review-pr-123",
|
|
refName: "feature/worktree-base-ref",
|
|
action: "checkout",
|
|
githubPrNumber: 123,
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: request.requestId,
|
|
error: "git ref fields sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("git ref fields sentinel");
|
|
});
|
|
|
|
test("omitting create_agent_request worktree base-ref fields preserves legacy wire shape", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createAgent({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
requestId: "req-agent-legacy",
|
|
git: {
|
|
createWorktree: true,
|
|
worktreeSlug: "feature-a",
|
|
},
|
|
});
|
|
|
|
expect(assertStr(mock.sent[0])).toBe(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "create_agent_request",
|
|
config: {
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
},
|
|
git: {
|
|
createWorktree: true,
|
|
worktreeSlug: "feature-a",
|
|
},
|
|
labels: {},
|
|
requestId: "req-agent-legacy",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_create_failed",
|
|
requestId: "req-agent-legacy",
|
|
error: "legacy git shape sentinel",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).rejects.toThrow("legacy git shape sentinel");
|
|
});
|
|
|
|
test("sends structured first-agent context attachments with create_paseo_worktree_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createPaseoWorktree({
|
|
cwd: "/tmp/project",
|
|
worktreeSlug: "review-pr-123",
|
|
firstAgentContext: {
|
|
attachments: [
|
|
{
|
|
type: "github_pr",
|
|
mimeType: "application/github-pr",
|
|
number: 123,
|
|
title: "Fix race in worktree setup",
|
|
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
const firstAgentContext = z
|
|
.object({ attachments: z.array(z.unknown()) })
|
|
.parse(request.firstAgentContext);
|
|
expect(firstAgentContext.attachments).toEqual([
|
|
{
|
|
type: "github_pr",
|
|
mimeType: "application/github-pr",
|
|
number: 123,
|
|
title: "Fix race in worktree setup",
|
|
url: "https://github.com/getpaseo/paseo/pull/123",
|
|
},
|
|
]);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "create_paseo_worktree_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: "worktree attachment sentinel",
|
|
setupTerminalId: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: "worktree attachment sentinel",
|
|
setupTerminalId: null,
|
|
});
|
|
});
|
|
|
|
test("sends project.add.request without creating a workspace", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const addPromise = client.addProject("/tmp/project", "req-add-project");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "project.add.request",
|
|
requestId: "req-add-project",
|
|
cwd: "/tmp/project",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "project.add.response",
|
|
payload: {
|
|
requestId: "req-add-project",
|
|
project: {
|
|
projectId: "/tmp/project",
|
|
projectDisplayName: "project",
|
|
projectCustomName: null,
|
|
projectRootPath: "/tmp/project",
|
|
projectKind: "git",
|
|
},
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(addPromise).resolves.toEqual({
|
|
requestId: "req-add-project",
|
|
project: {
|
|
projectId: "/tmp/project",
|
|
projectDisplayName: "project",
|
|
projectCustomName: null,
|
|
projectRootPath: "/tmp/project",
|
|
projectKind: "git",
|
|
},
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("marks a workspace unread through the dotted RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const markPromise = client.markWorkspaceUnread("workspace-1", "req-mark-unread");
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "workspace.mark_unread.request",
|
|
workspaceId: "workspace-1",
|
|
requestId: "req-mark-unread",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "workspace.mark_unread.response",
|
|
payload: {
|
|
requestId: "req-mark-unread",
|
|
workspaceId: "workspace-1",
|
|
markedAgentId: "agent-1",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(markPromise).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("searches GitHub repositories through the dotted RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const searchPromise = client.searchGithubRepositories(
|
|
{ query: "paseo", limit: 10 },
|
|
"req-repositories",
|
|
);
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "workspace.github.search_repositories.request",
|
|
query: "paseo",
|
|
limit: 10,
|
|
requestId: "req-repositories",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "workspace.github.search_repositories.response",
|
|
payload: {
|
|
status: "success",
|
|
requestId: "req-repositories",
|
|
repositories: [
|
|
{
|
|
id: "R_paseo",
|
|
name: "paseo",
|
|
nameWithOwner: "getpaseo/paseo",
|
|
description: "Development environment in your pocket",
|
|
visibility: "public",
|
|
updatedAt: "2026-07-15T10:00:00Z",
|
|
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
|
},
|
|
],
|
|
available: true,
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(searchPromise).resolves.toEqual({
|
|
status: "success",
|
|
requestId: "req-repositories",
|
|
repositories: [
|
|
{
|
|
id: "R_paseo",
|
|
name: "paseo",
|
|
nameWithOwner: "getpaseo/paseo",
|
|
description: "Development environment in your pocket",
|
|
visibility: "public",
|
|
updatedAt: "2026-07-15T10:00:00Z",
|
|
cloneUrl: "git@github.com:getpaseo/paseo.git",
|
|
},
|
|
],
|
|
available: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("creates and registers a project directory through the dotted RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createProjectDirectory(
|
|
{ parentPath: "/tmp/projects", name: "new-project" },
|
|
"req-create-project-directory",
|
|
);
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "project.create_directory.request",
|
|
parentPath: "/tmp/projects",
|
|
name: "new-project",
|
|
requestId: "req-create-project-directory",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "project.create_directory.response",
|
|
payload: {
|
|
requestId: "req-create-project-directory",
|
|
directoryPath: "/tmp/projects/new-project",
|
|
project: {
|
|
projectId: "directory:/tmp/projects/new-project",
|
|
projectDisplayName: "new-project",
|
|
projectCustomName: null,
|
|
projectRootPath: "/tmp/projects/new-project",
|
|
projectKind: "non_git",
|
|
},
|
|
error: null,
|
|
errorCode: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: "req-create-project-directory",
|
|
directoryPath: "/tmp/projects/new-project",
|
|
project: {
|
|
projectId: "directory:/tmp/projects/new-project",
|
|
projectDisplayName: "new-project",
|
|
projectCustomName: null,
|
|
projectRootPath: "/tmp/projects/new-project",
|
|
projectKind: "non_git",
|
|
},
|
|
error: null,
|
|
errorCode: null,
|
|
});
|
|
});
|
|
|
|
test("sends first-agent prompt context with workspace.create.request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createWorkspace(
|
|
{
|
|
source: {
|
|
kind: "directory",
|
|
path: "/tmp/project",
|
|
projectId: "local:/tmp/project",
|
|
},
|
|
firstAgentContext: {
|
|
prompt: "Fix login bug",
|
|
attachments: [],
|
|
},
|
|
},
|
|
"req-local-title",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "workspace.create.request",
|
|
requestId: "req-local-title",
|
|
source: {
|
|
kind: "directory",
|
|
path: "/tmp/project",
|
|
projectId: "local:/tmp/project",
|
|
},
|
|
firstAgentContext: {
|
|
prompt: "Fix login bug",
|
|
attachments: [],
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "workspace.create.response",
|
|
payload: {
|
|
requestId: "req-local-title",
|
|
workspace: null,
|
|
error: "local title sentinel",
|
|
setupTerminalId: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: "req-local-title",
|
|
workspace: null,
|
|
error: "local title sentinel",
|
|
setupTerminalId: null,
|
|
});
|
|
});
|
|
|
|
test("sends project.remove.request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const removePromise = client.removeProject("remote:github.com/acme/app", "req-remove-project");
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "project.remove.request",
|
|
requestId: "req-remove-project",
|
|
projectId: "remote:github.com/acme/app",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "project.remove.response",
|
|
payload: {
|
|
requestId: "req-remove-project",
|
|
projectId: "remote:github.com/acme/app",
|
|
accepted: true,
|
|
removedWorkspaceIds: ["ws-main"],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(removePromise).resolves.toEqual({ removedWorkspaceIds: ["ws-main"] });
|
|
});
|
|
|
|
test("sends worktree base-ref fields in create_paseo_worktree_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createPaseoWorktree(
|
|
{
|
|
cwd: "/tmp/project",
|
|
projectId: "remote:github.com/acme/project",
|
|
worktreeSlug: "review-pr-123",
|
|
refName: "feature/worktree-base-ref",
|
|
action: "checkout",
|
|
githubPrNumber: 123,
|
|
},
|
|
"req-worktree-ref",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "create_paseo_worktree_request",
|
|
cwd: "/tmp/project",
|
|
projectId: "remote:github.com/acme/project",
|
|
worktreeSlug: "review-pr-123",
|
|
refName: "feature/worktree-base-ref",
|
|
action: "checkout",
|
|
githubPrNumber: 123,
|
|
requestId: "req-worktree-ref",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "create_paseo_worktree_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: "worktree ref fields sentinel",
|
|
setupTerminalId: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: request.requestId,
|
|
workspace: null,
|
|
error: "worktree ref fields sentinel",
|
|
setupTerminalId: null,
|
|
});
|
|
});
|
|
|
|
test("omitting create_paseo_worktree_request worktree base-ref fields preserves legacy wire shape", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const createPromise = client.createPaseoWorktree(
|
|
{
|
|
cwd: "/tmp/project",
|
|
worktreeSlug: "feature-a",
|
|
},
|
|
"req-worktree-legacy",
|
|
);
|
|
|
|
expect(assertStr(mock.sent[0])).toBe(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "create_paseo_worktree_request",
|
|
cwd: "/tmp/project",
|
|
worktreeSlug: "feature-a",
|
|
requestId: "req-worktree-legacy",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "create_paseo_worktree_response",
|
|
payload: {
|
|
requestId: "req-worktree-legacy",
|
|
workspace: null,
|
|
error: "legacy worktree shape sentinel",
|
|
setupTerminalId: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(createPromise).resolves.toEqual({
|
|
requestId: "req-worktree-legacy",
|
|
workspace: null,
|
|
error: "legacy worktree shape sentinel",
|
|
setupTerminalId: null,
|
|
});
|
|
});
|
|
|
|
test("sends explicit shutdown_server_request via shutdownServer", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
expect(typeof client.shutdownServer).toBe("function");
|
|
const promise = client.shutdownServer({ requestId: "req-shutdown-1" });
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "shutdown_server_request",
|
|
requestId: "req-shutdown-1",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "shutdown_requested",
|
|
clientId: "clsk_unit_test",
|
|
requestId: "req-shutdown-1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
status: "shutdown_requested",
|
|
clientId: "clsk_unit_test",
|
|
requestId: "req-shutdown-1",
|
|
});
|
|
});
|
|
|
|
test("restartServer remains restart-only and sends restart_server_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.restartServer("settings_update", "req-restart-1");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "restart_server_request",
|
|
reason: "settings_update",
|
|
requestId: "req-restart-1",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "restart_requested",
|
|
clientId: "clsk_unit_test",
|
|
reason: "settings_update",
|
|
requestId: "req-restart-1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
status: "restart_requested",
|
|
clientId: "clsk_unit_test",
|
|
reason: "settings_update",
|
|
requestId: "req-restart-1",
|
|
});
|
|
});
|
|
|
|
test("transitions out of connecting when connect timeout elapses", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
connectTimeoutMs: 100,
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const pendingConnect = client.connect().then(
|
|
() => ({ ok: true as const }),
|
|
(error) => ({ ok: false as const, error }),
|
|
);
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
|
|
await vi.advanceTimersByTimeAsync(120);
|
|
const result = await pendingConnect;
|
|
expect(result.ok).toBe(false);
|
|
if (!result.ok) {
|
|
expect(result.error).toBeInstanceOf(Error);
|
|
if (result.error instanceof Error) {
|
|
expect(result.error.message).toContain("Connection timed out");
|
|
}
|
|
}
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("reconnects after relay close with replaced-by-new-connection reason", async () => {
|
|
useHeartbeatClock();
|
|
try {
|
|
const logger = createMockLogger();
|
|
const first = createMockTransport();
|
|
const second = createMockTransport();
|
|
const transports = [first, second];
|
|
let transportIndex = 0;
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
|
|
clientId: "clsk_test",
|
|
logger,
|
|
reconnect: {
|
|
enabled: true,
|
|
baseDelayMs: 5,
|
|
maxDelayMs: 5,
|
|
},
|
|
transportFactory: () => {
|
|
const next = transports[Math.min(transportIndex, transports.length - 1)];
|
|
transportIndex += 1;
|
|
return next.transport;
|
|
},
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
first.triggerOpen();
|
|
await connectPromise;
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
|
|
first.triggerClose({ code: 1008, reason: "Replaced by new connection" });
|
|
expect(client.getConnectionState().status).toBe("disconnected");
|
|
|
|
await vi.advanceTimersByTimeAsync(10);
|
|
expect(client.getConnectionState().status).toBe("connecting");
|
|
|
|
second.triggerOpen();
|
|
expect(client.getConnectionState().status).toBe("connected");
|
|
} finally {
|
|
vi.useRealTimers();
|
|
}
|
|
});
|
|
|
|
test("requires non-empty clientId", () => {
|
|
expect(() => {
|
|
const _client = new DaemonClient({
|
|
url: "ws://relay.test/ws?role=client&serverId=srv_test&v=2",
|
|
clientId: "",
|
|
reconnect: { enabled: false },
|
|
});
|
|
void _client;
|
|
}).toThrow("Daemon client requires a non-empty clientId");
|
|
});
|
|
|
|
test("requires non-empty clientId for direct connections", () => {
|
|
expect(() => {
|
|
const _client = new DaemonClient({
|
|
url: "ws://127.0.0.1:6767/ws",
|
|
clientId: " ",
|
|
reconnect: { enabled: false },
|
|
});
|
|
void _client;
|
|
}).toThrow("Daemon client requires a non-empty clientId");
|
|
});
|
|
|
|
test("logs configured runtime generation in connection transition events", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
runtimeGeneration: 7,
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const transitionPayloads = logger.debug.mock.calls.filter(
|
|
([, message]) => message === "DaemonClientTransition",
|
|
);
|
|
expect(transitionPayloads.length).toBeGreaterThan(0);
|
|
for (const [payload] of transitionPayloads) {
|
|
expect(
|
|
z.object({ generation: z.number().nullable().optional() }).parse(payload).generation,
|
|
).toBe(7);
|
|
}
|
|
});
|
|
|
|
test("subscribes to checkout diff updates via RPC handshake", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const subscription = client.observeCheckoutDiff("/tmp/project", { mode: "uncommitted" });
|
|
const promise = subscription.ready;
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("subscribe_checkout_diff_request");
|
|
expect(request.subscriptionId).toBeUndefined();
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.compare).toEqual({ mode: "uncommitted" });
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "subscribe_checkout_diff_response",
|
|
payload: {
|
|
subscriptionId: "checkout-sub-1",
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
requestId: request.requestId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
subscriptionId: "checkout-sub-1",
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
requestId: request.requestId,
|
|
});
|
|
});
|
|
|
|
test("getCheckoutDiff reads a snapshot without creating a subscription", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.getCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" });
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const subscribeRequest = parseSentFrame(mock.sent[0]);
|
|
expect(subscribeRequest.type).toBe("checkout.diff.get.request");
|
|
expect(subscribeRequest.cwd).toBe("/tmp/project");
|
|
expect(subscribeRequest.compare).toEqual({
|
|
mode: "base",
|
|
baseRef: "main",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.diff.get.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
requestId: subscribeRequest.requestId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
requestId: subscribeRequest.requestId,
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
});
|
|
|
|
test("requests branch suggestions via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.getBranchSuggestions(
|
|
{ cwd: "/tmp/project", query: "mai", limit: 5 },
|
|
"req-branches",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("branch_suggestions_request");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.query).toBe("mai");
|
|
expect(request.limit).toBe(5);
|
|
expect(request.requestId).toBe("req-branches");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "branch_suggestions_response",
|
|
payload: {
|
|
branches: ["main"],
|
|
error: null,
|
|
requestId: "req-branches",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
branches: ["main"],
|
|
error: null,
|
|
requestId: "req-branches",
|
|
});
|
|
});
|
|
|
|
test("reads project config via correlated RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.readProjectConfig("/repo/app", "read-project-config-1");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "read_project_config_request",
|
|
requestId: "read-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "read_project_config_response",
|
|
payload: {
|
|
requestId: "read-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
ok: true,
|
|
config: { worktree: { setup: "npm install" } },
|
|
revision: { mtimeMs: 10, size: 20 },
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: "read-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
ok: true,
|
|
config: { worktree: { setup: "npm install" } },
|
|
revision: { mtimeMs: 10, size: 20 },
|
|
});
|
|
});
|
|
|
|
test("writes project config via correlated RPC and returns inline failures", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.writeProjectConfig({
|
|
requestId: "write-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
config: { worktree: { setup: ["npm install"] } },
|
|
expectedRevision: { mtimeMs: 10, size: 20 },
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toEqual({
|
|
type: "write_project_config_request",
|
|
requestId: "write-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
config: { worktree: { setup: ["npm install"] } },
|
|
expectedRevision: { mtimeMs: 10, size: 20 },
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "write_project_config_response",
|
|
payload: {
|
|
requestId: "write-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
ok: false,
|
|
error: {
|
|
code: "stale_project_config",
|
|
currentRevision: { mtimeMs: 11, size: 21 },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: "write-project-config-1",
|
|
repoRoot: "/repo/app",
|
|
ok: false,
|
|
error: {
|
|
code: "stale_project_config",
|
|
currentRevision: { mtimeMs: 11, size: 21 },
|
|
},
|
|
});
|
|
});
|
|
|
|
test("requests directory suggestions via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.getDirectorySuggestions(
|
|
{
|
|
query: "proj",
|
|
limit: 10,
|
|
cwd: "/tmp/project",
|
|
includeFiles: true,
|
|
includeDirectories: true,
|
|
matchMode: "suffix",
|
|
},
|
|
"req-directories",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("directory_suggestions_request");
|
|
expect(request.query).toBe("proj");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.includeFiles).toBe(true);
|
|
expect(request.includeDirectories).toBe(true);
|
|
expect(request.matchMode).toBe("suffix");
|
|
expect(request.limit).toBe(10);
|
|
expect(request.requestId).toBe("req-directories");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "directory_suggestions_response",
|
|
payload: {
|
|
directories: ["/Users/test/projects/paseo"],
|
|
entries: [{ path: "README.md", kind: "file" }],
|
|
error: null,
|
|
requestId: "req-directories",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
directories: ["/Users/test/projects/paseo"],
|
|
entries: [{ path: "README.md", kind: "file" }],
|
|
error: null,
|
|
requestId: "req-directories",
|
|
});
|
|
});
|
|
|
|
test("requests checkout merge from base via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.checkoutMergeFromBase(
|
|
"/tmp/project",
|
|
{ baseRef: "main", requireCleanTarget: true },
|
|
"req-merge-from-base",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("checkout_merge_from_base_request");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.baseRef).toBe("main");
|
|
expect(request.requireCleanTarget).toBe(true);
|
|
expect(request.requestId).toBe("req-merge-from-base");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout_merge_from_base_response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
requestId: "req-merge-from-base",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
requestId: "req-merge-from-base",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("requests GitHub auto-merge enable via namespaced RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.checkoutGithubSetAutoMerge(
|
|
"/tmp/project",
|
|
{ enabled: true, method: "squash" },
|
|
"req-enable-auto-merge",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("checkout.github.set_auto_merge.request");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.enabled).toBe(true);
|
|
expect(request.mergeMethod).toBe("squash");
|
|
expect(request.requestId).toBe("req-enable-auto-merge");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.github.set_auto_merge.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
enabled: true,
|
|
requestId: "req-enable-auto-merge",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
enabled: true,
|
|
requestId: "req-enable-auto-merge",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("requests GitHub auto-merge disable via namespaced RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.checkoutGithubSetAutoMerge(
|
|
"/tmp/project",
|
|
{ enabled: false },
|
|
"req-disable-auto-merge",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("checkout.github.set_auto_merge.request");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.enabled).toBe(false);
|
|
expect(request.mergeMethod).toBeUndefined();
|
|
expect(request.requestId).toBe("req-disable-auto-merge");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.github.set_auto_merge.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
enabled: false,
|
|
requestId: "req-disable-auto-merge",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
enabled: false,
|
|
requestId: "req-disable-auto-merge",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("requests GitHub check details via namespaced RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.checkoutGithubGetCheckDetails(
|
|
{
|
|
cwd: "/tmp/project",
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
checkRunId: 12345,
|
|
workflowRunId: 456,
|
|
},
|
|
"req-check-details",
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "checkout.github.get_check_details.request",
|
|
cwd: "/tmp/project",
|
|
repoOwner: "getpaseo",
|
|
repoName: "paseo",
|
|
checkRunId: 12345,
|
|
workflowRunId: 456,
|
|
requestId: "req-check-details",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.github.get_check_details.response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
requestId: "req-check-details",
|
|
success: true,
|
|
details: {
|
|
checkRunId: 12345,
|
|
workflowRunId: 456,
|
|
name: "server-tests",
|
|
status: "completed",
|
|
conclusion: "failure",
|
|
annotations: [],
|
|
failedJobs: [],
|
|
truncated: false,
|
|
},
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
requestId: "req-check-details",
|
|
success: true,
|
|
details: {
|
|
checkRunId: 12345,
|
|
workflowRunId: 456,
|
|
name: "server-tests",
|
|
status: "completed",
|
|
conclusion: "failure",
|
|
annotations: [],
|
|
failedJobs: [],
|
|
truncated: false,
|
|
},
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("requests checkout pull via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.checkoutPull("/tmp/project", "req-pull");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("checkout_pull_request");
|
|
expect(request.cwd).toBe("/tmp/project");
|
|
expect(request.requestId).toBe("req-pull");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout_pull_response",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
requestId: "req-pull",
|
|
success: true,
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
cwd: "/tmp/project",
|
|
requestId: "req-pull",
|
|
success: true,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("renames a branch via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.renameBranch({
|
|
cwd: "/tmp/project",
|
|
branch: "feature/new-name",
|
|
requestId: "req-rename-branch",
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = JSON.parse(assertStr(mock.sent[0])) as {
|
|
type: "session";
|
|
message: {
|
|
type: "checkout.rename_branch.request";
|
|
cwd: string;
|
|
branch: string;
|
|
requestId: string;
|
|
};
|
|
};
|
|
expect(request.message.type).toBe("checkout.rename_branch.request");
|
|
expect(request.message.cwd).toBe("/tmp/project");
|
|
expect(request.message.branch).toBe("feature/new-name");
|
|
expect(request.message.requestId).toBe("req-rename-branch");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.rename_branch.response",
|
|
payload: {
|
|
requestId: "req-rename-branch",
|
|
success: true,
|
|
cwd: "/tmp/project",
|
|
currentBranch: "feature/new-name",
|
|
error: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: "req-rename-branch",
|
|
success: true,
|
|
cwd: "/tmp/project",
|
|
currentBranch: "feature/new-name",
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
test("returns renameBranch business failures", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.renameBranch({
|
|
cwd: "/tmp/project",
|
|
branch: "already-exists",
|
|
requestId: "req-rename-branch-fail",
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "checkout.rename_branch.response",
|
|
payload: {
|
|
requestId: "req-rename-branch-fail",
|
|
success: false,
|
|
cwd: "/tmp/project",
|
|
currentBranch: null,
|
|
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: "req-rename-branch-fail",
|
|
success: false,
|
|
cwd: "/tmp/project",
|
|
currentBranch: null,
|
|
error: { code: "NOT_ALLOWED", message: "Branch already exists" },
|
|
});
|
|
});
|
|
|
|
test("resubscribes checkout diff streams after reconnect", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const observation = client.observeCheckoutDiff("/tmp/project", { mode: "base", baseRef: "main" });
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
|
|
const first = parseSentFrame(mock.sent[0]);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_checkout_diff_response",
|
|
payload: {
|
|
requestId: first.requestId,
|
|
subscriptionId: "server-first",
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
await observation.ready;
|
|
mock.triggerClose();
|
|
const reconnect = client.connect();
|
|
mock.triggerOpen();
|
|
await reconnect;
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "subscribe_checkout_diff_request",
|
|
cwd: "/tmp/project",
|
|
compare: { mode: "base", baseRef: "main" },
|
|
});
|
|
expect(request.subscriptionId).toBeUndefined();
|
|
expect(request.requestId).not.toBe(first.requestId);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_checkout_diff_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
subscriptionId: "server-second",
|
|
cwd: "/tmp/project",
|
|
files: [],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
await vi.waitFor(() => expect(observation.subscriptionId).toBe("server-second"));
|
|
});
|
|
|
|
test("fetches agents via RPC with filters, sort, and pagination", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.fetchAgents({
|
|
filter: { labels: { surface: "workspace" } },
|
|
sort: [
|
|
{ key: "status_priority", direction: "asc" },
|
|
{ key: "created_at", direction: "desc" },
|
|
],
|
|
page: { limit: 25, cursor: "cursor-1" },
|
|
subscribe: {},
|
|
});
|
|
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("fetch_agents_request");
|
|
expect(request.sort).toEqual([
|
|
{ key: "status_priority", direction: "asc" },
|
|
{ key: "created_at", direction: "desc" },
|
|
]);
|
|
expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
|
expect(request.subscribe).toEqual({});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "fetch_agents_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
subscriptionId: "sub-1",
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: "cursor-1",
|
|
hasMore: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: request.requestId,
|
|
subscriptionId: "sub-1",
|
|
subscription: expect.objectContaining({
|
|
subscriptionId: "sub-1",
|
|
release: expect.any(Function),
|
|
}),
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: "cursor-1",
|
|
hasMore: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("detaches an agent through the namespaced detach RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.detachAgent("child-agent");
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "agent.detach.request",
|
|
agentId: "child-agent",
|
|
});
|
|
expect(typeof request.requestId).toBe("string");
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent.detach.response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
agentId: "child-agent",
|
|
accepted: true,
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toBeUndefined();
|
|
});
|
|
|
|
test("sends active-scoped fetch_agents_request", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.fetchAgents({
|
|
scope: "active",
|
|
page: { limit: 50 },
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "fetch_agents_request",
|
|
scope: "active",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "fetch_agents_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: null,
|
|
hasMore: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toMatchObject({
|
|
requestId: request.requestId,
|
|
entries: [],
|
|
});
|
|
});
|
|
|
|
test("fetches paginated agent history separately from active agents", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.fetchAgentHistory({
|
|
page: { limit: 25, cursor: "cursor-1" },
|
|
sort: [{ key: "updated_at", direction: "desc" }],
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("fetch_agent_history_request");
|
|
expect(request.page).toEqual({ limit: 25, cursor: "cursor-1" });
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "fetch_agent_history_response",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: "cursor-1",
|
|
hasMore: false,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: request.requestId,
|
|
entries: [],
|
|
pageInfo: {
|
|
nextCursor: null,
|
|
prevCursor: "cursor-1",
|
|
hasMore: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
test("fetches scoped recent provider sessions", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.fetchRecentProviderSessions({
|
|
cwd: "/tmp/repo",
|
|
providers: ["my-claude"],
|
|
since: "2026-04-30T00:00:00.000Z",
|
|
limit: 25,
|
|
query: "invoice",
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = JSON.parse(String(mock.sent[0])) as {
|
|
type: "session";
|
|
message: {
|
|
type: "fetch_recent_provider_sessions_request";
|
|
requestId: string;
|
|
cwd?: string;
|
|
providers?: string[];
|
|
since?: string;
|
|
limit?: number;
|
|
query?: string;
|
|
};
|
|
};
|
|
expect(request.message).toMatchObject({
|
|
type: "fetch_recent_provider_sessions_request",
|
|
cwd: "/tmp/repo",
|
|
providers: ["my-claude"],
|
|
since: "2026-04-30T00:00:00.000Z",
|
|
limit: 25,
|
|
query: "invoice",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "fetch_recent_provider_sessions_response",
|
|
payload: {
|
|
requestId: request.message.requestId,
|
|
entries: [
|
|
{
|
|
providerId: "codex",
|
|
providerLabel: "Codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
title: "Import me",
|
|
firstPromptPreview: "first prompt",
|
|
lastPromptPreview: "last prompt",
|
|
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
|
},
|
|
],
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
requestId: request.message.requestId,
|
|
entries: [
|
|
{
|
|
providerId: "codex",
|
|
providerLabel: "Codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
title: "Import me",
|
|
firstPromptPreview: "first prompt",
|
|
lastPromptPreview: "last prompt",
|
|
lastActivityAt: "2026-04-30T12:34:56.000Z",
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("imports an agent by provider handle id", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.importAgent({
|
|
providerId: "custom-codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
});
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
const request = JSON.parse(String(mock.sent[0])) as {
|
|
type: "session";
|
|
message: {
|
|
type: "import_agent_request";
|
|
requestId: string;
|
|
providerId?: string;
|
|
providerHandleId?: string;
|
|
sessionId?: string;
|
|
cwd?: string;
|
|
};
|
|
};
|
|
expect(request.message).toMatchObject({
|
|
type: "import_agent_request",
|
|
providerId: "custom-codex",
|
|
providerHandleId: "thread-1",
|
|
cwd: "/tmp/repo",
|
|
});
|
|
expect(request.message).not.toHaveProperty("sessionId");
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "status",
|
|
payload: {
|
|
status: "agent_resumed",
|
|
requestId: request.message.requestId,
|
|
agentId: "agent-1",
|
|
timelineSize: 0,
|
|
agent: {
|
|
id: "agent-1",
|
|
provider: "custom-codex",
|
|
cwd: "/tmp/repo",
|
|
model: null,
|
|
features: [],
|
|
thinkingOptionId: null,
|
|
effectiveThinkingOptionId: null,
|
|
createdAt: "2026-04-30T00:00:00.000Z",
|
|
updatedAt: "2026-04-30T00:00:00.000Z",
|
|
lastUserMessageAt: null,
|
|
status: "idle",
|
|
capabilities: {
|
|
supportsStreaming: false,
|
|
supportsSessionPersistence: false,
|
|
supportsDynamicModes: false,
|
|
supportsMcpServers: false,
|
|
supportsReasoningStream: false,
|
|
supportsToolInvocations: false,
|
|
},
|
|
currentModeId: null,
|
|
availableModes: [],
|
|
pendingPermissions: [],
|
|
persistence: {
|
|
provider: "custom-codex",
|
|
sessionId: "thread-1",
|
|
nativeHandle: "thread-1",
|
|
},
|
|
title: null,
|
|
labels: {},
|
|
requiresAttention: false,
|
|
attentionReason: null,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toMatchObject({
|
|
id: "agent-1",
|
|
provider: "custom-codex",
|
|
});
|
|
});
|
|
|
|
test("uses server-provided dictation finish timeout budget", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const finishPromise = client.finishDictationStream("dict-1", 0);
|
|
const finishError = finishPromise.then(
|
|
() => null,
|
|
(error) => error,
|
|
);
|
|
|
|
expect(mock.sent).toHaveLength(1);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "dictation_stream_finish_accepted",
|
|
payload: {
|
|
dictationId: "dict-1",
|
|
timeoutMs: 100,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await vi.advanceTimersByTimeAsync(5_101);
|
|
const error = await finishError;
|
|
expect(error).toBeInstanceOf(Error);
|
|
if (error instanceof Error) {
|
|
expect(error.message).toContain("Timeout waiting for dictation finalization (5100ms)");
|
|
}
|
|
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test("resolves dictation finish when final arrives after finish accepted", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const finishPromise = client.finishDictationStream("dict-2", 1);
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "dictation_stream_finish_accepted",
|
|
payload: {
|
|
dictationId: "dict-2",
|
|
timeoutMs: 1000,
|
|
},
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "dictation_stream_final",
|
|
payload: {
|
|
dictationId: "dict-2",
|
|
text: "hello",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(finishPromise).resolves.toEqual({
|
|
dictationId: "dict-2",
|
|
text: "hello",
|
|
});
|
|
});
|
|
|
|
test("cancels waiters when send fails (no leaked timeouts)", async () => {
|
|
useHeartbeatClock();
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
let sendCount = 0;
|
|
|
|
const transportFactory = () => ({
|
|
...mock.transport,
|
|
send: () => {
|
|
sendCount += 1;
|
|
if (sendCount > 1) {
|
|
throw new Error("boom");
|
|
}
|
|
},
|
|
});
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.getCheckoutStatus("/tmp/project");
|
|
await expect(promise).rejects.toThrow("boom");
|
|
|
|
// Ensure we didn't leave a waiter behind that will reject later.
|
|
const internal = client as unknown as { waiters: Set<unknown> };
|
|
expect(internal.waiters.size).toBe(0);
|
|
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
vi.useRealTimers();
|
|
});
|
|
|
|
test("lists available providers via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.listAvailableProviders();
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("list_available_providers_request");
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "list_available_providers_response",
|
|
payload: {
|
|
providers: [
|
|
{ provider: "claude", available: true, error: null },
|
|
{ provider: "codex", available: false, error: "Missing binary" },
|
|
],
|
|
error: null,
|
|
fetchedAt: "2026-02-12T00:00:00.000Z",
|
|
requestId: request.requestId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
providers: [
|
|
{ provider: "claude", available: true, error: null },
|
|
{ provider: "codex", available: false, error: "Missing binary" },
|
|
],
|
|
error: null,
|
|
fetchedAt: "2026-02-12T00:00:00.000Z",
|
|
requestId: request.requestId,
|
|
});
|
|
});
|
|
|
|
test("requests provider snapshots conditionally and expands the compact response", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_provider_snapshot_test",
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.getProvidersSnapshot({ cwd: "/repo", ifNoneMatch: "previous-hash" });
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({
|
|
type: "get_providers_snapshot_request",
|
|
cwd: "/repo",
|
|
ifNoneMatch: "previous-hash",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "get_providers_snapshot_response",
|
|
payload: {
|
|
entries: [],
|
|
compactSnapshot: {
|
|
entries: [
|
|
{
|
|
provider: "pi",
|
|
status: "ready",
|
|
enabled: true,
|
|
models: [
|
|
{ id: "model-a", label: "Model A", thinkingSet: 0 },
|
|
{ id: "model-b", label: "Model B", thinkingSet: 0 },
|
|
],
|
|
},
|
|
],
|
|
thinkingSets: [
|
|
{
|
|
options: [{ id: "high", label: "High", isDefault: true }],
|
|
defaultOptionId: "high",
|
|
},
|
|
],
|
|
},
|
|
snapshotHash: "next-hash",
|
|
generatedAt: "2026-08-04T00:00:00.000Z",
|
|
requestId: request.requestId,
|
|
},
|
|
}),
|
|
);
|
|
|
|
const response = await promise;
|
|
expect(response.entries[0]?.models).toEqual([
|
|
{
|
|
provider: "pi",
|
|
id: "model-a",
|
|
label: "Model A",
|
|
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
|
|
defaultThinkingOptionId: "high",
|
|
},
|
|
{
|
|
provider: "pi",
|
|
id: "model-b",
|
|
label: "Model B",
|
|
thinkingOptions: [{ id: "high", label: "High", isDefault: true }],
|
|
defaultThinkingOptionId: "high",
|
|
},
|
|
]);
|
|
expect(response.entries[0]?.models?.[0]?.thinkingOptions).toBe(
|
|
response.entries[0]?.models?.[1]?.thinkingOptions,
|
|
);
|
|
});
|
|
|
|
test("lists commands with draft config via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.listCommands({
|
|
agentId: "__new_agent__",
|
|
draftConfig: {
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
modeId: "bypassPermissions",
|
|
model: "gpt-5",
|
|
thinkingOptionId: "off",
|
|
},
|
|
});
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("list_commands_request");
|
|
expect(request.agentId).toBe("__new_agent__");
|
|
expect(request.draftConfig).toEqual({
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
modeId: "bypassPermissions",
|
|
model: "gpt-5",
|
|
thinkingOptionId: "off",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "list_commands_response",
|
|
payload: {
|
|
agentId: "__new_agent__",
|
|
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
|
error: null,
|
|
requestId: request.requestId,
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
agentId: "__new_agent__",
|
|
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
|
error: null,
|
|
requestId: request.requestId,
|
|
});
|
|
});
|
|
|
|
test("lists commands with explicit requestId via RPC", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.listCommands({
|
|
agentId: "agent-1",
|
|
requestId: "req-commands",
|
|
});
|
|
expect(mock.sent).toHaveLength(1);
|
|
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request.type).toBe("list_commands_request");
|
|
expect(request.agentId).toBe("agent-1");
|
|
expect(request.requestId).toBe("req-commands");
|
|
expect(request.draftConfig).toBeUndefined();
|
|
|
|
mock.triggerMessage(
|
|
JSON.stringify({
|
|
type: "session",
|
|
message: {
|
|
type: "list_commands_response",
|
|
payload: {
|
|
agentId: "agent-1",
|
|
commands: [],
|
|
error: null,
|
|
requestId: "req-commands",
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
agentId: "agent-1",
|
|
commands: [],
|
|
error: null,
|
|
requestId: "req-commands",
|
|
});
|
|
});
|
|
|
|
test("preserves legacy listCommands id overload", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.listCommands("agent-1", "req-commands-legacy");
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "list_commands_request",
|
|
requestId: "req-commands-legacy",
|
|
agentId: "agent-1",
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "list_commands_response",
|
|
payload: {
|
|
agentId: "agent-1",
|
|
commands: [],
|
|
error: null,
|
|
requestId: "req-commands-legacy",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
agentId: "agent-1",
|
|
commands: [],
|
|
error: null,
|
|
requestId: "req-commands-legacy",
|
|
});
|
|
});
|
|
|
|
test("preserves legacy listCommands options overload", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const promise = client.listCommands("__new_agent__", {
|
|
requestId: "req-commands-draft-legacy",
|
|
draftConfig: {
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
modeId: "bypassPermissions",
|
|
model: "gpt-5",
|
|
thinkingOptionId: "off",
|
|
},
|
|
});
|
|
|
|
expect(parseSentFrame(mock.sent[0])).toEqual({
|
|
type: "list_commands_request",
|
|
requestId: "req-commands-draft-legacy",
|
|
agentId: "__new_agent__",
|
|
draftConfig: {
|
|
provider: "codex",
|
|
cwd: "/tmp/project",
|
|
modeId: "bypassPermissions",
|
|
model: "gpt-5",
|
|
thinkingOptionId: "off",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "list_commands_response",
|
|
payload: {
|
|
agentId: "__new_agent__",
|
|
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
|
error: null,
|
|
requestId: "req-commands-draft-legacy",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(promise).resolves.toEqual({
|
|
agentId: "__new_agent__",
|
|
commands: [{ name: "help", description: "Show help", argumentHint: "" }],
|
|
error: null,
|
|
requestId: "req-commands-draft-legacy",
|
|
});
|
|
});
|
|
|
|
test("emits output events for the active terminal stream", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const seen: string[] = [];
|
|
const unsubscribe = client.onTerminalStreamEvent((event) => {
|
|
if (event.type !== "output") {
|
|
return;
|
|
}
|
|
seen.push(new TextDecoder().decode(event.data));
|
|
});
|
|
|
|
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-1" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-1",
|
|
slot: 11,
|
|
error: null,
|
|
requestId: "sub-1",
|
|
},
|
|
}),
|
|
);
|
|
await subscribePromise;
|
|
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot: 11,
|
|
payload: new TextEncoder().encode("hello"),
|
|
}),
|
|
);
|
|
|
|
expect(seen).toEqual(["hello"]);
|
|
expect(mock.sent).toHaveLength(1);
|
|
unsubscribe();
|
|
});
|
|
|
|
test("emits snapshot events for the subscribed terminal stream", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const snapshots: unknown[] = [];
|
|
client.onTerminalStreamEvent((event) => {
|
|
if (event.type !== "snapshot") {
|
|
return;
|
|
}
|
|
snapshots.push(event.state);
|
|
});
|
|
|
|
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-2" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-1",
|
|
slot: 12,
|
|
error: null,
|
|
requestId: "sub-2",
|
|
},
|
|
}),
|
|
);
|
|
await subscribePromise;
|
|
|
|
const state = {
|
|
rows: 1,
|
|
cols: 5,
|
|
grid: [[{ char: "h" }, { char: "e" }, { char: "l" }, { char: "l" }, { char: "o" }]],
|
|
scrollback: [],
|
|
cursor: { row: 0, col: 5 },
|
|
};
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Snapshot,
|
|
slot: 12,
|
|
payload: encodeTerminalSnapshotPayload(state),
|
|
}),
|
|
);
|
|
|
|
expect(snapshots).toEqual([state]);
|
|
});
|
|
|
|
test("sends explicit terminal input and resize commands", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-3" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-1",
|
|
slot: 13,
|
|
error: null,
|
|
requestId: "sub-3",
|
|
},
|
|
}),
|
|
);
|
|
await subscribePromise;
|
|
mock.sent.length = 0;
|
|
|
|
client.sendTerminalInput("term-1", {
|
|
type: "input",
|
|
data: "echo hello\r",
|
|
});
|
|
client.sendTerminalInput("term-1", {
|
|
type: "resize",
|
|
rows: 24,
|
|
cols: 80,
|
|
intent: "update",
|
|
});
|
|
|
|
expect(mock.sent.map(parseSentFrame)).toEqual([
|
|
{
|
|
type: "terminal_input",
|
|
terminalId: "term-1",
|
|
message: { type: "input", data: "echo hello\r" },
|
|
},
|
|
{
|
|
type: "terminal_input",
|
|
terminalId: "term-1",
|
|
message: { type: "resize", rows: 24, cols: 80, intent: "update" },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("routes concurrent terminal stream frames by slot", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const seen: string[] = [];
|
|
client.onTerminalStreamEvent((event) => {
|
|
if (event.type !== "output") {
|
|
return;
|
|
}
|
|
seen.push(`${event.terminalId}:${new TextDecoder().decode(event.data)}`);
|
|
});
|
|
|
|
const subscribeFirstPromise = client.subscribeTerminal("term-1", { requestId: "sub-multi-1" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-1",
|
|
slot: 21,
|
|
error: null,
|
|
requestId: "sub-multi-1",
|
|
},
|
|
}),
|
|
);
|
|
await subscribeFirstPromise;
|
|
|
|
const subscribeSecondPromise = client.subscribeTerminal("term-2", { requestId: "sub-multi-2" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-2",
|
|
slot: 22,
|
|
error: null,
|
|
requestId: "sub-multi-2",
|
|
},
|
|
}),
|
|
);
|
|
await subscribeSecondPromise;
|
|
mock.sent.length = 0;
|
|
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot: 22,
|
|
payload: new TextEncoder().encode("beta"),
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot: 21,
|
|
payload: new TextEncoder().encode("alpha"),
|
|
}),
|
|
);
|
|
|
|
client.sendTerminalInput("term-2", {
|
|
type: "input",
|
|
data: "echo beta\r",
|
|
});
|
|
client.sendTerminalInput("term-1", {
|
|
type: "resize",
|
|
rows: 10,
|
|
cols: 20,
|
|
});
|
|
|
|
expect(seen).toEqual(["term-2:beta", "term-1:alpha"]);
|
|
expect(mock.sent.map(parseSentFrame)).toEqual([
|
|
{
|
|
type: "terminal_input",
|
|
terminalId: "term-2",
|
|
message: { type: "input", data: "echo beta\r" },
|
|
},
|
|
{
|
|
type: "terminal_input",
|
|
terminalId: "term-1",
|
|
message: { type: "resize", rows: 10, cols: 20 },
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("ignores terminal stream frames after terminal_stream_exit", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const seen: string[] = [];
|
|
const unsubscribe = client.onTerminalStreamEvent((event) => {
|
|
if (event.type !== "output") {
|
|
return;
|
|
}
|
|
seen.push(new TextDecoder().decode(event.data));
|
|
});
|
|
|
|
const subscribePromise = client.subscribeTerminal("term-1", { requestId: "sub-4" });
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscribe_terminal_request"),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscribe_terminal_response",
|
|
payload: {
|
|
subscriptionId: "server-" + String(parseSentFrame(mock.sent.at(-1)).requestId),
|
|
terminalId: "term-1",
|
|
slot: 14,
|
|
error: null,
|
|
requestId: "sub-4",
|
|
},
|
|
}),
|
|
);
|
|
await subscribePromise;
|
|
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot: 14,
|
|
payload: new TextEncoder().encode("before-exit"),
|
|
}),
|
|
);
|
|
expect(seen).toEqual(["before-exit"]);
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "terminal_stream_exit",
|
|
payload: {
|
|
subscriptionId: "server-sub-4",
|
|
terminalId: "term-1",
|
|
},
|
|
}),
|
|
);
|
|
|
|
mock.triggerMessage(
|
|
encodeTerminalStreamFrame({
|
|
opcode: TerminalStreamOpcode.Output,
|
|
slot: 14,
|
|
payload: new TextEncoder().encode("after-exit"),
|
|
}),
|
|
);
|
|
|
|
expect(seen).toEqual(["before-exit"]);
|
|
await vi.waitFor(() =>
|
|
expect(parseSentFrame(mock.sent.at(-1)).type).toBe("subscription.release.request"),
|
|
);
|
|
const release = parseSentFrame(mock.sent.at(-1));
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscription.release.response",
|
|
payload: {
|
|
requestId: release.requestId,
|
|
subscriptionId: "server-sub-4",
|
|
released: true,
|
|
},
|
|
}),
|
|
);
|
|
await Promise.resolve();
|
|
unsubscribe();
|
|
});
|
|
|
|
test("parses canonical agent_stream tool_call payloads without crashing", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const received: unknown[] = [];
|
|
const unsubscribe = client.on("agent_stream", (msg) => {
|
|
received.push(msg);
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent_stream",
|
|
payload: {
|
|
agentId: "agent_cli",
|
|
timestamp: "2026-02-08T20:20:00.000Z",
|
|
event: {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call_cli_stream",
|
|
name: "shell",
|
|
status: "running",
|
|
detail: {
|
|
type: "shell",
|
|
command: "pwd",
|
|
},
|
|
error: null,
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
unsubscribe();
|
|
|
|
expect(received).toHaveLength(1);
|
|
expect(received[0]).toMatchObject({
|
|
payload: {
|
|
event: {
|
|
item: {
|
|
status: "running",
|
|
error: null,
|
|
detail: { type: "shell" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("drops legacy agent_stream tool_call payloads and logs validation warning", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const received: unknown[] = [];
|
|
const unsubscribe = client.on("agent_stream", (msg) => {
|
|
received.push(msg);
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "agent_stream",
|
|
payload: {
|
|
agentId: "agent_cli",
|
|
timestamp: "2026-02-08T20:20:00.000Z",
|
|
event: {
|
|
type: "timeline",
|
|
provider: "codex",
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call_cli_stream_legacy",
|
|
name: "shell",
|
|
status: "inProgress",
|
|
detail: {
|
|
type: "unknown",
|
|
input: { command: "pwd" },
|
|
output: null,
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}),
|
|
);
|
|
|
|
unsubscribe();
|
|
|
|
expect(received).toHaveLength(0);
|
|
expect(logger.warn).toHaveBeenCalled();
|
|
});
|
|
|
|
test("parses canonical fetch_agent_timeline_response payloads without crashing", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const received: unknown[] = [];
|
|
const unsubscribe = client.on("fetch_agent_timeline_response", (msg) => {
|
|
received.push(msg);
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fetch_agent_timeline_response",
|
|
payload: {
|
|
requestId: "req-1",
|
|
agentId: "agent_cli",
|
|
agent: null,
|
|
direction: "tail",
|
|
projection: "projected",
|
|
epoch: "epoch-1",
|
|
reset: false,
|
|
staleCursor: false,
|
|
gap: false,
|
|
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
|
startCursor: { epoch: "epoch-1", seq: 1 },
|
|
endCursor: { epoch: "epoch-1", seq: 1 },
|
|
hasOlder: false,
|
|
hasNewer: false,
|
|
entries: [
|
|
{
|
|
timestamp: "2026-02-08T20:20:00.000Z",
|
|
provider: "codex",
|
|
seqStart: 1,
|
|
seqEnd: 1,
|
|
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
|
collapsed: [],
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call_cli_snapshot",
|
|
name: "shell",
|
|
status: "running",
|
|
detail: {
|
|
type: "shell",
|
|
command: "pwd",
|
|
},
|
|
error: null,
|
|
},
|
|
},
|
|
],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
unsubscribe();
|
|
|
|
expect(received).toHaveLength(1);
|
|
expect(received[0]).toMatchObject({
|
|
payload: {
|
|
entries: [
|
|
{
|
|
item: {
|
|
type: "tool_call",
|
|
status: "running",
|
|
error: null,
|
|
detail: { type: "shell" },
|
|
},
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(logger.warn).not.toHaveBeenCalled();
|
|
});
|
|
|
|
test("rejects and logs a correlated response that violates the protocol schema", async () => {
|
|
const mock = createMockTransport();
|
|
const warnings: string[] = [];
|
|
const logger: Logger = {
|
|
...noopLogger,
|
|
warn: (_fields, message) => warnings.push(message ?? ""),
|
|
};
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const response = client.fetchAgentTimeline("agent_cli", {
|
|
requestId: "req-invalid",
|
|
timeout: 1,
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "fetch_agent_timeline_response",
|
|
payload: {
|
|
requestId: "req-invalid",
|
|
agentId: "agent_cli",
|
|
agent: null,
|
|
direction: "tail",
|
|
projection: "projected",
|
|
epoch: "epoch-1",
|
|
reset: false,
|
|
staleCursor: false,
|
|
gap: false,
|
|
window: { minSeq: 1, maxSeq: 1, nextSeq: 2 },
|
|
startCursor: { epoch: "epoch-1", seq: 1 },
|
|
endCursor: { epoch: "epoch-1", seq: 1 },
|
|
hasOlder: false,
|
|
hasNewer: false,
|
|
entries: [
|
|
{
|
|
timestamp: "2026-02-08T20:20:00.000Z",
|
|
provider: "codex",
|
|
seqStart: 1,
|
|
seqEnd: 1,
|
|
sourceSeqRanges: [{ startSeq: 1, endSeq: 1 }],
|
|
collapsed: [],
|
|
item: {
|
|
type: "tool_call",
|
|
callId: "call_cli_invalid",
|
|
name: "shell",
|
|
status: "inProgress",
|
|
detail: {
|
|
type: "unknown",
|
|
input: { command: "pwd" },
|
|
output: null,
|
|
},
|
|
},
|
|
},
|
|
],
|
|
error: null,
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(response).rejects.toMatchObject({
|
|
requestId: "req-invalid",
|
|
message: expect.stringMatching(/validation/i),
|
|
});
|
|
expect(warnings).toEqual(["Message validation failed"]);
|
|
});
|
|
|
|
test("does not reject a request for an invalid correlated progress event", async () => {
|
|
const mock = createMockTransport();
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger: noopLogger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const response = client.updateDaemon("req-update");
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "daemon.update.progress",
|
|
payload: {
|
|
requestId: "req-update",
|
|
phase: "verifying",
|
|
},
|
|
}),
|
|
);
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "daemon.update.response",
|
|
payload: {
|
|
requestId: "req-update",
|
|
success: true,
|
|
error: null,
|
|
previousVersion: "0.1.106",
|
|
newVersion: "0.1.107",
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(response).resolves.toMatchObject({
|
|
requestId: "req-update",
|
|
success: true,
|
|
});
|
|
});
|
|
|
|
test("sends subscribe/unsubscribe terminals messages", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const observation = client.observeTerminals({ cwd: "/tmp/project" });
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(1));
|
|
const request = parseSentFrame(mock.sent[0]);
|
|
expect(request).toMatchObject({ type: "subscribe_terminals_request", cwd: "/tmp/project" });
|
|
expect(request.subscriptionId).toBeUndefined();
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "terminals_changed",
|
|
payload: {
|
|
requestId: request.requestId,
|
|
subscriptionId: "server-terminals",
|
|
cwd: "/tmp/project",
|
|
terminals: [],
|
|
},
|
|
}),
|
|
);
|
|
await observation.ready;
|
|
const release = observation.release();
|
|
await vi.waitFor(() => expect(mock.sent).toHaveLength(2));
|
|
const requestRelease = parseSentFrame(mock.sent[1]);
|
|
expect(requestRelease).toMatchObject({
|
|
type: "subscription.release.request",
|
|
subscriptionId: "server-terminals",
|
|
});
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "subscription.release.response",
|
|
payload: {
|
|
requestId: requestRelease.requestId,
|
|
subscriptionId: "server-terminals",
|
|
released: true,
|
|
},
|
|
}),
|
|
);
|
|
await release;
|
|
});
|
|
|
|
test("dispatches terminals_changed events to typed listeners", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const received: Array<{ cwd: string; names: string[] }> = [];
|
|
const unsubscribe = client.on("terminals_changed", (message) => {
|
|
received.push({
|
|
cwd: message.payload.cwd,
|
|
names: message.payload.terminals.map((terminal) => terminal.name),
|
|
});
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "terminals_changed",
|
|
payload: {
|
|
cwd: "/tmp/project",
|
|
terminals: [
|
|
{
|
|
id: "term-1",
|
|
name: "Dev Server",
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
unsubscribe();
|
|
|
|
expect(received).toEqual([
|
|
{
|
|
cwd: "/tmp/project",
|
|
names: ["Dev Server"],
|
|
},
|
|
]);
|
|
});
|
|
|
|
test("sends provider.usage.list.request and resolves provider.usage.list.response", async () => {
|
|
const logger = createMockLogger();
|
|
const mock = createMockTransport();
|
|
|
|
const client = new DaemonClient({
|
|
url: "ws://test",
|
|
clientId: "clsk_unit_test",
|
|
logger,
|
|
reconnect: { enabled: false },
|
|
transportFactory: () => mock.transport,
|
|
});
|
|
clients.push(client);
|
|
|
|
const connectPromise = client.connect();
|
|
mock.triggerOpen();
|
|
await connectPromise;
|
|
|
|
const usagePromise = client.listProviderUsage({ requestId: "usage-1" });
|
|
|
|
expect(JSON.parse(assertStr(mock.sent[0]))).toEqual({
|
|
type: "session",
|
|
message: {
|
|
type: "provider.usage.list.request",
|
|
requestId: "usage-1",
|
|
},
|
|
});
|
|
|
|
mock.triggerMessage(
|
|
wrapSessionMessage({
|
|
type: "provider.usage.list.response",
|
|
payload: {
|
|
requestId: "usage-1",
|
|
fetchedAt: "2026-06-19T00:00:00.000Z",
|
|
providers: [
|
|
{
|
|
providerId: "glm",
|
|
displayName: "GLM coding plan",
|
|
status: "available",
|
|
planLabel: "GLM coding plan",
|
|
windows: [
|
|
{
|
|
id: "biweekly",
|
|
label: "Biweekly",
|
|
usedPct: 23,
|
|
remainingPct: 77,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
},
|
|
}),
|
|
);
|
|
|
|
await expect(usagePromise).resolves.toEqual({
|
|
requestId: "usage-1",
|
|
fetchedAt: "2026-06-19T00:00:00.000Z",
|
|
providers: [
|
|
{
|
|
providerId: "glm",
|
|
displayName: "GLM coding plan",
|
|
status: "available",
|
|
planLabel: "GLM coding plan",
|
|
windows: [
|
|
{
|
|
id: "biweekly",
|
|
label: "Biweekly",
|
|
usedPct: 23,
|
|
remainingPct: 77,
|
|
},
|
|
],
|
|
},
|
|
],
|
|
});
|
|
});
|
|
|
|
test("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"]);
|
|
});
|