From ca318f29cf62d61c325558a2da59ee5d27a3941c Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Sat, 2 May 2026 20:00:32 +0700 Subject: [PATCH] feat(client): advertise reasoning merge capability --- .../server/src/client/daemon-client.test.ts | 37 +++++++++++++++++-- packages/server/src/client/daemon-client.ts | 4 ++ .../websocket-server.relay-reconnect.test.ts | 36 +++++++++++++++++- 3 files changed, 73 insertions(+), 4 deletions(-) diff --git a/packages/server/src/client/daemon-client.test.ts b/packages/server/src/client/daemon-client.test.ts index b1181fd91..d72342fec 100644 --- a/packages/server/src/client/daemon-client.test.ts +++ b/packages/server/src/client/daemon-client.test.ts @@ -60,10 +60,12 @@ function createMockTransport() { return { transport, sent, - triggerOpen: () => { + triggerOpen: (options?: { preserveSent?: boolean }) => { onOpen(); - // Ignore HELLO handshake payloads in assertions. - sent.length = 0; + if (!options?.preserveSent) { + // Ignore HELLO handshake payloads in assertions. + sent.length = 0; + } onMessage( JSON.stringify({ type: "session", @@ -214,6 +216,35 @@ test("passes password as HTTP bearer header and WebSocket subprotocol", async () }); }); +test("advertises reasoning_merge_enum 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, + }); + clients.push(client); + + const connectPromise = client.connect(); + mock.triggerOpen({ preserveSent: true }); + await connectPromise; + + expect(mock.sent).toHaveLength(1); + expect(JSON.parse(mock.sent[0] as string)).toEqual({ + type: "hello", + clientId: "clsk_unit_test", + clientType: "cli", + protocolVersion: 1, + capabilities: { + reasoning_merge_enum: true, + }, + }); +}); + test("does not reconnect after close when ensureConnected is called", async () => { const logger = createMockLogger(); const mock = createMockTransport(); diff --git a/packages/server/src/client/daemon-client.ts b/packages/server/src/client/daemon-client.ts index 6068c1fdf..01e37b940 100644 --- a/packages/server/src/client/daemon-client.ts +++ b/packages/server/src/client/daemon-client.ts @@ -1,4 +1,5 @@ import type { z } from "zod"; +import { CLIENT_CAPS } from "../shared/client-capabilities.js"; import { AgentCreateFailedStatusPayloadSchema, AgentCreatedStatusPayloadSchema, @@ -3877,6 +3878,9 @@ export class DaemonClient { clientId: this.config.clientId, clientType: this.config.clientType ?? "cli", protocolVersion: 1, + capabilities: { + [CLIENT_CAPS.reasoningMergeEnum]: true, + }, ...(this.config.appVersion ? { appVersion: this.config.appVersion } : {}), }), ); diff --git a/packages/server/src/server/websocket-server.relay-reconnect.test.ts b/packages/server/src/server/websocket-server.relay-reconnect.test.ts index 4e9765866..1dcdbadf5 100644 --- a/packages/server/src/server/websocket-server.relay-reconnect.test.ts +++ b/packages/server/src/server/websocket-server.relay-reconnect.test.ts @@ -15,6 +15,7 @@ import { encodeTerminalStreamFrame, TerminalStreamOpcode, } from "../shared/terminal-stream-protocol.js"; +import { CLIENT_CAPS } from "../shared/client-capabilities.js"; type SocketListener = (...args: unknown[]) => void; @@ -47,6 +48,7 @@ const sessionMock = vi.hoisted(() => { cleanup = vi.fn(async () => {}); handleMessage = vi.fn(async () => {}); handleBinaryFrame = vi.fn((_frame: unknown) => {}); + supports = vi.fn((capability: string) => this.args.clientCapabilities?.[capability] === true); getClientActivity = vi.fn(() => null); resetPeakInflight = vi.fn(() => {}); getRuntimeMetrics = vi.fn(() => ({ @@ -293,12 +295,16 @@ function createDownloadInProgressSpeechReadinessSnapshot(): SpeechReadinessSnaps }; } -function createHelloMessage(clientId: string) { +function createHelloMessage( + clientId: string, + options?: { capabilities?: Record }, +) { return { type: "hello" as const, clientId, clientType: "cli" as const, protocolVersion: 1, + ...(options?.capabilities ? { capabilities: options.capabilities } : {}), }; } @@ -400,6 +406,34 @@ describe("relay external socket reconnect behavior", () => { await server.close(); }); + test("passes hello capabilities through to the created session", async () => { + const server = createServer(); + const socket = new MockSocket(); + + await (server as unknown as WebSocketServerInternals).attachSocket( + socket, + createDirectRequest(), + ); + socket.emit( + "message", + JSON.stringify( + createHelloMessage("client-capabilities", { + capabilities: { [CLIENT_CAPS.reasoningMergeEnum]: true }, + }), + ), + ); + await Promise.resolve(); + + expect(sessionMock.instances).toHaveLength(1); + const session = sessionMock.instances[0]!; + expect(session.args.clientCapabilities).toEqual({ + [CLIENT_CAPS.reasoningMergeEnum]: true, + }); + expect(session.supports(CLIENT_CAPS.reasoningMergeEnum)).toBe(true); + + await server.close(); + }); + test("closes pending connection when hello timeout elapses", async () => { const server = createServer();