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:
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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] : [],
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user