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:01:49 +07:00
parent ca318f29cf
commit d53d601043
4 changed files with 31 additions and 41 deletions

View File

@@ -137,6 +137,21 @@ Terminal I/O and agent streaming share the same connection via `BinaryMuxFrame`:
- Channel 1: terminal data
- 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
```

View File

@@ -8,11 +8,7 @@ import { basename, resolve, sep } from "path";
import { homedir } from "node:os";
import { z } from "zod";
import type { ToolSet } from "ai";
import {
CLIENT_CAPS,
readDeclaredClientCapabilities,
type ClientCapability,
} from "../shared/client-capabilities.js";
import { CLIENT_CAPS, type ClientCapability } from "../shared/client-capabilities.js";
import {
isLegacyEditorTargetId,
serializeAgentStreamEvent,
@@ -731,20 +727,18 @@ function convertPCMToWavBuffer(
return wavBuffer;
}
class ClientCapabilities {
private readonly supported: ReadonlySet<ClientCapability>;
constructor(capabilities: Iterable<ClientCapability>) {
this.supported = new Set(capabilities);
}
static fromHello(capabilities: Record<string, unknown> | null | undefined): ClientCapabilities {
return new ClientCapabilities(readDeclaredClientCapabilities(capabilities));
}
supports(capability: ClientCapability): boolean {
return this.supported.has(capability);
function parseClientCapabilities(
capabilities: Record<string, unknown> | null | undefined,
): ReadonlySet<ClientCapability> {
if (!capabilities) {
return new Set();
}
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 {
private readonly clientId: string;
private appVersion: string | null;
private clientCapabilities: ClientCapabilities;
private clientCapabilities: ReadonlySet<ClientCapability>;
private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
@@ -912,7 +906,7 @@ export class Session {
} = options;
this.clientId = clientId;
this.appVersion = appVersion ?? null;
this.clientCapabilities = ClientCapabilities.fromHello(clientCapabilities);
this.clientCapabilities = parseClientCapabilities(clientCapabilities);
this.sessionId = uuidv4();
this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null;
@@ -985,11 +979,11 @@ export class Session {
}
updateClientCapabilities(capabilities: Record<string, unknown> | null): void {
this.clientCapabilities = ClientCapabilities.fromHello(capabilities);
this.clientCapabilities = parseClientCapabilities(capabilities);
}
supports(capability: ClientCapability): boolean {
return this.clientCapabilities.supports(capability);
return this.clientCapabilities.has(capability);
}
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {

View File

@@ -429,7 +429,6 @@ describe("relay external socket reconnect behavior", () => {
expect(session.args.clientCapabilities).toEqual({
[CLIENT_CAPS.reasoningMergeEnum]: true,
});
expect(session.supports(CLIENT_CAPS.reasoningMergeEnum)).toBe(true);
await server.close();
});

View File

@@ -3,21 +3,3 @@ export const CLIENT_CAPS = {
} as const;
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] : [],
);
}