refactor(server): inline client capability parsing

With capability negotiation in place, the consumer-side tolerance pattern is unnecessary. Drop the dead readDeclaredClientCapabilities helper, inline the parsing in ClientCapabilities.fromHello, delete a tautological assertion in the relay reconnect test, and document the compatibility rules in architecture.md.
This commit is contained in:
Mohamed Boudra
2026-05-02 20:30:25 +07:00
parent ca318f29cf
commit d53d601043
4 changed files with 31 additions and 41 deletions
+15
View File
@@ -137,6 +137,21 @@ Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
- Channel 1: terminal data - Channel 1: terminal data
- 1-byte channel ID + 1-byte flags + variable payload - 1-byte channel ID + 1-byte flags + variable payload
### Compatibility rules
- WebSocket schemas are append-only. Add fields, do not remove fields, and never make optional fields required.
- New wire enum values must be gated at serialization with `session.supports(CLIENT_CAPS.someCapability)`.
- `Session` stores client capabilities from the `hello` handshake and rehydrates them on reconnect, so the wire boundary can ask one question: `session.supports(...)`.
Example: adding a new enum value
```ts
// 1. Add CLIENT_CAPS.newThing = "new_thing"
// 2. Let new clients advertise it in WS hello
// 3. Keep the shared producer schema strict
// 4. Gate the new emitted value: session.supports(CLIENT_CAPS.newThing) ? "new_value" : "old_value"
```
## Agent lifecycle ## Agent lifecycle
``` ```
+16 -22
View File
@@ -8,11 +8,7 @@ import { basename, resolve, sep } from "path";
import { homedir } from "node:os"; import { homedir } from "node:os";
import { z } from "zod"; import { z } from "zod";
import type { ToolSet } from "ai"; import type { ToolSet } from "ai";
import { import { CLIENT_CAPS, type ClientCapability } from "../shared/client-capabilities.js";
CLIENT_CAPS,
readDeclaredClientCapabilities,
type ClientCapability,
} from "../shared/client-capabilities.js";
import { import {
isLegacyEditorTargetId, isLegacyEditorTargetId,
serializeAgentStreamEvent, serializeAgentStreamEvent,
@@ -731,20 +727,18 @@ function convertPCMToWavBuffer(
return wavBuffer; return wavBuffer;
} }
class ClientCapabilities { function parseClientCapabilities(
private readonly supported: ReadonlySet<ClientCapability>; capabilities: Record<string, unknown> | null | undefined,
): ReadonlySet<ClientCapability> {
constructor(capabilities: Iterable<ClientCapability>) { if (!capabilities) {
this.supported = new Set(capabilities); return new Set();
}
static fromHello(capabilities: Record<string, unknown> | null | undefined): ClientCapabilities {
return new ClientCapabilities(readDeclaredClientCapabilities(capabilities));
}
supports(capability: ClientCapability): boolean {
return this.supported.has(capability);
} }
const known = new Set<ClientCapability>(Object.values(CLIENT_CAPS));
return new Set(
Object.entries(capabilities).flatMap(([key, value]) =>
value === true && known.has(key as ClientCapability) ? [key as ClientCapability] : [],
),
);
} }
/** /**
@@ -755,7 +749,7 @@ class ClientCapabilities {
export class Session { export class Session {
private readonly clientId: string; private readonly clientId: string;
private appVersion: string | null; private appVersion: string | null;
private clientCapabilities: ClientCapabilities; private clientCapabilities: ReadonlySet<ClientCapability>;
private readonly sessionId: string; private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void; private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null; private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
@@ -912,7 +906,7 @@ export class Session {
} = options; } = options;
this.clientId = clientId; this.clientId = clientId;
this.appVersion = appVersion ?? null; this.appVersion = appVersion ?? null;
this.clientCapabilities = ClientCapabilities.fromHello(clientCapabilities); this.clientCapabilities = parseClientCapabilities(clientCapabilities);
this.sessionId = uuidv4(); this.sessionId = uuidv4();
this.onMessage = onMessage; this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null; this.onBinaryMessage = onBinaryMessage ?? null;
@@ -985,11 +979,11 @@ export class Session {
} }
updateClientCapabilities(capabilities: Record<string, unknown> | null): void { updateClientCapabilities(capabilities: Record<string, unknown> | null): void {
this.clientCapabilities = ClientCapabilities.fromHello(capabilities); this.clientCapabilities = parseClientCapabilities(capabilities);
} }
supports(capability: ClientCapability): boolean { supports(capability: ClientCapability): boolean {
return this.clientCapabilities.supports(capability); return this.clientCapabilities.has(capability);
} }
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> { async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
@@ -429,7 +429,6 @@ describe("relay external socket reconnect behavior", () => {
expect(session.args.clientCapabilities).toEqual({ expect(session.args.clientCapabilities).toEqual({
[CLIENT_CAPS.reasoningMergeEnum]: true, [CLIENT_CAPS.reasoningMergeEnum]: true,
}); });
expect(session.supports(CLIENT_CAPS.reasoningMergeEnum)).toBe(true);
await server.close(); await server.close();
}); });
@@ -3,21 +3,3 @@ export const CLIENT_CAPS = {
} as const; } as const;
export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS]; export type ClientCapability = (typeof CLIENT_CAPS)[keyof typeof CLIENT_CAPS];
const CLIENT_CAPABILITY_SET = new Set<string>(Object.values(CLIENT_CAPS));
export function isClientCapability(value: string): value is ClientCapability {
return CLIENT_CAPABILITY_SET.has(value);
}
export function readDeclaredClientCapabilities(
capabilities: Record<string, unknown> | null | undefined,
): ClientCapability[] {
if (!capabilities) {
return [];
}
return Object.entries(capabilities).flatMap(([key, value]) =>
value === true && isClientCapability(key) ? [key] : [],
);
}