refactor: replace welcome message with server_info status payload

Unify the initial connection handshake and capability update paths.
The server now sends a server_info status message on connect and
whenever capabilities change, enabling the onboarding flow where
voice becomes available after models are configured.
This commit is contained in:
Mohamed Boudra
2026-03-12 20:38:03 +07:00
parent 554017b0b8
commit b09a731d85
16 changed files with 177 additions and 162 deletions
+1 -34
View File
@@ -356,7 +356,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
serverInfo, serverInfo,
mode: 'dictation', mode: 'dictation',
}) })
const dictationReadiness = serverInfo?.capabilities?.voice?.dictation ?? null
const canStartDictation = useCallback(() => { const canStartDictation = useCallback(() => {
const socketConnected = client?.isConnected ?? false const socketConnected = client?.isConnected ?? false
@@ -416,28 +415,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
}, [dictationStatus, isDictating, isDictationProcessing]) }, [dictationStatus, isDictating, isDictationProcessing])
const startDictationIfAvailable = useCallback(async () => { const startDictationIfAvailable = useCallback(async () => {
console.info(
`[DictationGate] start requested ${JSON.stringify({
serverId: voiceServerId ?? null,
agentId: voiceAgentId ?? null,
socketConnected: client?.isConnected ?? false,
isReadyForDictation: isReadyForDictation ?? null,
disabled,
serverInfoPresent: Boolean(serverInfo),
capabilitiesPresent: Boolean(serverInfo?.capabilities),
dictationReadinessEnabled: dictationReadiness?.enabled ?? null,
dictationReadinessReason: dictationReadiness?.reason ?? null,
dictationUnavailableMessage,
})}`
)
if (dictationUnavailableMessage) { if (dictationUnavailableMessage) {
console.info(
`[DictationGate] blocked before start ${JSON.stringify({
serverId: voiceServerId ?? null,
agentId: voiceAgentId ?? null,
dictationUnavailableMessage,
})}`
)
isDictatingRef.current = false isDictatingRef.current = false
toast.error(dictationUnavailableMessage) toast.error(dictationUnavailableMessage)
return return
@@ -446,18 +424,7 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(funct
// state-ref sync effect, so a rapid second toggle routes to confirm. // state-ref sync effect, so a rapid second toggle routes to confirm.
isDictatingRef.current = true isDictatingRef.current = true
await startDictation() await startDictation()
}, [ }, [dictationUnavailableMessage, startDictation, toast])
client,
dictationReadiness,
dictationUnavailableMessage,
disabled,
isReadyForDictation,
serverInfo,
startDictation,
toast,
voiceAgentId,
voiceServerId,
])
// Animate overlay // Animate overlay
useEffect(() => { useEffect(() => {
@@ -1100,19 +1100,6 @@ function SessionProviderInternal({
if (message.type !== "status") return; if (message.type !== "status") return;
const serverInfo = parseServerInfoStatusPayload(message.payload); const serverInfo = parseServerInfoStatusPayload(message.payload);
if (serverInfo) { if (serverInfo) {
console.info(
`[Session] server_info received ${JSON.stringify({
serverId,
payloadServerId: serverInfo.serverId,
hostname: serverInfo.hostname,
version: serverInfo.version,
capabilitiesPresent: Boolean(serverInfo.capabilities),
dictationCapabilityEnabled:
serverInfo.capabilities?.voice?.dictation?.enabled ?? null,
dictationCapabilityReason:
serverInfo.capabilities?.voice?.dictation?.reason ?? null,
})}`
);
updateSessionServerInfo(serverId, { updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId, serverId: serverInfo.serverId,
hostname: serverInfo.hostname, hostname: serverInfo.hostname,
@@ -5,12 +5,11 @@ const daemonClientMock = vi.hoisted(() => {
class MockDaemonClient { class MockDaemonClient {
public lastError: string | null = null; public lastError: string | null = null;
private lastWelcome = { private lastServerInfo = {
type: "welcome" as const, status: "server_info" as const,
serverId: "srv_probe_test", serverId: "srv_probe_test",
hostname: "probe-host" as string | null, hostname: "probe-host" as string | null,
version: "0.0.0", version: "0.0.0",
resumed: false,
}; };
constructor(config: { clientId?: string; url?: string }) { constructor(config: { clientId?: string; url?: string }) {
@@ -29,8 +28,8 @@ const daemonClientMock = vi.hoisted(() => {
return; return;
} }
getLastWelcomeMessage() { getLastServerInfoMessage() {
return this.lastWelcome; return this.lastServerInfo;
} }
async ping(): Promise<{ rttMs: number }> { async ping(): Promise<{ rttMs: number }> {
@@ -115,12 +115,12 @@ export function connectAndProbe(
void client.connect().then(() => { void client.connect().then(() => {
clearTimeout(timer); clearTimeout(timer);
const welcome = client.getLastWelcomeMessage(); const serverInfo = client.getLastServerInfoMessage();
if (!welcome) { if (!serverInfo) {
void client.close().catch(() => undefined); void client.close().catch(() => undefined);
reject( reject(
new DaemonConnectionTestError("Missing welcome message", { new DaemonConnectionTestError("Missing server info message", {
reason: "Missing welcome message", reason: "Missing server info message",
lastError: client.lastError ?? null, lastError: client.lastError ?? null,
}) })
); );
@@ -128,8 +128,8 @@ export function connectAndProbe(
} }
resolve({ resolve({
client, client,
serverId: welcome.serverId, serverId: serverInfo.serverId,
hostname: welcome.hostname, hostname: serverInfo.hostname,
}); });
}).catch((error) => { }).catch((error) => {
clearTimeout(timer); clearTimeout(timer);
+1 -1
View File
@@ -5,7 +5,7 @@
"description": "Paseo desktop app (Tauri wrapper)", "description": "Paseo desktop app (Tauri wrapper)",
"scripts": { "scripts": {
"build:managed-runtime": "node ./scripts/build-managed-runtime.mjs", "build:managed-runtime": "node ./scripts/build-managed-runtime.mjs",
"prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime", "prepare:managed-runtime": "npm --prefix ../.. run build:daemon && npm run build:managed-runtime && npm run validate:managed-runtime",
"dev": "npm run prepare:managed-runtime && tauri dev", "dev": "npm run prepare:managed-runtime && tauri dev",
"build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build", "build": "npm --prefix ../.. run build:web --workspace=@getpaseo/app && npm run prepare:managed-runtime && tauri build",
"validate:managed-runtime": "node ./scripts/validate-managed-runtime.mjs", "validate:managed-runtime": "node ./scripts/validate-managed-runtime.mjs",
@@ -213,6 +213,18 @@ function runCommand(command, args, options = {}) {
return result; return result;
} }
function withManagedRuntimeNodeOptions(env = process.env) {
const existing = env.NODE_OPTIONS?.trim() ?? "";
if (existing.includes("--max-old-space-size")) {
return env;
}
const nodeOptions = existing ? `${existing} --max-old-space-size=8192` : "--max-old-space-size=8192";
return {
...env,
NODE_OPTIONS: nodeOptions,
};
}
async function extractNodeDistribution(archivePath, nodeArtifact) { async function extractNodeDistribution(archivePath, nodeArtifact) {
const extractionRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-node-")); const extractionRoot = await fs.mkdtemp(path.join(os.tmpdir(), "paseo-managed-runtime-node-"));
if (nodeArtifact.extension === "zip") { if (nodeArtifact.extension === "zip") {
@@ -260,6 +272,7 @@ async function packWorkspace(packageRoot, tarballRoot) {
tarballRoot, tarballRoot,
], { ], {
cwd: packageRoot, cwd: packageRoot,
env: withManagedRuntimeNodeOptions(process.env),
}); });
const [{ filename }] = JSON.parse(result.stdout.trim()); const [{ filename }] = JSON.parse(result.stdout.trim());
if (!filename) { if (!filename) {
@@ -55,8 +55,28 @@ for (const pkg of ["@getpaseo/relay", "@getpaseo/server", "@getpaseo/cli"]) {
}); });
} }
const sherpaPlatformMap = {
darwin: "darwin",
linux: "linux",
win32: "win",
};
const sherpaPlatform = sherpaPlatformMap[manifest.platform];
assert.ok(
sherpaPlatform,
`Unsupported sherpa platform mapping for managed runtime validation: ${manifest.platform}`
);
const sherpaNativePackage = `sherpa-onnx-${sherpaPlatform}-${manifest.arch}`;
const sherpaNativePackageDir = path.join(runtimeRoot, "node_modules", sherpaNativePackage);
await fs.access(sherpaNativePackageDir).catch(() => {
throw new Error(
`Missing bundled native speech dependency: ${sherpaNativePackage} (expected at ${sherpaNativePackageDir})`
);
});
console.log(`[validate-managed-runtime] PASS`); console.log(`[validate-managed-runtime] PASS`);
console.log(` runtimeId: ${manifest.runtimeId}`); console.log(` runtimeId: ${manifest.runtimeId}`);
console.log(` version: ${expectedVersion}`); console.log(` version: ${expectedVersion}`);
console.log(` platform: ${manifest.platform}`); console.log(` platform: ${manifest.platform}`);
console.log(` arch: ${manifest.arch}`); console.log(` arch: ${manifest.arch}`);
console.log(` sherpaNativePackage: ${sherpaNativePackage}`);
@@ -333,8 +333,31 @@ fn bundled_runtime_root(app: &AppHandle) -> Result<PathBuf, String> {
return Ok(candidate); return Ok(candidate);
} }
} else { } else {
log::info!("[runtime] resource_dir() unavailable"); log::info!("[runtime] resource_dir() unavailable, resolving from executable path");
} }
// Fallback: resolve symlinks and search relative to the real executable location.
// Handles CLI symlink invocation where Tauri can't resolve resource_dir.
if let Ok(canonical) = std::env::current_exe().and_then(|p| p.canonicalize()) {
let exe_dir = dunce::simplified(&canonical);
if let Some(exe_dir) = exe_dir.parent() {
let mut candidates = vec![exe_dir.join("resources"), exe_dir.to_path_buf()];
if let Some(contents_dir) = exe_dir.parent() {
candidates.push(contents_dir.join("Resources"));
candidates.push(contents_dir.join("resources"));
}
for resource_dir in candidates {
if let Some(found) = bundled_runtime_root_from_resource_dir(&resource_dir) {
log::info!(
"[runtime] found bundled runtime (via exe fallback) at {}",
found.display()
);
return Ok(found);
}
}
}
}
log::error!("[runtime] no managed runtime found"); log::error!("[runtime] no managed runtime found");
Err("Managed runtime resources are not bundled with this desktop build.".to_string()) Err("Managed runtime resources are not bundled with this desktop build.".to_string())
} }
@@ -29,7 +29,7 @@ function createMockTransport() {
let onOpen: () => void = () => {} let onOpen: () => void = () => {}
let onClose: (_event?: unknown) => void = () => {} let onClose: (_event?: unknown) => void = () => {}
let onError: (_event?: unknown) => void = () => {} let onError: (_event?: unknown) => void = () => {}
let welcomeOrdinal = 1 let serverInfoOrdinal = 1
const transport: DaemonTransport = { const transport: DaemonTransport = {
send: (data) => sent.push(data), send: (data) => sent.push(data),
@@ -61,11 +61,16 @@ function createMockTransport() {
sent.length = 0 sent.length = 0
onMessage( onMessage(
JSON.stringify({ JSON.stringify({
type: 'welcome', type: 'session',
serverId: `srv_test_${welcomeOrdinal++}`, message: {
hostname: null, type: 'status',
version: null, payload: {
resumed: false, status: 'server_info',
serverId: `srv_test_${serverInfoOrdinal++}`,
hostname: null,
version: null,
},
},
}) })
) )
}, },
+24 -19
View File
@@ -4,10 +4,11 @@ import {
AgentCreatedStatusPayloadSchema, AgentCreatedStatusPayloadSchema,
AgentRefreshedStatusPayloadSchema, AgentRefreshedStatusPayloadSchema,
AgentResumedStatusPayloadSchema, AgentResumedStatusPayloadSchema,
parseServerInfoStatusPayload,
RestartRequestedStatusPayloadSchema, RestartRequestedStatusPayloadSchema,
ShutdownRequestedStatusPayloadSchema, ShutdownRequestedStatusPayloadSchema,
SessionInboundMessageSchema, SessionInboundMessageSchema,
type WSWelcomeMessage, type ServerInfoStatusPayload,
WSOutboundMessageSchema, WSOutboundMessageSchema,
} from '../shared/messages.js' } from '../shared/messages.js'
import type { import type {
@@ -450,7 +451,7 @@ export class DaemonClient {
private readonly logServerId: string | null private readonly logServerId: string | null
private readonly logClientIdHash: string private readonly logClientIdHash: string
private readonly logGeneration: number | null private readonly logGeneration: number | null
private lastWelcomeMessage: WSWelcomeMessage | null = null private lastServerInfoMessage: ServerInfoStatusPayload | null = null
constructor(private config: DaemonClientConfig) { constructor(private config: DaemonClientConfig) {
this.logger = config.logger ?? consoleLogger this.logger = config.logger ?? consoleLogger
@@ -565,7 +566,7 @@ export class DaemonClient {
const transportUrl = this.resolveTransportUrlForAttempt() const transportUrl = this.resolveTransportUrlForAttempt()
const transport = transportFactory({ url: transportUrl, headers }) const transport = transportFactory({ url: transportUrl, headers })
this.transport = transport this.transport = transport
this.lastWelcomeMessage = null this.lastServerInfoMessage = null
this.updateConnectionState({ this.updateConnectionState({
status: 'connecting', status: 'connecting',
@@ -700,7 +701,7 @@ export class DaemonClient {
this.clearWaiters(new Error('Daemon client closed')) this.clearWaiters(new Error('Daemon client closed'))
this.rejectPendingSendQueue(new Error('Daemon client closed')) this.rejectPendingSendQueue(new Error('Daemon client closed'))
this.terminalStreams.clearAll() this.terminalStreams.clearAll()
this.lastWelcomeMessage = null this.lastServerInfoMessage = null
this.updateConnectionState( this.updateConnectionState(
{ status: 'disposed' }, { status: 'disposed' },
{ event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' } { event: 'DISPOSE', reason: 'Client closed', reasonCode: 'disposed' }
@@ -2796,8 +2797,8 @@ export class DaemonClient {
return requestId ?? crypto.randomUUID() return requestId ?? crypto.randomUUID()
} }
getLastWelcomeMessage(): WSWelcomeMessage | null { getLastServerInfoMessage(): ServerInfoStatusPayload | null {
return this.lastWelcomeMessage return this.lastServerInfoMessage
} }
private resolveTransportUrlForAttempt(): string { private resolveTransportUrlForAttempt(): string {
@@ -2962,18 +2963,6 @@ export class DaemonClient {
return return
} }
if (parsed.data.type === 'welcome') {
this.lastWelcomeMessage = parsed.data
this.resetConnectTimeout()
this.reconnectAttempt = 0
this.updateConnectionState({ status: 'connected' }, { event: 'HELLO_WELCOME' })
this.resubscribeCheckoutDiffSubscriptions()
this.resubscribeTerminalDirectorySubscriptions()
this.flushPendingSendQueue()
this.resolveConnect()
return
}
this.handleSessionMessage(parsed.data.message) this.handleSessionMessage(parsed.data.message)
} }
@@ -3050,7 +3039,7 @@ export class DaemonClient {
this.clearWaiters(new Error(reason ?? 'Connection lost')) this.clearWaiters(new Error(reason ?? 'Connection lost'))
this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost')) this.rejectPendingSendQueue(new Error(reason ?? 'Connection lost'))
this.terminalStreams.clearAll() this.terminalStreams.clearAll()
this.lastWelcomeMessage = null this.lastServerInfoMessage = null
if (wasDisposed) { if (wasDisposed) {
this.rejectConnect(new Error(reason ?? 'Daemon client is disposed')) this.rejectConnect(new Error(reason ?? 'Daemon client is disposed'))
@@ -3084,6 +3073,22 @@ export class DaemonClient {
} }
private handleSessionMessage(msg: SessionOutboundMessage): void { private handleSessionMessage(msg: SessionOutboundMessage): void {
if (msg.type === 'status') {
const serverInfo = parseServerInfoStatusPayload(msg.payload)
if (serverInfo) {
this.lastServerInfoMessage = serverInfo
if (this.connectionState.status === 'connecting') {
this.resetConnectTimeout()
this.reconnectAttempt = 0
this.updateConnectionState({ status: 'connected' }, { event: 'HELLO_SERVER_INFO' })
this.resubscribeCheckoutDiffSubscriptions()
this.resubscribeTerminalDirectorySubscriptions()
this.flushPendingSendQueue()
this.resolveConnect()
}
}
}
if (msg.type === 'terminal_stream_exit') { if (msg.type === 'terminal_stream_exit') {
this.terminalStreams.clearStream({ streamId: msg.payload.streamId }) this.terminalStreams.clearStream({ streamId: msg.payload.streamId })
} }
@@ -335,17 +335,16 @@ describe("daemon client E2E", () => {
} }
}, 30000); }, 30000);
test("receives welcome on websocket connect", async () => { test("receives server_info on websocket connect", async () => {
const client = new DaemonClient({ const client = new DaemonClient({
url: `ws://127.0.0.1:${ctx.daemon.port}/ws`, url: `ws://127.0.0.1:${ctx.daemon.port}/ws`,
clientId: `cid-e2e-${randomUUID()}`, clientId: `cid-e2e-${randomUUID()}`,
clientType: "cli", clientType: "cli",
}); });
await client.connect(); await client.connect();
const welcome = client.getLastWelcomeMessage(); const serverInfo = client.getLastServerInfoMessage();
expect(welcome).not.toBeNull(); expect(serverInfo).not.toBeNull();
expect(welcome?.serverId.length).toBeGreaterThan(0); expect(serverInfo?.serverId.length).toBeGreaterThan(0);
expect(typeof welcome?.resumed).toBe("boolean");
await client.close(); await client.close();
}, 15000); }, 15000);
@@ -369,8 +368,8 @@ describe("daemon client E2E", () => {
try { try {
await client.connect(); await client.connect();
const welcome = client.getLastWelcomeMessage(); const serverInfo = client.getLastServerInfoMessage();
const voice = welcome?.capabilities?.voice; const voice = serverInfo?.capabilities?.voice;
expect(voice).toBeTruthy(); expect(voice).toBeTruthy();
expect(voice?.dictation.enabled).toBe(false); expect(voice?.dictation.enabled).toBe(false);
@@ -243,7 +243,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
try { try {
const payload = const payload =
typeof data === "string" ? JSON.parse(data) : data; typeof data === "string" ? JSON.parse(data) : data;
if (payload && typeof payload === "object" && (payload as any).type === "welcome") { if (
payload &&
typeof payload === "object" &&
(payload as any).type === "session" &&
(payload as any).message?.type === "status" &&
(payload as any).message?.payload?.status === "server_info"
) {
if (!pingSent && channelRef) { if (!pingSent && channelRef) {
pingSent = true; pingSent = true;
void channelRef.send(JSON.stringify({ type: "ping" })); void channelRef.send(JSON.stringify({ type: "ping" }));
@@ -365,7 +371,13 @@ async function waitForRelayWebSocketReady(port: number, timeout = 60000): Promis
const channel = await createClientChannel(transport, daemonPublicKeyB64, { const channel = await createClientChannel(transport, daemonPublicKeyB64, {
onmessage: (data) => { onmessage: (data) => {
const payload = typeof data === "string" ? JSON.parse(data) : data; const payload = typeof data === "string" ? JSON.parse(data) : data;
if (payload && typeof payload === "object" && (payload as any).type === "welcome") { if (
payload &&
typeof payload === "object" &&
(payload as any).type === "session" &&
(payload as any).message?.type === "status" &&
(payload as any).message?.payload?.status === "server_info"
) {
if (!pingSent && channelRef) { if (!pingSent && channelRef) {
pingSent = true; pingSent = true;
void channelRef.send(JSON.stringify({ type: "ping" })); void channelRef.send(JSON.stringify({ type: "ping" }));
@@ -459,7 +459,7 @@ const shouldRun = !process.env.CI;
const helloReady = new Promise<void>((resolve, reject) => { const helloReady = new Promise<void>((resolve, reject) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
cleanup(); cleanup();
reject(new Error("Timed out waiting for websocket welcome")); reject(new Error("Timed out waiting for initial websocket server_info"));
}, 10000); }, 10000);
const onError = (error: Error) => { const onError = (error: Error) => {
@@ -478,8 +478,15 @@ const shouldRun = !process.env.CI;
} }
const text = typeof raw === "string" ? raw : raw.toString("utf8"); const text = typeof raw === "string" ? raw : raw.toString("utf8");
try { try {
const parsed = JSON.parse(text) as { type?: string }; const parsed = JSON.parse(text) as {
if (parsed.type === "welcome") { type?: string;
message?: { type?: string; payload?: { status?: string } };
};
if (
parsed.type === "session" &&
parsed.message?.type === "status" &&
parsed.message.payload?.status === "server_info"
) {
cleanup(); cleanup();
resolve(); resolve();
} }
@@ -282,13 +282,15 @@ async function attachRelayAndHello(params: {
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId))); params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
await Promise.resolve(); await Promise.resolve();
expect(params.socket.sent.length).toBeGreaterThan(0); expect(params.socket.sent.length).toBeGreaterThan(0);
const welcome = JSON.parse(params.socket.sent[0] as string) as { const envelope = JSON.parse(params.socket.sent[0] as string) as {
type?: unknown; type?: unknown;
resumed?: unknown; message?: { type?: unknown; payload?: unknown };
serverId?: unknown;
}; };
expect(welcome.type).toBe("welcome"); expect(envelope.type).toBe("session");
return welcome; const serverInfo = parseServerInfoStatusPayload(envelope.message?.payload);
expect(envelope.message?.type).toBe("status");
expect(serverInfo).not.toBeNull();
return serverInfo!;
} }
async function attachDirectAndHello(params: { async function attachDirectAndHello(params: {
@@ -300,13 +302,15 @@ async function attachDirectAndHello(params: {
params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId))); params.socket.emit("message", JSON.stringify(createHelloMessage(params.clientId)));
await Promise.resolve(); await Promise.resolve();
expect(params.socket.sent.length).toBeGreaterThan(0); expect(params.socket.sent.length).toBeGreaterThan(0);
const welcome = JSON.parse(params.socket.sent[0] as string) as { const envelope = JSON.parse(params.socket.sent[0] as string) as {
type?: unknown; type?: unknown;
resumed?: unknown; message?: { type?: unknown; payload?: unknown };
serverId?: unknown;
}; };
expect(welcome.type).toBe("welcome"); expect(envelope.type).toBe("session");
return welcome; const serverInfo = parseServerInfoStatusPayload(envelope.message?.payload);
expect(envelope.message?.type).toBe("status");
expect(serverInfo).not.toBeNull();
return serverInfo!;
} }
describe("relay external socket reconnect behavior", () => { describe("relay external socket reconnect behavior", () => {
@@ -324,12 +328,11 @@ describe("relay external socket reconnect behavior", () => {
const clientId = "cid-relay-reconnect"; const clientId = "cid-relay-reconnect";
const socket1 = new MockSocket(); const socket1 = new MockSocket();
const firstWelcome = await attachRelayAndHello({ await attachRelayAndHello({
server, server,
socket: socket1, socket: socket1,
clientId, clientId,
}); });
expect(firstWelcome.resumed).toBe(false);
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!; const session = sessionMock.instances[0]!;
@@ -338,12 +341,11 @@ describe("relay external socket reconnect behavior", () => {
expect(session.cleanup).not.toHaveBeenCalled(); expect(session.cleanup).not.toHaveBeenCalled();
const socket2 = new MockSocket(); const socket2 = new MockSocket();
const secondWelcome = await attachRelayAndHello({ await attachRelayAndHello({
server, server,
socket: socket2, socket: socket2,
clientId, clientId,
}); });
expect(secondWelcome.resumed).toBe(true);
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(20_000); await vi.advanceTimersByTimeAsync(20_000);
@@ -373,50 +375,46 @@ describe("relay external socket reconnect behavior", () => {
await server.close(); await server.close();
}); });
test("marks hello as resumed when clientId already has a session", async () => { test("returns server_info when clientId reconnects with existing session", async () => {
const server = createServer(); const server = createServer();
const clientId = "cid-resume-flag"; const clientId = "cid-resume-flag";
const firstSocket = new MockSocket(); const firstSocket = new MockSocket();
const firstWelcome = await attachRelayAndHello({ await attachRelayAndHello({
server, server,
socket: firstSocket, socket: firstSocket,
clientId, clientId,
}); });
expect(firstWelcome.resumed).toBe(false);
firstSocket.emit("close", 1006, ""); firstSocket.emit("close", 1006, "");
await vi.advanceTimersByTimeAsync(1_000); await vi.advanceTimersByTimeAsync(1_000);
const secondSocket = new MockSocket(); const secondSocket = new MockSocket();
const secondWelcome = await attachRelayAndHello({ await attachRelayAndHello({
server, server,
socket: secondSocket, socket: secondSocket,
clientId, clientId,
}); });
expect(secondWelcome.resumed).toBe(true);
await server.close(); await server.close();
}); });
test("marks hello as not resumed for new clientIds", async () => { test("returns server_info for distinct clientIds", async () => {
const server = createServer(); const server = createServer();
const firstSocket = new MockSocket(); const firstSocket = new MockSocket();
const firstWelcome = await attachRelayAndHello({ await attachRelayAndHello({
server, server,
socket: firstSocket, socket: firstSocket,
clientId: "cid-new-1", clientId: "cid-new-1",
}); });
expect(firstWelcome.resumed).toBe(false);
const secondSocket = new MockSocket(); const secondSocket = new MockSocket();
const secondWelcome = await attachRelayAndHello({ await attachRelayAndHello({
server, server,
socket: secondSocket, socket: secondSocket,
clientId: "cid-new-2", clientId: "cid-new-2",
}); });
expect(secondWelcome.resumed).toBe(false);
expect(sessionMock.instances).toHaveLength(2); expect(sessionMock.instances).toHaveLength(2);
await server.close(); await server.close();
@@ -456,12 +454,11 @@ describe("relay external socket reconnect behavior", () => {
const clientId = "cid-direct-reconnect"; const clientId = "cid-direct-reconnect";
const socket1 = new MockSocket(); const socket1 = new MockSocket();
const firstWelcome = await attachDirectAndHello({ await attachDirectAndHello({
server, server,
socket: socket1, socket: socket1,
clientId, clientId,
}); });
expect(firstWelcome.resumed).toBe(false);
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
const session = sessionMock.instances[0]!; const session = sessionMock.instances[0]!;
@@ -470,12 +467,11 @@ describe("relay external socket reconnect behavior", () => {
expect(session.cleanup).not.toHaveBeenCalled(); expect(session.cleanup).not.toHaveBeenCalled();
const socket2 = new MockSocket(); const socket2 = new MockSocket();
const secondWelcome = await attachDirectAndHello({ await attachDirectAndHello({
server, server,
socket: socket2, socket: socket2,
clientId, clientId,
}); });
expect(secondWelcome.resumed).toBe(true);
expect(sessionMock.instances).toHaveLength(1); expect(sessionMock.instances).toHaveLength(1);
await vi.advanceTimersByTimeAsync(20_000); await vi.advanceTimersByTimeAsync(20_000);
@@ -548,12 +544,12 @@ describe("relay external socket reconnect behavior", () => {
await server.close(); await server.close();
}); });
test("includes voice capabilities in welcome when speech readiness exists", async () => { test("includes voice capabilities in initial server_info when speech readiness exists", async () => {
const speechReadiness = createReadySpeechReadinessSnapshot(); const speechReadiness = createReadySpeechReadinessSnapshot();
const server = createServer({ speechReadiness }); const server = createServer({ speechReadiness });
const socket = new MockSocket(); const socket = new MockSocket();
const welcome = await attachRelayAndHello({ const serverInfo = await attachRelayAndHello({
server, server,
socket, socket,
clientId: "cid-server-info-capabilities", clientId: "cid-server-info-capabilities",
@@ -566,15 +562,15 @@ describe("relay external socket reconnect behavior", () => {
}; };
}; };
}; };
expect(welcome.version).toBe(TEST_DAEMON_VERSION); expect(serverInfo.version).toBe(TEST_DAEMON_VERSION);
expect(welcome.capabilities?.voice?.dictation?.enabled).toBe( expect(serverInfo.capabilities?.voice?.dictation?.enabled).toBe(
speechReadiness.dictation.enabled speechReadiness.dictation.enabled
); );
expect(welcome.capabilities?.voice?.dictation?.reason).toBe(""); expect(serverInfo.capabilities?.voice?.dictation?.reason).toBe("");
expect(welcome.capabilities?.voice?.voice?.enabled).toBe( expect(serverInfo.capabilities?.voice?.voice?.enabled).toBe(
speechReadiness.realtimeVoice.enabled speechReadiness.realtimeVoice.enabled
); );
expect(welcome.capabilities?.voice?.voice?.reason).toBe(""); expect(serverInfo.capabilities?.voice?.voice?.reason).toBe("");
await server.close(); await server.close();
}); });
+12 -19
View File
@@ -12,7 +12,6 @@ import type { ProjectRegistry, WorkspaceRegistry } from "./workspace-registry.js
import { import {
type ServerInfoStatusPayload, type ServerInfoStatusPayload,
type WSHelloMessage, type WSHelloMessage,
type WSWelcomeMessage,
WSInboundMessageSchema, WSInboundMessageSchema,
type ServerCapabilityState, type ServerCapabilityState,
type ServerCapabilities, type ServerCapabilities,
@@ -678,17 +677,6 @@ export class VoiceAssistantWebSocketServer {
return pending; return pending;
} }
private buildWelcomeMessage(params: { resumed: boolean }): WSWelcomeMessage {
return {
type: "welcome",
serverId: this.serverId,
hostname: getHostname(),
version: this.daemonVersion,
resumed: params.resumed,
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
};
}
private handleHello(params: { private handleHello(params: {
ws: WebSocketLike; ws: WebSocketLike;
message: WSHelloMessage; message: WSHelloMessage;
@@ -735,7 +723,7 @@ export class VoiceAssistantWebSocketServer {
} }
existing.sockets.add(ws); existing.sockets.add(ws);
this.sessions.set(ws, existing); this.sessions.set(ws, existing);
this.sendToClient(ws, this.buildWelcomeMessage({ resumed: true })); this.sendToClient(ws, this.createServerInfoMessage());
existing.connectionLogger.trace( existing.connectionLogger.trace(
{ {
clientId, clientId,
@@ -756,7 +744,7 @@ export class VoiceAssistantWebSocketServer {
}); });
this.sessions.set(ws, connection); this.sessions.set(ws, connection);
this.externalSessionsByKey.set(clientId, connection); this.externalSessionsByKey.set(clientId, connection);
this.sendToClient(ws, this.buildWelcomeMessage({ resumed: false })); this.sendToClient(ws, this.createServerInfoMessage());
connection.connectionLogger.trace( connection.connectionLogger.trace(
{ {
clientId, clientId,
@@ -777,13 +765,18 @@ export class VoiceAssistantWebSocketServer {
}; };
} }
private broadcastCapabilitiesUpdate(): void { private createServerInfoMessage(): WSOutboundMessage {
this.broadcast( return {
wrapSessionMessage({ type: "session",
message: {
type: "status", type: "status",
payload: this.buildServerInfoStatusPayload(), payload: this.buildServerInfoStatusPayload(),
}) },
); };
}
private broadcastCapabilitiesUpdate(): void {
this.broadcast(this.createServerInfoMessage());
} }
private bindSocketHandlers(ws: WebSocketLike): void { private bindSocketHandlers(ws: WebSocketLike): void {
-11
View File
@@ -2388,15 +2388,6 @@ export const WSHelloMessageSchema = z.object({
.optional(), .optional(),
}) })
export const WSWelcomeMessageSchema = z.object({
type: z.literal('welcome'),
serverId: z.string(),
hostname: z.string().nullable(),
version: z.string().nullable(),
resumed: z.boolean(),
capabilities: ServerCapabilitiesSchema.optional(),
})
export const WSRecordingStateMessageSchema = z.object({ export const WSRecordingStateMessageSchema = z.object({
type: z.literal('recording_state'), type: z.literal('recording_state'),
isRecording: z.boolean(), isRecording: z.boolean(),
@@ -2423,14 +2414,12 @@ export const WSInboundMessageSchema = z.discriminatedUnion('type', [
export const WSOutboundMessageSchema = z.discriminatedUnion('type', [ export const WSOutboundMessageSchema = z.discriminatedUnion('type', [
WSPongMessageSchema, WSPongMessageSchema,
WSWelcomeMessageSchema,
WSSessionOutboundSchema, WSSessionOutboundSchema,
]) ])
export type WSInboundMessage = z.infer<typeof WSInboundMessageSchema> export type WSInboundMessage = z.infer<typeof WSInboundMessageSchema>
export type WSOutboundMessage = z.infer<typeof WSOutboundMessageSchema> export type WSOutboundMessage = z.infer<typeof WSOutboundMessageSchema>
export type WSHelloMessage = z.infer<typeof WSHelloMessageSchema> export type WSHelloMessage = z.infer<typeof WSHelloMessageSchema>
export type WSWelcomeMessage = z.infer<typeof WSWelcomeMessageSchema>
// ============================================================================ // ============================================================================
// Helper functions for message conversion // Helper functions for message conversion