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