Files
paseo/packages/server/src/client/daemon-client.test.ts
Mohamed Boudra b09a731d85 refactor: replace welcome message with server_info status payload
Unify the initial connection handshake and capability update paths.
The server now sends a server_info status message on connect and
whenever capabilities change, enabling the onboarding flow where
voice becomes available after models are configured.
2026-03-12 20:38:03 +07:00

1932 lines
52 KiB
TypeScript

import { afterEach, describe, expect, expectTypeOf, test, vi } from 'vitest'
import { DaemonClient, type DaemonTransport } from './daemon-client'
import {
BinaryMuxChannel,
TerminalBinaryMessageType,
asUint8Array,
decodeBinaryMuxFrame,
encodeBinaryMuxFrame,
} from '../shared/binary-mux.js'
expectTypeOf<'getGitDiff' extends keyof DaemonClient ? true : false>().toEqualTypeOf<false>()
expectTypeOf<
'getHighlightedDiff' extends keyof DaemonClient ? true : false
>().toEqualTypeOf<false>()
function createMockLogger() {
return {
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}
}
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),
close: () => {},
onMessage: (handler) => {
onMessage = handler
return () => {}
},
onOpen: (handler) => {
onOpen = handler
return () => {}
},
onClose: (handler) => {
onClose = handler
return () => {}
},
onError: (handler) => {
onError = handler
return () => {}
},
}
return {
transport,
sent,
triggerOpen: () => {
onOpen()
// 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,
},
},
})
)
},
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,
})
}
describe('DaemonClient', () => {
const clients: DaemonClient[] = []
afterEach(async () => {
for (const client of clients) {
await client.close()
}
clients.length = 0
})
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 = JSON.parse(mock.sent[0]) as {
type: 'session'
message: { type: 'checkout_status_request'; cwd: string; requestId: string }
}
const response = {
type: 'session',
message: {
type: 'checkout_status_response',
payload: {
cwd: '/tmp/project',
error: null,
requestId: request.message.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.message.requestId,
isGit: false,
})
expect(r2).toMatchObject({
cwd: '/tmp/project',
requestId: request.message.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 = JSON.parse(mock.sent[1]) as {
type: 'session'
message: { type: 'checkout_status_request'; cwd: string; requestId: string }
}
mock.triggerMessage(
JSON.stringify({
...response,
message: {
...response.message,
payload: { ...response.message.payload, requestId: request2.message.requestId },
},
})
)
await expect(p3).resolves.toMatchObject({
cwd: '/tmp/project',
requestId: request2.message.requestId,
isGit: false,
})
})
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('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
const lifecycleClient = client as unknown as {
shutdownServer: (requestId?: string) => Promise<{
status: 'shutdown_requested'
clientId: string
requestId: string
}>
}
expect(typeof lifecycleClient.shutdownServer).toBe('function')
const promise = lifecycleClient.shutdownServer('req-shutdown-1')
expect(mock.sent).toHaveLength(1)
const request = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: string
requestId: string
}
}
expect(request.message).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 = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: string
reason?: string
requestId: string
}
}
expect(request.message).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 () => {
vi.useFakeTimers()
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)
expect((result.error as 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 () => {
vi.useFakeTimers()
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(() => {
new DaemonClient({
url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2',
clientId: '',
reconnect: { enabled: false },
})
}).toThrow('Daemon client requires a non-empty clientId')
})
test('requires non-empty clientId for direct connections', () => {
expect(() => {
new DaemonClient({
url: 'ws://127.0.0.1:6767/ws',
clientId: ' ',
reconnect: { enabled: false },
})
}).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')
.map(([payload]) => payload as { generation?: number | null })
expect(transitionPayloads.length).toBeGreaterThan(0)
for (const payload of transitionPayloads) {
expect(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 promise = client.subscribeCheckoutDiff(
'/tmp/project',
{ mode: 'uncommitted' },
{ subscriptionId: 'checkout-sub-1' }
)
expect(mock.sent).toHaveLength(1)
const request = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: 'subscribe_checkout_diff_request'
subscriptionId: string
cwd: string
compare: { mode: 'uncommitted' | 'base'; baseRef?: string }
requestId: string
}
}
expect(request.message.type).toBe('subscribe_checkout_diff_request')
expect(request.message.subscriptionId).toBe('checkout-sub-1')
expect(request.message.cwd).toBe('/tmp/project')
expect(request.message.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.message.requestId,
},
},
})
)
await expect(promise).resolves.toEqual({
subscriptionId: 'checkout-sub-1',
cwd: '/tmp/project',
files: [],
error: null,
requestId: request.message.requestId,
})
})
test('getCheckoutDiff uses one-shot subscription protocol', async () => {
const logger = createMockLogger()
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://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 = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: 'subscribe_checkout_diff_request'
subscriptionId: string
cwd: string
compare: { mode: 'uncommitted' | 'base'; baseRef?: string }
requestId: string
}
}
expect(subscribeRequest.message.type).toBe('subscribe_checkout_diff_request')
expect(subscribeRequest.message.cwd).toBe('/tmp/project')
expect(subscribeRequest.message.compare).toEqual({ mode: 'base', baseRef: 'main' })
mock.triggerMessage(
JSON.stringify({
type: 'session',
message: {
type: 'subscribe_checkout_diff_response',
payload: {
subscriptionId: subscribeRequest.message.subscriptionId,
cwd: '/tmp/project',
files: [],
error: null,
requestId: subscribeRequest.message.requestId,
},
},
})
)
await expect(promise).resolves.toEqual({
cwd: '/tmp/project',
files: [],
error: null,
requestId: subscribeRequest.message.requestId,
})
expect(mock.sent).toHaveLength(2)
const unsubscribeRequest = JSON.parse(mock.sent[1]) as {
type: 'session'
message: {
type: 'unsubscribe_checkout_diff_request'
subscriptionId: string
}
}
expect(unsubscribeRequest.message.type).toBe('unsubscribe_checkout_diff_request')
expect(unsubscribeRequest.message.subscriptionId).toBe(subscribeRequest.message.subscriptionId)
})
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 = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: 'branch_suggestions_request'
cwd: string
query?: string
limit?: number
requestId: string
}
}
expect(request.message.type).toBe('branch_suggestions_request')
expect(request.message.cwd).toBe('/tmp/project')
expect(request.message.query).toBe('mai')
expect(request.message.limit).toBe(5)
expect(request.message.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('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,
},
'req-directories'
)
expect(mock.sent).toHaveLength(1)
const request = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: 'directory_suggestions_request'
query: string
cwd?: string
includeFiles?: boolean
includeDirectories?: boolean
limit?: number
requestId: string
}
}
expect(request.message.type).toBe('directory_suggestions_request')
expect(request.message.query).toBe('proj')
expect(request.message.cwd).toBe('/tmp/project')
expect(request.message.includeFiles).toBe(true)
expect(request.message.includeDirectories).toBe(true)
expect(request.message.limit).toBe(10)
expect(request.message.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 = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: 'checkout_merge_from_base_request'
cwd: string
baseRef?: string
requireCleanTarget?: boolean
requestId: string
}
}
expect(request.message.type).toBe('checkout_merge_from_base_request')
expect(request.message.cwd).toBe('/tmp/project')
expect(request.message.baseRef).toBe('main')
expect(request.message.requireCleanTarget).toBe(true)
expect(request.message.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('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 internal = client as unknown as {
checkoutDiffSubscriptions: Map<
string,
{ cwd: string; compare: { mode: 'uncommitted' | 'base'; baseRef?: string } }
>
}
internal.checkoutDiffSubscriptions.set('checkout-sub-1', {
cwd: '/tmp/project',
compare: { mode: 'base', baseRef: 'main' },
})
const connectPromise = client.connect()
mock.triggerOpen()
await connectPromise
expect(mock.sent).toHaveLength(1)
const request = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: 'subscribe_checkout_diff_request'
subscriptionId: string
cwd: string
compare: { mode: 'uncommitted' | 'base'; baseRef?: string }
requestId: string
}
}
expect(request.message.type).toBe('subscribe_checkout_diff_request')
expect(request.message.subscriptionId).toBe('checkout-sub-1')
expect(request.message.cwd).toBe('/tmp/project')
expect(request.message.compare).toEqual({ mode: 'base', baseRef: 'main' })
expect(typeof request.message.requestId).toBe('string')
expect(request.message.requestId.length).toBeGreaterThan(0)
})
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: { subscriptionId: 'sub-1' },
})
expect(mock.sent).toHaveLength(1)
const request = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: 'fetch_agents_request'
requestId: string
filter?: { labels?: Record<string, string> }
sort?: Array<{
key: 'status_priority' | 'created_at' | 'updated_at' | 'title'
direction: 'asc' | 'desc'
}>
page?: { limit: number; cursor?: string }
subscribe?: { subscriptionId?: string }
}
}
expect(request.message.type).toBe('fetch_agents_request')
expect(request.message.sort).toEqual([
{ key: 'status_priority', direction: 'asc' },
{ key: 'created_at', direction: 'desc' },
])
expect(request.message.page).toEqual({ limit: 25, cursor: 'cursor-1' })
expect(request.message.subscribe).toEqual({ subscriptionId: 'sub-1' })
mock.triggerMessage(
JSON.stringify({
type: 'session',
message: {
type: 'fetch_agents_response',
payload: {
requestId: request.message.requestId,
subscriptionId: 'sub-1',
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: 'cursor-1',
hasMore: false,
},
},
},
})
)
await expect(promise).resolves.toEqual({
requestId: request.message.requestId,
subscriptionId: 'sub-1',
entries: [],
pageInfo: {
nextCursor: null,
prevCursor: 'cursor-1',
hasMore: false,
},
})
})
test('uses server-provided dictation finish timeout budget', async () => {
vi.useFakeTimers()
const logger = createMockLogger()
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://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)
expect((error as 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 () => {
vi.useFakeTimers()
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)
vi.runOnlyPendingTimers()
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 = JSON.parse(mock.sent[0]) as {
type: 'session'
message: { type: 'list_available_providers_request'; requestId: string }
}
expect(request.message.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.message.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.message.requestId,
})
})
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('__new_agent__', {
draftConfig: {
provider: 'codex',
cwd: '/tmp/project',
modeId: 'bypassPermissions',
model: 'gpt-5',
thinkingOptionId: 'off',
},
})
expect(mock.sent).toHaveLength(1)
const request = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: 'list_commands_request'
agentId: string
draftConfig?: {
provider: string
cwd: string
modeId?: string
model?: string
thinkingOptionId?: string
}
requestId: string
}
}
expect(request.message.type).toBe('list_commands_request')
expect(request.message.agentId).toBe('__new_agent__')
expect(request.message.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.message.requestId,
},
},
})
)
await expect(promise).resolves.toEqual({
agentId: '__new_agent__',
commands: [{ name: 'help', description: 'Show help', argumentHint: '' }],
error: null,
requestId: request.message.requestId,
})
})
test('lists commands with legacy requestId signature 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('agent-1', 'req-legacy')
expect(mock.sent).toHaveLength(1)
const request = JSON.parse(mock.sent[0]) as {
type: 'session'
message: {
type: 'list_commands_request'
agentId: string
draftConfig?: unknown
requestId: string
}
}
expect(request.message.type).toBe('list_commands_request')
expect(request.message.agentId).toBe('agent-1')
expect(request.message.requestId).toBe('req-legacy')
expect(request.message.draftConfig).toBeUndefined()
mock.triggerMessage(
JSON.stringify({
type: 'session',
message: {
type: 'list_commands_response',
payload: {
agentId: 'agent-1',
commands: [],
error: null,
requestId: 'req-legacy',
},
},
})
)
await expect(promise).resolves.toEqual({
agentId: 'agent-1',
commands: [],
error: null,
requestId: 'req-legacy',
})
})
test('auto-acks terminal stream chunks after delivery', async () => {
const logger = createMockLogger()
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://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 decoder = new TextDecoder()
const unsubscribe = client.onTerminalStreamData(7, (chunk) => {
seen.push(decoder.decode(chunk.data))
})
const payload = new TextEncoder().encode('hello')
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 7,
offset: 10,
payload,
})
)
expect(seen).toEqual(['hello'])
expect(mock.sent).toHaveLength(1)
const ackBytes = asUint8Array(mock.sent[0])
expect(ackBytes).not.toBeNull()
const ackFrame = decodeBinaryMuxFrame(ackBytes!)
expect(ackFrame).not.toBeNull()
expect(ackFrame!.channel).toBe(BinaryMuxChannel.Terminal)
expect(ackFrame!.messageType).toBe(TerminalBinaryMessageType.Ack)
expect(ackFrame!.streamId).toBe(7)
expect(ackFrame!.offset).toBe(15)
unsubscribe()
})
test('handles terminal binary frames delivered as UTF-8 strings', async () => {
const logger = createMockLogger()
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://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.onTerminalStreamData(11, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data))
})
const payload = new TextEncoder().encode('ls\r\n')
const frame = encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 11,
offset: 0,
payload,
})
const frameAsString = new TextDecoder('utf-8', { fatal: true }).decode(frame)
mock.triggerMessage(frameAsString)
expect(seen).toEqual(['ls\r\n'])
expect(mock.sent).toHaveLength(1)
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
expect(ackFrame?.channel).toBe(BinaryMuxChannel.Terminal)
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack)
expect(ackFrame?.streamId).toBe(11)
expect(ackFrame?.offset).toBe(4)
})
test('acks buffered terminal chunks when handler is attached', async () => {
const logger = createMockLogger()
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://test',
clientId: 'clsk_unit_test',
logger,
reconnect: { enabled: false },
transportFactory: () => mock.transport,
})
clients.push(client)
const connectPromise = client.connect()
mock.triggerOpen()
await connectPromise
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 19,
offset: 0,
payload: new TextEncoder().encode('buffered'),
})
)
expect(mock.sent).toHaveLength(1)
const bufferedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
expect(bufferedAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
expect(bufferedAck?.streamId).toBe(19)
expect(bufferedAck?.offset).toBe(8)
mock.sent.length = 0
const seen: string[] = []
client.onTerminalStreamData(19, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data))
})
expect(seen.join('')).toContain('buffered')
expect(mock.sent).toHaveLength(0)
})
test('stops delivering and acking 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.onTerminalStreamData(23, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data))
})
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 23,
offset: 0,
payload: new TextEncoder().encode('before-exit'),
})
)
expect(seen).toEqual(['before-exit'])
expect(mock.sent).toHaveLength(1)
mock.sent.length = 0
mock.triggerMessage(
wrapSessionMessage({
type: 'terminal_stream_exit',
payload: {
streamId: 23,
terminalId: 'term-1',
},
})
)
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 23,
offset: 12,
payload: new TextEncoder().encode('after-exit'),
})
)
expect(seen).toEqual(['before-exit'])
expect(mock.sent).toHaveLength(1)
const postExitAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
expect(postExitAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
expect(postExitAck?.streamId).toBe(23)
expect(postExitAck?.offset).toBe(22)
unsubscribe()
})
test('cleans local stream state even when detach reports success=false', async () => {
const logger = createMockLogger()
const mock = createMockTransport()
const client = new DaemonClient({
url: 'ws://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.onTerminalStreamData(31, (chunk) => {
seen.push(new TextDecoder().decode(chunk.data))
})
const detachPromise = client.detachTerminalStream(31, 'detach-31')
const detachRequest = JSON.parse(String(mock.sent[0])) as {
type: 'session'
message: { type: string; streamId: number; requestId: string }
}
expect(detachRequest.message.type).toBe('detach_terminal_stream_request')
expect(detachRequest.message.streamId).toBe(31)
mock.triggerMessage(
wrapSessionMessage({
type: 'detach_terminal_stream_response',
payload: {
streamId: 31,
success: false,
requestId: 'detach-31',
},
})
)
const payload = await detachPromise
expect(payload.success).toBe(false)
mock.sent.length = 0
mock.triggerMessage(
encodeBinaryMuxFrame({
channel: BinaryMuxChannel.Terminal,
messageType: TerminalBinaryMessageType.OutputUtf8,
streamId: 31,
offset: 0,
payload: new TextEncoder().encode('after-detach'),
})
)
expect(seen).toEqual([])
expect(mock.sent).toHaveLength(1)
const detachedAck = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
expect(detachedAck?.messageType).toBe(TerminalBinaryMessageType.Ack)
expect(detachedAck?.streamId).toBe(31)
expect(detachedAck?.offset).toBe(12)
})
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)
const streamMsg = received[0] as {
payload: {
event: {
type: 'timeline'
item: {
type: 'tool_call'
status: string
error: unknown
detail: {
type: string
}
}
}
}
}
expect(streamMsg.payload.event.item.status).toBe('running')
expect(streamMsg.payload.event.item.error).toBeNull()
expect(streamMsg.payload.event.item.detail.type).toBe('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)
const timelineMsg = received[0] as {
payload: {
entries: Array<{
item: {
type: 'tool_call'
status: string
error: unknown
detail: {
type: string
}
}
}>
}
}
const firstEntry = timelineMsg.payload.entries[0]
expect(firstEntry?.item.type).toBe('tool_call')
if (firstEntry?.item.type === 'tool_call') {
expect(firstEntry.item.status).toBe('running')
expect(firstEntry.item.error).toBeNull()
expect(firstEntry.item.detail.type).toBe('shell')
}
expect(logger.warn).not.toHaveBeenCalled()
})
test('drops invalid fetch_agent_timeline_response 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('fetch_agent_timeline_response', (msg) => {
received.push(msg)
})
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,
},
})
)
unsubscribe()
expect(received).toHaveLength(0)
expect(logger.warn).toHaveBeenCalled()
})
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
client.subscribeTerminals({ cwd: '/tmp/project' })
client.unsubscribeTerminals({ cwd: '/tmp/project' })
expect(mock.sent).toHaveLength(2)
expect(JSON.parse(String(mock.sent[0]))).toEqual({
type: 'session',
message: {
type: 'subscribe_terminals_request',
cwd: '/tmp/project',
},
})
expect(JSON.parse(String(mock.sent[1]))).toEqual({
type: 'session',
message: {
type: 'unsubscribe_terminals_request',
cwd: '/tmp/project',
},
})
})
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('waitForFinish with timeout=0 omits timeoutMs and has no client deadline', async () => {
vi.useFakeTimers()
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 = JSON.parse(String(mock.sent[0])) as {
type: 'session'
message: {
type: 'wait_for_finish_request'
requestId: string
agentId: string
timeoutMs?: number
}
}
expect(request.message.type).toBe('wait_for_finish_request')
expect(request.message.agentId).toBe('agent-wait-zero-timeout')
expect(request.message).not.toHaveProperty('timeoutMs')
const settled = vi.fn()
void waitPromise.then(
() => settled('resolved'),
() => settled('rejected')
)
await vi.advanceTimersByTimeAsync(5 * 60 * 1000)
expect(settled).not.toHaveBeenCalled()
mock.triggerMessage(
wrapSessionMessage({
type: 'wait_for_finish_response',
payload: {
requestId: request.message.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()
}
})
})