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