fix(dictation): avoid timeout from dangling non-final segments

This commit is contained in:
Mohamed Boudra
2026-02-18 21:21:27 +07:00
parent bcae95c372
commit a1fdeeb7ae
16 changed files with 419 additions and 10 deletions
@@ -996,6 +996,7 @@ export function SessionProvider({
updateSessionServerInfo(serverId, { updateSessionServerInfo(serverId, {
serverId: serverInfo.serverId, serverId: serverInfo.serverId,
hostname: serverInfo.hostname, hostname: serverInfo.hostname,
version: serverInfo.version,
...(serverInfo.capabilities ...(serverInfo.capabilities
? { capabilities: serverInfo.capabilities } ? { capabilities: serverInfo.capabilities }
: {}), : {}),
+73 -2
View File
@@ -185,6 +185,17 @@ const styles = StyleSheet.create((theme) => ({
color: theme.colors.foregroundMuted, color: theme.colors.foregroundMuted,
flexShrink: 1, flexShrink: 1,
}, },
versionPill: {
flexDirection: "row",
alignItems: "center",
paddingHorizontal: theme.spacing[2],
paddingVertical: 4,
borderRadius: theme.borderRadius.full,
borderWidth: 1,
borderColor: theme.colors.border,
backgroundColor: theme.colors.surface3,
maxWidth: 200,
},
hostSettingsButton: { hostSettingsButton: {
width: 28, width: 28,
height: 28, height: 28,
@@ -407,6 +418,41 @@ const styles = StyleSheet.create((theme) => ({
}, },
})); }));
function resolveAppVersion(): string | null {
const expoVersion = Constants.expoConfig?.version;
if (typeof expoVersion === "string" && expoVersion.trim().length > 0) {
return expoVersion.trim();
}
const manifestVersion = (Constants as any).manifest?.version;
if (typeof manifestVersion === "string" && manifestVersion.trim().length > 0) {
return manifestVersion.trim();
}
return null;
}
function formatVersionBadgeText(params: { daemonVersion: string | null; appVersion: string | null }): string {
const appVersion = params.appVersion?.trim();
if (!appVersion) {
if (Platform.OS === "web") {
return "App version unavailable";
}
return "App unavailable";
}
const daemonVersion = params.daemonVersion?.trim();
if (!daemonVersion) {
if (Platform.OS === "web") {
return `Daemon unavailable | App ${appVersion}`;
}
return `D unavailable | A ${appVersion}`;
}
if (Platform.OS === "web") {
return `Daemon ${daemonVersion} | App ${appVersion}`;
}
return `D ${daemonVersion} | A ${appVersion}`;
}
export default function SettingsScreen() { export default function SettingsScreen() {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
@@ -442,7 +488,7 @@ export default function SettingsScreen() {
const showDesktopPermissionSection = shouldShowDesktopPermissionSection(); const showDesktopPermissionSection = shouldShowDesktopPermissionSection();
const isMountedRef = useRef(true); const isMountedRef = useRef(true);
const lastHandledEditHostRef = useRef<string | null>(null); const lastHandledEditHostRef = useRef<string | null>(null);
const appVersion = Constants.expoConfig?.version ?? (Constants as any).manifest?.version ?? "0.1.0"; const appVersion = resolveAppVersion();
const editingServerId = editingDaemon?.serverId ?? null; const editingServerId = editingDaemon?.serverId ?? null;
const editingDaemonLive = editingServerId const editingDaemonLive = editingServerId
? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null ? daemons.find((daemon) => daemon.serverId === editingServerId) ?? null
@@ -695,6 +741,7 @@ export default function SettingsScreen() {
<DaemonCard <DaemonCard
key={daemon.serverId} key={daemon.serverId}
daemon={daemon} daemon={daemon}
appVersion={appVersion}
connectionStatus={connectionStatus} connectionStatus={connectionStatus}
activeConnection={activeConnection} activeConnection={activeConnection}
lastError={lastConnectionError} lastError={lastConnectionError}
@@ -829,6 +876,7 @@ export default function SettingsScreen() {
<HostDetailModal <HostDetailModal
visible={Boolean(editingDaemonLive)} visible={Boolean(editingDaemonLive)}
host={editingDaemonLive} host={editingDaemonLive}
appVersion={appVersion}
connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"} connectionStatus={editingServerId ? (connectionStates.get(editingServerId)?.status ?? "idle") : "idle"}
activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null} activeConnection={editingServerId ? (connectionStates.get(editingServerId)?.activeConnection ?? null) : null}
lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null} lastError={editingServerId ? (connectionStates.get(editingServerId)?.lastError ?? null) : null}
@@ -934,7 +982,7 @@ export default function SettingsScreen() {
<View style={styles.audioRowContent}> <View style={styles.audioRowContent}>
<Text style={styles.audioRowTitle}>Version</Text> <Text style={styles.audioRowTitle}>Version</Text>
</View> </View>
<Text style={styles.aboutValue}>{appVersion}</Text> <Text style={styles.aboutValue}>{appVersion ?? "Unavailable"}</Text>
</View> </View>
</View> </View>
</View> </View>
@@ -947,6 +995,7 @@ export default function SettingsScreen() {
interface HostDetailModalProps { interface HostDetailModalProps {
visible: boolean; visible: boolean;
host: HostProfile | null; host: HostProfile | null;
appVersion: string | null;
connectionStatus: ConnectionStatus; connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null; activeConnection: ActiveConnection | null;
lastError: string | null; lastError: string | null;
@@ -964,6 +1013,7 @@ interface HostDetailModalProps {
function HostDetailModal({ function HostDetailModal({
visible, visible,
host, host,
appVersion,
connectionStatus, connectionStatus,
activeConnection, activeConnection,
lastError, lastError,
@@ -1002,6 +1052,7 @@ function HostDetailModal({
// Restart logic (moved from DaemonCard) // Restart logic (moved from DaemonCard)
const daemonClient = useSessionStore((state) => host ? (state.sessions[host.serverId]?.client ?? null) : null); const daemonClient = useSessionStore((state) => host ? (state.sessions[host.serverId]?.client ?? null) : null);
const daemonConnection = useSessionStore((state) => host ? (state.sessions[host.serverId]?.connection ?? null) : null); const daemonConnection = useSessionStore((state) => host ? (state.sessions[host.serverId]?.connection ?? null) : null);
const daemonVersion = useSessionStore((state) => host ? (state.sessions[host.serverId]?.serverInfo?.version ?? null) : null);
const isConnected = daemonConnection?.isConnected ?? false; const isConnected = daemonConnection?.isConnected ?? false;
const isConnectedRef = useRef(isConnected); const isConnectedRef = useRef(isConnected);
const [isRestarting, setIsRestarting] = useState(false); const [isRestarting, setIsRestarting] = useState(false);
@@ -1113,6 +1164,7 @@ function HostDetailModal({
text: activeConnection.display, text: activeConnection.display,
}; };
})(); })();
const versionBadgeText = formatVersionBadgeText({ daemonVersion, appVersion });
const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null; const connectionError = typeof lastError === "string" && lastError.trim().length > 0 ? lastError.trim() : null;
const handleDraftLabelChange = useCallback((nextValue: string) => { const handleDraftLabelChange = useCallback((nextValue: string) => {
@@ -1154,6 +1206,11 @@ function HostDetailModal({
</Text> </Text>
</View> </View>
) : null} ) : null}
<View style={styles.versionPill}>
<Text style={styles.connectionText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
</View> </View>
{connectionError ? ( {connectionError ? (
<Text style={{ color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs }}> <Text style={{ color: theme.colors.palette.red[300], fontSize: theme.fontSize.xs }}>
@@ -1429,6 +1486,7 @@ function ConnectionRow({
interface DaemonCardProps { interface DaemonCardProps {
daemon: HostProfile; daemon: HostProfile;
appVersion: string | null;
connectionStatus: ConnectionStatus; connectionStatus: ConnectionStatus;
activeConnection: ActiveConnection | null; activeConnection: ActiveConnection | null;
lastError: string | null; lastError: string | null;
@@ -1437,12 +1495,19 @@ interface DaemonCardProps {
function DaemonCard({ function DaemonCard({
daemon, daemon,
appVersion,
connectionStatus, connectionStatus,
activeConnection, activeConnection,
lastError, lastError,
onOpenSettings, onOpenSettings,
}: DaemonCardProps) { }: DaemonCardProps) {
const { theme } = useUnistyles(); const { theme } = useUnistyles();
const daemonVersion = useSessionStore(
useCallback(
(state) => state.sessions[daemon.serverId]?.serverInfo?.version ?? null,
[daemon.serverId]
)
);
const statusLabel = formatConnectionStatus(connectionStatus); const statusLabel = formatConnectionStatus(connectionStatus);
const statusTone = getConnectionStatusTone(connectionStatus); const statusTone = getConnectionStatusTone(connectionStatus);
const statusColor = const statusColor =
@@ -1473,6 +1538,7 @@ function DaemonCard({
text: activeConnection.display, text: activeConnection.display,
}; };
})(); })();
const versionBadgeText = formatVersionBadgeText({ daemonVersion, appVersion });
return ( return (
<View <View
@@ -1500,6 +1566,11 @@ function DaemonCard({
) : null} ) : null}
</View> </View>
) : null} ) : null}
<View style={styles.versionPill}>
<Text style={styles.connectionText} numberOfLines={1}>
{versionBadgeText}
</Text>
</View>
<Pressable <Pressable
style={({ pressed, hovered }) => [ style={({ pressed, hovered }) => [
+6
View File
@@ -156,6 +156,7 @@ export interface DaemonConnectionSnapshot {
export type DaemonServerInfo = { export type DaemonServerInfo = {
serverId: string; serverId: string;
hostname: string | null; hostname: string | null;
version: string | null;
capabilities?: ServerCapabilities; capabilities?: ServerCapabilities;
}; };
@@ -455,12 +456,15 @@ export const useSessionStore = create<SessionStore>()(
const nextHostname = info.hostname?.trim() || null; const nextHostname = info.hostname?.trim() || null;
const prevHostname = session.serverInfo?.hostname?.trim() || null; const prevHostname = session.serverInfo?.hostname?.trim() || null;
const nextVersion = info.version?.trim() || null;
const prevVersion = session.serverInfo?.version?.trim() || null;
const nextCapabilities = info.capabilities; const nextCapabilities = info.capabilities;
const prevCapabilities = session.serverInfo?.capabilities; const prevCapabilities = session.serverInfo?.capabilities;
if ( if (
session.serverInfo?.serverId === info.serverId && session.serverInfo?.serverId === info.serverId &&
prevHostname === nextHostname && prevHostname === nextHostname &&
prevVersion === nextVersion &&
areServerCapabilitiesEqual(prevCapabilities, nextCapabilities) areServerCapabilitiesEqual(prevCapabilities, nextCapabilities)
) { ) {
return prev; return prev;
@@ -469,6 +473,7 @@ export const useSessionStore = create<SessionStore>()(
logSessionStoreUpdate("updateSessionServerInfo", serverId, { logSessionStoreUpdate("updateSessionServerInfo", serverId, {
serverId: info.serverId, serverId: info.serverId,
hostname: nextHostname, hostname: nextHostname,
version: nextVersion,
}); });
return { return {
@@ -480,6 +485,7 @@ export const useSessionStore = create<SessionStore>()(
serverInfo: { serverInfo: {
serverId: info.serverId, serverId: info.serverId,
hostname: nextHostname, hostname: nextHostname,
version: nextVersion,
...(nextCapabilities ? { capabilities: nextCapabilities } : {}), ...(nextCapabilities ? { capabilities: nextCapabilities } : {}),
}, },
}, },
@@ -11,6 +11,7 @@ function buildServerInfo(capabilities?: ServerCapabilities): DaemonServerInfo {
return { return {
serverId: "srv-1", serverId: "srv-1",
hostname: "test-host", hostname: "test-host",
version: "0.1.0",
...(capabilities ? { capabilities } : {}), ...(capabilities ? { capabilities } : {}),
}; };
} }
+14 -2
View File
@@ -22,8 +22,20 @@ import { withOutput } from './output/index.js'
import { onboardCommand } from './commands/onboard.js' import { onboardCommand } from './commands/onboard.js'
const require = createRequire(import.meta.url) const require = createRequire(import.meta.url)
const packageJson = require('../package.json') as { version?: string }
const VERSION = typeof packageJson.version === 'string' ? packageJson.version : '0.0.0' type CliPackageJson = {
version?: unknown
}
function resolveCliVersion(): string {
const packageJson = require('../package.json') as CliPackageJson
if (typeof packageJson.version === 'string' && packageJson.version.trim().length > 0) {
return packageJson.version.trim()
}
throw new Error('Unable to resolve @getpaseo/cli version from package.json.')
}
const VERSION = resolveCliVersion()
// Helper function to collect multiple option values into an array // Helper function to collect multiple option values into an array
function collectMultiple(value: string, previous: string[]): string[] { function collectMultiple(value: string, previous: string[]): string[] {
+4 -1
View File
@@ -58,6 +58,7 @@ import {
import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js"; import { loadOrCreateDaemonKeyPair } from "./daemon-keypair.js";
import { startRelayTransport, type RelayTransportController } from "./relay-transport.js"; import { startRelayTransport, type RelayTransportController } from "./relay-transport.js";
import { getOrCreateServerId } from "./server-id.js"; import { getOrCreateServerId } from "./server-id.js";
import { resolveDaemonVersion } from "./daemon-version.js";
import type { import type {
AgentClient, AgentClient,
AgentProvider, AgentProvider,
@@ -136,6 +137,7 @@ export async function createPaseoDaemon(
rootLogger: Logger rootLogger: Logger
): Promise<PaseoDaemon> { ): Promise<PaseoDaemon> {
const logger = rootLogger.child({ module: "bootstrap" }); const logger = rootLogger.child({ module: "bootstrap" });
const daemonVersion = resolveDaemonVersion(import.meta.url);
// Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately. // Acquire PID lock before expensive bootstrap work so duplicate starts fail immediately.
await acquirePidLock(config.paseoHome, config.listen); await acquirePidLock(config.paseoHome, config.listen);
@@ -496,7 +498,8 @@ export async function createPaseoDaemon(
localModels: localModelConfig ?? undefined, localModels: localModelConfig ?? undefined,
getSpeechReadiness, getSpeechReadiness,
}, },
config.agentProviderSettings config.agentProviderSettings,
daemonVersion
); );
unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => { unsubscribeSpeechReadiness = subscribeSpeechReadiness((snapshot) => {
wsServer?.publishSpeechReadiness(snapshot); wsServer?.publishSpeechReadiness(snapshot);
@@ -0,0 +1,71 @@
import { afterEach, describe, expect, it } from "vitest";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import {
DaemonVersionResolutionError,
resolveDaemonVersion,
} from "./daemon-version.js";
const createdDirs: string[] = [];
function createTempDir(): string {
const dir = mkdtempSync(path.join(tmpdir(), "paseo-daemon-version-"));
createdDirs.push(dir);
return dir;
}
afterEach(() => {
for (const dir of createdDirs.splice(0, createdDirs.length)) {
rmSync(dir, { recursive: true, force: true });
}
});
describe("resolveDaemonVersion", () => {
it("resolves server version by walking up to @getpaseo/server package.json", () => {
const root = createTempDir();
writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "@getpaseo/server", version: "9.8.7" }),
"utf8"
);
const nestedDir = path.join(root, "dist", "server");
mkdirSync(nestedDir, { recursive: true });
const moduleUrl = pathToFileURL(path.join(nestedDir, "index.js")).href;
expect(resolveDaemonVersion(moduleUrl)).toBe("9.8.7");
});
it("throws when @getpaseo/server package metadata cannot be resolved", () => {
const root = createTempDir();
writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "not-getpaseo-server", version: "1.2.3" }),
"utf8"
);
const nestedDir = path.join(root, "dist", "server");
mkdirSync(nestedDir, { recursive: true });
const moduleUrl = pathToFileURL(path.join(nestedDir, "index.js")).href;
expect(() => resolveDaemonVersion(moduleUrl)).toThrow(
DaemonVersionResolutionError
);
});
it("throws when @getpaseo/server version is missing", () => {
const root = createTempDir();
writeFileSync(
path.join(root, "package.json"),
JSON.stringify({ name: "@getpaseo/server" }),
"utf8"
);
const nestedDir = path.join(root, "dist", "server");
mkdirSync(nestedDir, { recursive: true });
const moduleUrl = pathToFileURL(path.join(nestedDir, "index.js")).href;
expect(() => resolveDaemonVersion(moduleUrl)).toThrow(
DaemonVersionResolutionError
);
});
});
@@ -0,0 +1,25 @@
import {
PackageVersionResolutionError,
resolvePackageVersion,
} from "./package-version.js";
const SERVER_PACKAGE_NAME = "@getpaseo/server";
export class DaemonVersionResolutionError extends PackageVersionResolutionError {}
export function resolveDaemonVersion(moduleUrl: string = import.meta.url): string {
try {
return resolvePackageVersion({
moduleUrl,
packageName: SERVER_PACKAGE_NAME,
});
} catch (error) {
if (error instanceof PackageVersionResolutionError) {
throw new DaemonVersionResolutionError({
moduleUrl,
packageName: SERVER_PACKAGE_NAME,
});
}
throw error;
}
}
@@ -233,4 +233,82 @@ describe("DictationStreamManager (provider-agnostic provider)", () => {
expect(finishAccepted).toBeDefined(); expect(finishAccepted).toBeDefined();
expect(finishAccepted?.payload.timeoutMs).toBeGreaterThan(5000); expect(finishAccepted?.payload.timeoutMs).toBeGreaterThan(5000);
}); });
it("adapts finish timeout when only uncommitted non-final transcripts are pending", async () => {
const session = new FakeRealtimeSession();
const emitted: Array<{ type: string; payload: any }> = [];
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: (msg) => emitted.push(msg),
sessionId: "s1",
stt: new FakeSttProvider(session),
finalTimeoutMs: 5000,
});
await manager.handleStart("d-uncommitted-timeout", "audio/pcm;rate=24000;bits=16");
await manager.handleChunk({
dictationId: "d-uncommitted-timeout",
seq: 0,
audioBase64: buildPcmBase64(2000, 2400),
format: "audio/pcm;rate=24000;bits=16",
});
session.emitCommitted("seg-1");
session.emitTranscript("seg-1", "hello", true);
session.emitTranscript("seg-dangling", "hel", false);
await manager.handleFinish("d-uncommitted-timeout", 0);
const finishAccepted = emitted.find(
(msg) => msg.type === "dictation_stream_finish_accepted"
);
expect(finishAccepted).toBeDefined();
expect(finishAccepted?.payload.timeoutMs).toBeGreaterThan(5000);
});
it("drops dangling uncommitted non-final transcripts when finishing after silence tail clear", async () => {
vi.useFakeTimers();
try {
const session = new FakeRealtimeSession();
const emitted: Array<{ type: string; payload: any }> = [];
const manager = new DictationStreamManager({
logger: pino({ level: "silent" }),
emit: (msg) => emitted.push(msg),
sessionId: "s1",
stt: new FakeSttProvider(session),
finalTimeoutMs: 5000,
});
await manager.handleStart("d-clear-tail", "audio/pcm;rate=24000;bits=16");
await manager.handleChunk({
dictationId: "d-clear-tail",
seq: 0,
audioBase64: buildPcmBase64(2000, 2400),
format: "audio/pcm;rate=24000;bits=16",
});
session.emitCommitted("seg-1");
session.emitTranscript("seg-1", "hello", true);
await manager.handleChunk({
dictationId: "d-clear-tail",
seq: 1,
audioBase64: buildPcmBase64(0, 2400),
format: "audio/pcm;rate=24000;bits=16",
});
session.emitTranscript("seg-dangling", "", false);
await manager.handleFinish("d-clear-tail", 1);
await tick();
await vi.advanceTimersByTimeAsync(5_100);
const final = emitted.find((msg) => msg.type === "dictation_stream_final");
const error = emitted.find((msg) => msg.type === "dictation_stream_error");
expect(session.clearCalls).toBeGreaterThan(0);
expect(error).toBeUndefined();
expect(final?.payload.text).toBe("hello");
} finally {
vi.useRealTimers();
}
});
}); });
@@ -552,8 +552,19 @@ export class DictationStreamManager {
const pendingCommittedSegments = state.committedSegmentIds.reduce((count, segmentId) => { const pendingCommittedSegments = state.committedSegmentIds.reduce((count, segmentId) => {
return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1; return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1;
}, 0); }, 0);
const committedSet = new Set(state.committedSegmentIds);
const pendingUncommittedTranscriptSegments = Array.from(
state.transcriptsBySegmentId.keys()
).reduce((count, segmentId) => {
if (committedSet.has(segmentId)) {
return count;
}
return state.finalTranscriptSegmentIds.has(segmentId) ? count : count + 1;
}, 0);
const pendingSegments = const pendingSegments =
pendingCommittedSegments + (state.awaitingFinalCommit ? 1 : 0); pendingCommittedSegments +
pendingUncommittedTranscriptSegments +
(state.awaitingFinalCommit ? 1 : 0);
const pendingAudioSeconds = Math.ceil( const pendingAudioSeconds = Math.ceil(
Math.max(0, state.bytesSinceCommit) / bytesPerSecond Math.max(0, state.bytesSinceCommit) / bytesPerSecond
); );
@@ -629,6 +640,16 @@ export class DictationStreamManager {
state.bytesSinceCommit = 0; state.bytesSinceCommit = 0;
state.peakSinceCommit = 0; state.peakSinceCommit = 0;
state.awaitingFinalCommit = false; state.awaitingFinalCommit = false;
const droppedSegments = this.dropUncommittedNonFinalTranscripts(state);
if (droppedSegments > 0) {
this.logger.debug(
{
dictationId,
droppedSegments,
},
"Dictation finish: dropped uncommitted non-final transcript segments after silence clear"
);
}
} else { } else {
state.awaitingFinalCommit = true; state.awaitingFinalCommit = true;
try { try {
@@ -646,6 +667,22 @@ export class DictationStreamManager {
state.finishSealed = true; state.finishSealed = true;
} }
private dropUncommittedNonFinalTranscripts(state: DictationStreamState): number {
const committedSet = new Set(state.committedSegmentIds);
let droppedCount = 0;
for (const segmentId of state.transcriptsBySegmentId.keys()) {
if (committedSet.has(segmentId)) {
continue;
}
if (state.finalTranscriptSegmentIds.has(segmentId)) {
continue;
}
state.transcriptsBySegmentId.delete(segmentId);
droppedCount += 1;
}
return droppedCount;
}
private maybeFinalizeDictationStream(dictationId: string): void { private maybeFinalizeDictationStream(dictationId: string): void {
const state = this.streams.get(dictationId); const state = this.streams.get(dictationId);
if (!state) { if (!state) {
@@ -0,0 +1,64 @@
import { existsSync, readFileSync } from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
type ResolvePackageVersionParams = {
moduleUrl?: string;
packageName: string;
};
type PackageJson = {
name?: unknown;
version?: unknown;
};
export class PackageVersionResolutionError extends Error {
constructor(params: { moduleUrl: string; packageName: string }) {
super(
`Unable to resolve ${params.packageName} version from module URL ${params.moduleUrl}.`
);
this.name = "PackageVersionResolutionError";
}
}
export function resolvePackageVersion(params: ResolvePackageVersionParams): string {
const moduleUrl = params.moduleUrl ?? import.meta.url;
let currentDir = path.dirname(fileURLToPath(moduleUrl));
while (true) {
const packageJsonPath = path.join(currentDir, "package.json");
if (existsSync(packageJsonPath)) {
try {
const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")) as PackageJson;
if (packageJson.name === params.packageName) {
if (
typeof packageJson.version === "string" &&
packageJson.version.trim().length > 0
) {
return packageJson.version.trim();
}
throw new PackageVersionResolutionError({
moduleUrl,
packageName: params.packageName,
});
}
} catch (error) {
if (error instanceof PackageVersionResolutionError) {
throw error;
}
// Continue searching parent directories.
}
}
const parentDir = path.dirname(currentDir);
if (parentDir === currentDir) {
break;
}
currentDir = parentDir;
}
throw new PackageVersionResolutionError({
moduleUrl,
packageName: params.packageName,
});
}
@@ -75,7 +75,13 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
{} as any, {} as any,
"/tmp/paseo-test", "/tmp/paseo-test",
async () => ({} as any), async () => ({} as any),
{ allowedOrigins: new Set() } { allowedOrigins: new Set() },
undefined,
undefined,
undefined,
undefined,
undefined,
"1.2.3-test"
); );
return { server, agentManager }; return { server, agentManager };
@@ -79,6 +79,8 @@ import {
import { parseServerInfoStatusPayload } from "./messages.js"; import { parseServerInfoStatusPayload } from "./messages.js";
import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js"; import type { SpeechReadinessSnapshot } from "./speech/speech-runtime.js";
const TEST_DAEMON_VERSION = "1.2.3-test";
class MockSocket { class MockSocket {
readyState = 1; readyState = 1;
sent: unknown[] = []; sent: unknown[] = [];
@@ -157,7 +159,9 @@ function createServer(options?: { speechReadiness?: SpeechReadinessSnapshot | nu
? { ? {
getSpeechReadiness: () => speechReadiness, getSpeechReadiness: () => speechReadiness,
} }
: undefined : undefined,
undefined,
TEST_DAEMON_VERSION
); );
} }
@@ -318,6 +322,7 @@ describe("relay external socket reconnect behavior", () => {
const payload = parseServerInfoStatusPayload(envelope.message?.payload); const payload = parseServerInfoStatusPayload(envelope.message?.payload);
expect(payload?.status).toBe("server_info"); expect(payload?.status).toBe("server_info");
expect(payload?.version).toBe(TEST_DAEMON_VERSION);
expect(payload?.capabilities?.voice?.dictation.enabled).toBe( expect(payload?.capabilities?.voice?.dictation.enabled).toBe(
speechReadiness.dictation.enabled speechReadiness.dictation.enabled
); );
+15 -1
View File
@@ -158,6 +158,13 @@ type SessionConnection = {
const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000; const EXTERNAL_SESSION_DISCONNECT_GRACE_MS = 90_000;
export class MissingDaemonVersionError extends Error {
constructor() {
super("VoiceAssistantWebSocketServer requires a non-empty daemonVersion.");
this.name = "MissingDaemonVersionError";
}
}
/** /**
* WebSocket server that only accepts sockets + parses/forwards messages to the session layer. * WebSocket server that only accepts sockets + parses/forwards messages to the session layer.
*/ */
@@ -168,6 +175,7 @@ export class VoiceAssistantWebSocketServer {
private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map(); private readonly externalSessionsByKey: Map<string, SessionConnection> = new Map();
private clientIdCounter = 0; private clientIdCounter = 0;
private readonly serverId: string; private readonly serverId: string;
private readonly daemonVersion: string;
private readonly agentManager: AgentManager; private readonly agentManager: AgentManager;
private readonly agentStorage: AgentStorage; private readonly agentStorage: AgentStorage;
private readonly downloadTokenStore: DownloadTokenStore; private readonly downloadTokenStore: DownloadTokenStore;
@@ -229,10 +237,15 @@ export class VoiceAssistantWebSocketServer {
}; };
getSpeechReadiness?: () => SpeechReadinessSnapshot; getSpeechReadiness?: () => SpeechReadinessSnapshot;
}, },
agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap agentProviderRuntimeSettings?: AgentProviderRuntimeSettingsMap,
daemonVersion?: string
) { ) {
this.logger = logger.child({ module: "websocket-server" }); this.logger = logger.child({ module: "websocket-server" });
this.serverId = serverId; this.serverId = serverId;
if (typeof daemonVersion !== "string" || daemonVersion.trim().length === 0) {
throw new MissingDaemonVersionError();
}
this.daemonVersion = daemonVersion.trim();
this.agentManager = agentManager; this.agentManager = agentManager;
this.agentStorage = agentStorage; this.agentStorage = agentStorage;
this.downloadTokenStore = downloadTokenStore; this.downloadTokenStore = downloadTokenStore;
@@ -509,6 +522,7 @@ export class VoiceAssistantWebSocketServer {
status: "server_info", status: "server_info",
serverId: this.serverId, serverId: this.serverId,
hostname: getHostname(), hostname: getHostname(),
version: this.daemonVersion,
...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}), ...(this.serverCapabilities ? { capabilities: this.serverCapabilities } : {}),
}; };
} }
+10
View File
@@ -1218,6 +1218,14 @@ const ServerInfoHostnameSchema = z.unknown().transform((value): string | null =>
return trimmed.length > 0 ? trimmed : null return trimmed.length > 0 ? trimmed : null
}) })
const ServerInfoVersionSchema = z.unknown().transform((value): string | null => {
if (typeof value !== 'string') {
return null
}
const trimmed = value.trim()
return trimmed.length > 0 ? trimmed : null
})
const ServerCapabilitiesFromUnknownSchema = z const ServerCapabilitiesFromUnknownSchema = z
.unknown() .unknown()
.optional() .optional()
@@ -1237,12 +1245,14 @@ export const ServerInfoStatusPayloadSchema = z
status: z.literal('server_info'), status: z.literal('server_info'),
serverId: z.string().trim().min(1), serverId: z.string().trim().min(1),
hostname: ServerInfoHostnameSchema.optional(), hostname: ServerInfoHostnameSchema.optional(),
version: ServerInfoVersionSchema.optional(),
capabilities: ServerCapabilitiesFromUnknownSchema, capabilities: ServerCapabilitiesFromUnknownSchema,
}) })
.passthrough() .passthrough()
.transform((payload) => ({ .transform((payload) => ({
...payload, ...payload,
hostname: payload.hostname ?? null, hostname: payload.hostname ?? null,
version: payload.version ?? null,
})) }))
export const StatusMessageSchema = z.object({ export const StatusMessageSchema = z.object({
+6 -1
View File
@@ -8,10 +8,15 @@ import {
computeExecutionOrder, computeExecutionOrder,
buildSortedChildrenMap, buildSortedChildrenMap,
} from "./execution-order.js"; } from "./execution-order.js";
import { resolvePackageVersion } from "../server/package-version.js";
import type { AgentType, ModelName, Task } from "./types.js"; import type { AgentType, ModelName, Task } from "./types.js";
const TASKS_DIR = resolve(process.cwd(), ".tasks"); const TASKS_DIR = resolve(process.cwd(), ".tasks");
const store = new FileTaskStore(TASKS_DIR); const store = new FileTaskStore(TASKS_DIR);
const TASK_CLI_VERSION = resolvePackageVersion({
moduleUrl: import.meta.url,
packageName: "@getpaseo/server",
});
async function readStdin(): Promise<string> { async function readStdin(): Promise<string> {
const chunks: Buffer[] = []; const chunks: Buffer[] = [];
@@ -24,7 +29,7 @@ async function readStdin(): Promise<string> {
const program = new Command() const program = new Command()
.name("task") .name("task")
.description("Minimal task management with dependency tracking") .description("Minimal task management with dependency tracking")
.version("0.1.0") .version(TASK_CLI_VERSION)
.addHelpText( .addHelpText(
"after", "after",
` `