fix(server): restore wire compatibility for old clients

This commit is contained in:
Mohamed Boudra
2026-05-02 19:47:50 +07:00
parent 71e83dc42f
commit e3e1c08525
9 changed files with 112 additions and 3 deletions

View File

@@ -237,6 +237,11 @@ export type ToolCallDetail =
description?: string;
childSessionId?: string;
log: string;
actions?: Array<{
index: number;
toolName: string;
summary?: string;
}>;
}
| {
type: "plain_text";

View File

@@ -99,6 +99,7 @@ export class ClaudeSidechainTracker {
action.summary ? `[${action.toolName}] ${action.summary}` : `[${action.toolName}]`,
)
.join("\n"),
actions: [],
};
return [

View File

@@ -851,6 +851,7 @@ function mapCollabAgentToolCallItem(
subAgentType: "Sub-agent",
...(item.prompt ? { description: item.prompt } : {}),
log: "",
actions: [],
};
if (status === "failed") {

View File

@@ -111,6 +111,7 @@ function deriveOpencodeTaskDetail(
...(description ? { description } : {}),
...(childSessionId ? { childSessionId } : {}),
log,
actions: [],
};
}

View File

@@ -8,6 +8,11 @@ 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 {
isLegacyEditorTargetId,
serializeAgentStreamEvent,
@@ -552,6 +557,7 @@ interface VoiceTranscriptionResultPayload {
export interface SessionOptions {
clientId: string;
appVersion?: string | null;
clientCapabilities?: Record<string, unknown> | null;
onMessage: (msg: SessionOutboundMessage) => void;
onBinaryMessage?: (frame: Uint8Array) => void;
onLifecycleIntent?: (intent: SessionLifecycleIntent) => void;
@@ -725,6 +731,22 @@ 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);
}
}
/**
* Session represents a single connected client session.
* It owns all state management, orchestration logic, and message processing.
@@ -733,6 +755,7 @@ function convertPCMToWavBuffer(
export class Session {
private readonly clientId: string;
private appVersion: string | null;
private clientCapabilities: ClientCapabilities;
private readonly sessionId: string;
private readonly onMessage: (msg: SessionOutboundMessage) => void;
private readonly onBinaryMessage: ((frame: Uint8Array) => void) | null;
@@ -849,6 +872,7 @@ export class Session {
const {
clientId,
appVersion,
clientCapabilities,
onMessage,
onBinaryMessage,
onLifecycleIntent,
@@ -888,6 +912,7 @@ export class Session {
} = options;
this.clientId = clientId;
this.appVersion = appVersion ?? null;
this.clientCapabilities = ClientCapabilities.fromHello(clientCapabilities);
this.sessionId = uuidv4();
this.onMessage = onMessage;
this.onBinaryMessage = onBinaryMessage ?? null;
@@ -959,6 +984,14 @@ export class Session {
}
}
updateClientCapabilities(capabilities: Record<string, unknown> | null): void {
this.clientCapabilities = ClientCapabilities.fromHello(capabilities);
}
supports(capability: ClientCapability): boolean {
return this.clientCapabilities.supports(capability);
}
async syncWorkspaceGitObserverForWorkspace(workspace: PersistedWorkspaceRecord): Promise<void> {
const descriptor = await this.describeWorkspaceRecordWithGitData(workspace);
this.syncWorkspaceGitObservers([descriptor]);
@@ -7204,7 +7237,9 @@ export class Session {
seqStart: entry.seqStart,
seqEnd: entry.seqEnd,
sourceSeqRanges: entry.sourceSeqRanges,
collapsed: entry.collapsed,
collapsed: this.supports(CLIENT_CAPS.reasoningMergeEnum)
? entry.collapsed
: entry.collapsed.filter((value) => value !== "reasoning_merge"),
})),
error: null,
},

View File

@@ -264,6 +264,7 @@ interface SessionConnection {
session: Session;
clientId: string;
appVersion: string | null;
clientCapabilities: Record<string, unknown> | null;
connectionLogger: pino.Logger;
sockets: Set<WebSocketLike>;
externalDisconnectCleanupTimeout: ReturnType<typeof setTimeout> | null;
@@ -874,14 +875,16 @@ export class VoiceAssistantWebSocketServer {
ws: WebSocketLike;
clientId: string;
appVersion: string | null;
clientCapabilities: Record<string, unknown> | null;
connectionLogger: pino.Logger;
}): SessionConnection {
const { ws, clientId, appVersion, connectionLogger } = params;
const { ws, clientId, appVersion, clientCapabilities, connectionLogger } = params;
let connection: SessionConnection | null = null;
const session = new Session({
clientId,
appVersion,
clientCapabilities,
onMessage: (msg) => {
if (!connection) {
return;
@@ -958,6 +961,7 @@ export class VoiceAssistantWebSocketServer {
session,
clientId,
appVersion,
clientCapabilities,
connectionLogger,
sockets: new Set([ws]),
externalDisconnectCleanupTimeout: null,
@@ -1027,6 +1031,14 @@ export class VoiceAssistantWebSocketServer {
existing.appVersion = newAppVersion;
existing.session.updateAppVersion(newAppVersion);
}
const newClientCapabilities = message.capabilities ?? null;
if (
JSON.stringify(existing.clientCapabilities ?? null) !==
JSON.stringify(newClientCapabilities ?? null)
) {
existing.clientCapabilities = newClientCapabilities;
existing.session.updateClientCapabilities(newClientCapabilities);
}
existing.sockets.add(ws);
this.sessions.set(ws, existing);
this.sendToClient(ws, this.createServerInfoMessage());
@@ -1047,6 +1059,7 @@ export class VoiceAssistantWebSocketServer {
ws,
clientId,
appVersion: message.appVersion ?? null,
clientCapabilities: message.capabilities ?? null,
connectionLogger,
});
this.sessions.set(ws, connection);

View File

@@ -163,6 +163,23 @@ interface HandleCreatePaseoWorktreeRequestDependencies {
) => Promise<CreatePaseoWorktreeWorkflowResult>;
}
function normalizeFirstAgentContext(
request: Extract<SessionInboundMessage, { type: "create_paseo_worktree_request" }>,
): FirstAgentContext | undefined {
if (request.firstAgentContext) {
return request.firstAgentContext;
}
if (request.attachments || request.nameContext) {
return {
attachments: request.attachments ?? [],
...(request.nameContext ? { prompt: request.nameContext } : {}),
};
}
return undefined;
}
export async function buildAgentSessionConfig(
dependencies: BuildAgentSessionConfigDependencies,
config: AgentSessionConfig,
@@ -492,7 +509,7 @@ export async function handleCreatePaseoWorktreeRequest(
const createdWorktree = await dependencies.createPaseoWorktreeWorkflow({
cwd: request.cwd,
worktreeSlug: request.worktreeSlug,
firstAgentContext: request.firstAgentContext,
firstAgentContext: normalizeFirstAgentContext(request),
refName: request.refName,
action: request.action,
githubPrNumber: request.githubPrNumber,

View File

@@ -0,0 +1,23 @@
export const CLIENT_CAPS = {
reasoningMergeEnum: "reasoning_merge_enum",
} 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] : [],
);
}

View File

@@ -1,4 +1,5 @@
import { z } from "zod";
import { CLIENT_CAPS } from "./client-capabilities.js";
import { AGENT_LIFECYCLE_STATUSES } from "./agent-lifecycle.js";
import { MAX_EXPLICIT_AGENT_TITLE_CHARS } from "../server/agent/agent-title-limits.js";
import { AgentProviderSchema } from "../server/agent/provider-manifest.js";
@@ -414,6 +415,15 @@ const ToolCallDetailPayloadSchema: z.ZodType<ToolCallDetail, z.ZodTypeDef, unkno
description: z.string().optional(),
childSessionId: z.string().optional(),
log: z.string(),
actions: z
.array(
z.object({
index: z.number().int().positive(),
toolName: z.string(),
summary: z.string().optional(),
}),
)
.optional(),
}),
z.object({
type: z.literal("plain_text"),
@@ -1374,6 +1384,8 @@ export const CreatePaseoWorktreeRequestSchema = z.object({
type: z.literal("create_paseo_worktree_request"),
cwd: z.string(),
worktreeSlug: z.string().optional(),
nameContext: z.string().optional(),
attachments: AgentAttachmentsSchema.optional(),
firstAgentContext: FirstAgentContextSchema.optional(),
refName: z.string().min(1).optional(),
action: z.enum(["branch-off", "checkout"]).optional(),
@@ -3661,6 +3673,7 @@ export const WSHelloMessageSchema = z.object({
.object({
voice: z.boolean().optional(),
pushNotifications: z.boolean().optional(),
[CLIENT_CAPS.reasoningMergeEnum]: z.boolean().optional(),
})
.passthrough()
.optional(),