feat: implement daemon connectivity updates and relay protocol versioning
This commit is contained in:
@@ -83,7 +83,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -166,12 +166,177 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
})
|
||||
|
||||
test('does not reconnect after close when ensureConnected is called', async () => {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=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('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?clientSessionKey=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&clientId=clsk_test&v=2',
|
||||
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 explicit relay session identity when URL clientId is missing', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&v=2',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Relay client requires clientSessionKey or URL clientId')
|
||||
})
|
||||
|
||||
test('rejects relay identity mismatch between URL clientId and clientSessionKey', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://relay.test/ws?role=client&serverId=srv_test&clientId=clsk_a&v=2',
|
||||
clientSessionKey: 'clsk_b',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Relay clientId and clientSessionKey must match when both are provided')
|
||||
})
|
||||
|
||||
test('requires explicit direct session identity when URL clientSessionKey is missing', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://127.0.0.1:6767/ws',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Direct client requires clientSessionKey or URL clientSessionKey')
|
||||
})
|
||||
|
||||
test('rejects direct identity mismatch between URL and config clientSessionKey', () => {
|
||||
expect(() => {
|
||||
new DaemonClient({
|
||||
url: 'ws://127.0.0.1:6767/ws?clientSessionKey=clsk_a',
|
||||
clientSessionKey: 'clsk_b',
|
||||
reconnect: { enabled: false },
|
||||
})
|
||||
}).toThrow('Direct URL clientSessionKey and config clientSessionKey must match when both are provided')
|
||||
})
|
||||
|
||||
test('logs configured runtime generation in connection transition events', async () => {
|
||||
const logger = createMockLogger()
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test?clientSessionKey=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.info.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',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -234,7 +399,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -302,7 +467,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -361,7 +526,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -432,7 +597,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -494,7 +659,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -540,7 +705,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -621,7 +786,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -664,7 +829,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -716,7 +881,7 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory,
|
||||
@@ -743,7 +908,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -797,7 +962,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -872,7 +1037,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -928,7 +1093,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -974,7 +1139,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1016,7 +1181,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1037,7 +1202,12 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
)
|
||||
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
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) => {
|
||||
@@ -1045,11 +1215,7 @@ describe('DaemonClient', () => {
|
||||
})
|
||||
|
||||
expect(seen.join('')).toContain('buffered')
|
||||
expect(mock.sent).toHaveLength(1)
|
||||
const ackFrame = decodeBinaryMuxFrame(asUint8Array(mock.sent[0])!)
|
||||
expect(ackFrame?.messageType).toBe(TerminalBinaryMessageType.Ack)
|
||||
expect(ackFrame?.streamId).toBe(19)
|
||||
expect(ackFrame?.offset).toBe(8)
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
})
|
||||
|
||||
test('stops delivering and acking after terminal_stream_exit', async () => {
|
||||
@@ -1057,7 +1223,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1106,7 +1272,11 @@ describe('DaemonClient', () => {
|
||||
)
|
||||
|
||||
expect(seen).toEqual(['before-exit'])
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
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()
|
||||
})
|
||||
|
||||
@@ -1115,7 +1285,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1165,7 +1335,11 @@ describe('DaemonClient', () => {
|
||||
)
|
||||
|
||||
expect(seen).toEqual([])
|
||||
expect(mock.sent).toHaveLength(0)
|
||||
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 () => {
|
||||
@@ -1173,7 +1347,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1244,7 +1418,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1296,7 +1470,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1388,7 +1562,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1458,7 +1632,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
@@ -1494,7 +1668,7 @@ describe('DaemonClient', () => {
|
||||
const mock = createMockTransport()
|
||||
|
||||
const client = new DaemonClient({
|
||||
url: 'ws://test',
|
||||
url: 'ws://test?clientSessionKey=clsk_unit_test',
|
||||
logger,
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
|
||||
Reference in New Issue
Block a user