Update files

This commit is contained in:
Mohamed Boudra
2026-02-04 14:58:18 +07:00
parent 835e8e45d1
commit 8152deb206
15 changed files with 495 additions and 10 deletions
+1
View File
@@ -118,6 +118,7 @@ export default async function globalSetup() {
env: {
...process.env,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: 'e2e-test-daemon',
PASEO_LISTEN: `0.0.0.0:${port}`,
PASEO_RELAY_ENDPOINT: `127.0.0.1:${relayPort}`,
PASEO_CORS_ORIGINS: `http://localhost:${metroPort}`,
+116
View File
@@ -71,3 +71,119 @@ test('new agent respects serverId in the URL', async ({ page }) => {
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});
test('new agent auto-selects first online host when no preference is stored', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
const nowIso = new Date().toISOString();
const testDaemon = {
id: 'e2e-test-daemon',
label: 'localhost',
endpoints: [`127.0.0.1:${daemonPort}`],
relay: null,
metadata: null,
createdAt: nowIso,
updatedAt: nowIso,
};
await page.goto('/');
await page.evaluate(
({ daemon }) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.removeItem('@paseo:create-agent-preferences');
localStorage.removeItem('@paseo:settings');
},
{ daemon: testDaemon }
);
await page.reload();
await expect(page.getByText('New agent', { exact: true }).first()).toBeVisible();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
// Host should be auto-selected (no manual selection required).
await expect(page.getByText('localhost', { exact: true }).first()).toBeVisible();
const input = page.getByRole('textbox', { name: 'Message agent...' });
await expect(input).toBeEditable({ timeout: 30000 });
});
test('adopts daemon-provided serverId for legacy host ids', async ({ page }) => {
const daemonPort = process.env.E2E_DAEMON_PORT;
if (!daemonPort) {
throw new Error('E2E_DAEMON_PORT is not set (expected from globalSetup).');
}
const nowIso = new Date().toISOString();
const legacyId = 'legacy-daemon-id';
const daemon = {
id: legacyId,
label: 'localhost',
endpoints: [`127.0.0.1:${daemonPort}`],
relay: null,
metadata: null,
createdAt: nowIso,
updatedAt: nowIso,
};
await page.goto('/settings');
await page.evaluate(
({ daemon, legacyId }) => {
const nonce = localStorage.getItem('@paseo:e2e-seed-nonce') ?? '1';
localStorage.setItem('@paseo:e2e-disable-default-seed-once', nonce);
localStorage.setItem('@paseo:daemon-registry', JSON.stringify([daemon]));
localStorage.setItem(
'@paseo:create-agent-preferences',
JSON.stringify({
serverId: legacyId,
provider: 'claude',
providerPreferences: {
claude: { model: 'haiku' },
codex: { model: 'gpt-5.1-codex-mini' },
},
})
);
localStorage.removeItem('@paseo:settings');
},
{ daemon, legacyId }
);
await page.reload();
await expect(page.getByText('Online', { exact: true }).first()).toBeVisible({ timeout: 20000 });
// Wait for server_info handshake to rekey registry + preferences.
await page.waitForFunction(
() => {
const raw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
if (!raw || !prefsRaw) return false;
const registry = JSON.parse(raw);
const prefs = JSON.parse(prefsRaw);
if (!Array.isArray(registry) || registry.length !== 1) return false;
if (registry[0]?.id !== 'e2e-test-daemon') return false;
if (prefs?.serverId !== 'e2e-test-daemon') return false;
const legacyIds = registry[0]?.metadata?.legacyIds;
return Array.isArray(legacyIds) && legacyIds.includes('legacy-daemon-id');
},
{ timeout: 20000 }
);
const snapshot = await page.evaluate(() => {
const raw = localStorage.getItem('@paseo:daemon-registry');
const prefsRaw = localStorage.getItem('@paseo:create-agent-preferences');
return { raw, prefsRaw };
});
if (!snapshot.raw || !snapshot.prefsRaw) {
throw new Error('Expected registry + preferences to be present after rekey.');
}
const registry = JSON.parse(snapshot.raw) as any[];
expect(registry[0].id).toBe('e2e-test-daemon');
expect(registry[0].metadata.legacyIds).toContain('legacy-daemon-id');
const prefs = JSON.parse(snapshot.prefsRaw) as any;
expect(prefs.serverId).toBe('e2e-test-daemon');
});
@@ -1,12 +1,23 @@
import { useLocalSearchParams } from "expo-router";
import { useEffect } from "react";
import { useLocalSearchParams, useRouter } from "expo-router";
import { AgentReadyScreen } from "@/screens/agent/agent-ready-screen";
import { resolveCanonicalDaemonId, useDaemonRegistry } from "@/contexts/daemon-registry-context";
export default function AgentReadyRoute() {
const router = useRouter();
const { daemons } = useDaemonRegistry();
const { serverId, agentId } = useLocalSearchParams<{
serverId?: string;
agentId?: string;
}>();
useEffect(() => {
if (typeof serverId !== "string" || typeof agentId !== "string") return;
const canonical = resolveCanonicalDaemonId(daemons, serverId);
if (!canonical || canonical === serverId) return;
router.replace(`/agent/${canonical}/${agentId}` as any);
}, [agentId, daemons, router, serverId]);
return (
<AgentReadyScreen
serverId={typeof serverId === "string" ? serverId : ""}
@@ -14,4 +25,3 @@ export default function AgentReadyRoute() {
/>
);
}
@@ -210,7 +210,7 @@ export function DropdownMenuTrigger({
onPress={handlePress}
style={({ pressed, hovered }) => {
if (typeof style === "function") {
return style({ pressed, hovered, open: ctx.open });
return style({ pressed, hovered: Boolean(hovered), open: ctx.open });
}
return style;
}}
@@ -59,6 +59,7 @@ interface DaemonRegistryContextValue {
removeDaemon: (id: string) => Promise<void>;
upsertDaemonFromOffer: (offer: ConnectionOfferV1) => Promise<HostProfile>;
upsertDaemonFromOfferUrl: (offerUrlOrFragment: string) => Promise<HostProfile>;
adoptDaemonServerId: (currentId: string, serverId: string) => Promise<string>;
}
const DaemonRegistryContext = createContext<DaemonRegistryContextValue | null>(null);
@@ -129,6 +130,72 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
await persist(remaining);
}, [persist, readDaemons]);
const adoptDaemonServerId = useCallback(
async (currentId: string, serverId: string) => {
const trimmed = serverId.trim();
if (!trimmed) {
throw new Error("serverId is required");
}
if (currentId === trimmed) {
return trimmed;
}
const existing = readDaemons();
const now = new Date().toISOString();
const idx = existing.findIndex((daemon) => daemon.id === currentId);
if (idx === -1) {
return trimmed;
}
const current = existing[idx];
const otherIdx = existing.findIndex((daemon) => daemon.id === trimmed);
const addLegacyId = (metadata: Record<string, unknown> | null | undefined, legacyId: string) => {
const prev = (metadata ?? {}) as Record<string, unknown>;
const legacyIdsRaw = prev.legacyIds;
const legacyIds = Array.isArray(legacyIdsRaw)
? legacyIdsRaw.filter((value) => typeof value === "string")
: [];
if (!legacyIds.includes(legacyId)) {
legacyIds.push(legacyId);
}
return { ...prev, legacyIds };
};
// Merge into existing canonical entry if it exists.
if (otherIdx !== -1) {
const canonical = existing[otherIdx];
const merged: HostProfile = {
...canonical,
label: canonical.label?.trim() ? canonical.label : current.label,
endpoints: Array.from(new Set([...(canonical.endpoints ?? []), ...(current.endpoints ?? [])])),
daemonPublicKeyB64: canonical.daemonPublicKeyB64 ?? current.daemonPublicKeyB64,
relay: canonical.relay ?? current.relay ?? null,
createdAt: canonical.createdAt ?? current.createdAt,
updatedAt: now,
metadata: addLegacyId(canonical.metadata, currentId),
};
const next = existing.filter((daemon) => daemon.id !== currentId);
next[otherIdx > idx ? otherIdx - 1 : otherIdx] = merged;
await persist(next);
return trimmed;
}
const updated: HostProfile = {
...current,
id: trimmed,
updatedAt: now,
metadata: addLegacyId(current.metadata, currentId),
};
const next = [...existing];
next[idx] = updated;
await persist(next);
return trimmed;
},
[persist, readDaemons]
);
const upsertDaemonFromOffer = useCallback(
async (offer: ConnectionOfferV1) => {
const existing = readDaemons();
@@ -194,6 +261,7 @@ export function DaemonRegistryProvider({ children }: { children: ReactNode }) {
addDaemon,
updateDaemon,
removeDaemon,
adoptDaemonServerId,
upsertDaemonFromOffer,
upsertDaemonFromOfferUrl,
};
@@ -389,3 +457,16 @@ export function buildDirectDaemonWsUrl(profile: HostProfile): string {
}
return buildDaemonWebSocketUrl(endpoint);
}
export function resolveCanonicalDaemonId(daemons: HostProfile[], id: string): string | null {
const direct = daemons.find((daemon) => daemon.id === id);
if (direct) return direct.id;
for (const daemon of daemons) {
const legacyIds = (daemon.metadata as any)?.legacyIds;
if (Array.isArray(legacyIds) && legacyIds.some((value) => value === id)) {
return daemon.id;
}
}
return null;
}
+49 -1
View File
@@ -1,7 +1,7 @@
import { useRef, ReactNode, useCallback, useEffect, useMemo, useState } from "react";
import { AppState, Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useMutation } from "@tanstack/react-query";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useDaemonClient } from "@/hooks/use-daemon-client";
import { useAudioPlayer } from "@/hooks/use-audio-player";
import { useClientActivity } from "@/hooks/use-client-activity";
@@ -23,6 +23,7 @@ import type { AgentPermissionRequest } from "@server/server/agent/agent-sdk-type
import type { DaemonClient, ConnectionState } from "@server/client/daemon-client";
import { File } from "expo-file-system";
import { useDaemonConnections } from "./daemon-connections-context";
import { useDaemonRegistry } from "./daemon-registry-context";
import {
useSessionStore,
type SessionState,
@@ -259,18 +260,23 @@ interface SessionProviderProps {
serverId: string;
}
const FORM_PREFERENCES_STORAGE_KEY = "@paseo:create-agent-preferences";
const FORM_PREFERENCES_QUERY_KEY = ["form-preferences"];
// SessionProvider: Daemon client message handler that updates Zustand store
export function SessionProvider({
children,
serverUrl,
serverId,
}: SessionProviderProps) {
const queryClient = useQueryClient();
const client = useDaemonClient(serverUrl);
const [connectionSnapshot, setConnectionSnapshot] =
useState<DaemonConnectionSnapshot>(() =>
mapConnectionState(client.getConnectionState(), client.lastError)
);
const { updateConnectionStatus } = useDaemonConnections();
const { adoptDaemonServerId } = useDaemonRegistry();
// Zustand store actions
const initializeSession = useSessionStore((state) => state.initializeSession);
@@ -349,6 +355,7 @@ export function SessionProvider({
);
const attentionNotifiedRef = useRef<Map<string, number>>(new Map());
const appStateRef = useRef(AppState.currentState);
const hasAdoptedServerIdRef = useRef(false);
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextState) => {
@@ -443,6 +450,47 @@ export function SessionProvider({
return unsubscribe;
}, [client]);
// Daemon identity handshake: adopt stable serverId from the daemon.
useEffect(() => {
return client.on("status", (message) => {
if (message.type !== "status") return;
const payload = message.payload as { status?: unknown; serverId?: unknown };
if (payload?.status !== "server_info") return;
const remoteServerId = typeof payload.serverId === "string" ? payload.serverId.trim() : "";
if (!remoteServerId) return;
// Avoid loops if multiple server_info messages arrive.
if (hasAdoptedServerIdRef.current) return;
hasAdoptedServerIdRef.current = true;
if (remoteServerId === serverId) {
return;
}
void (async () => {
const canonicalId = await adoptDaemonServerId(serverId, remoteServerId);
// Migrate persisted "create agent" preferences if they reference the old id.
try {
const stored = await AsyncStorage.getItem(FORM_PREFERENCES_STORAGE_KEY);
if (stored) {
const parsed = JSON.parse(stored) as { serverId?: unknown } & Record<string, unknown>;
if (typeof parsed.serverId === "string" && parsed.serverId === serverId) {
const next = { ...parsed, serverId: canonicalId };
await AsyncStorage.setItem(FORM_PREFERENCES_STORAGE_KEY, JSON.stringify(next));
queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next);
}
}
} catch (error) {
console.warn("[Session] Failed to migrate form preferences serverId", { error });
}
})().catch((error) => {
console.warn("[Session] Failed to adopt daemon serverId", { error });
});
});
}, [adoptDaemonServerId, client, queryClient, serverId]);
useEffect(() => {
updateSessionConnection(serverId, connectionSnapshot);
}, [serverId, connectionSnapshot, updateSessionConnection]);
@@ -54,6 +54,7 @@ type UseAgentFormStateOptions = {
isVisible?: boolean;
isCreateFlow?: boolean;
isTargetDaemonReady?: boolean;
onlineServerIds?: string[];
};
type UseAgentFormStateResult = {
@@ -230,6 +231,7 @@ export function useAgentFormState(
isVisible = true,
isCreateFlow = true,
isTargetDaemonReady = true,
onlineServerIds = [],
} = options;
const {
@@ -357,6 +359,32 @@ export function useAgentFormState(
validServerIds,
]);
// Auto-select the first online host when:
// - no URL override
// - no stored preference applied
// - user hasn't manually picked a host in this session
useEffect(() => {
if (!isVisible || !isCreateFlow) return;
if (isPreferencesLoading) return;
if (!hasResolvedRef.current) return;
if (userModified.serverId) return;
if (combinedInitialValues?.serverId !== undefined) return;
if (formStateRef.current.serverId) return;
const candidate = onlineServerIds.find((id) => validServerIds.has(id)) ?? null;
if (!candidate) return;
setFormState((prev) => (prev.serverId ? prev : { ...prev, serverId: candidate }));
}, [
combinedInitialValues?.serverId,
isCreateFlow,
isPreferencesLoading,
isVisible,
onlineServerIds.join("|"),
userModified.serverId,
validServerIds,
]);
// Persist inferred serverId so reloads keep the selection (e.g. URL serverId or first-time load).
useEffect(() => {
if (!isVisible || !isCreateFlow) return;
@@ -32,7 +32,7 @@ import {
checkoutStatusQueryKey,
} from "@/hooks/use-checkout-status-query";
import { useDaemonConnections } from "@/contexts/daemon-connections-context";
import { useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { resolveCanonicalDaemonId, useDaemonRegistry } from "@/contexts/daemon-registry-context";
import { formatConnectionStatus } from "@/utils/daemons";
import { useSessionStore } from "@/stores/session-store";
import { useCreateFlowStore } from "@/stores/create-flow-store";
@@ -147,6 +147,33 @@ export function DraftAgentScreen({
const resolvedModel = getParamValue(params.model);
const resolvedWorkingDir = getParamValue(params.workingDir);
const onlineServerIds = useMemo(() => {
if (daemons.length === 0) return [];
const out: string[] = [];
for (const daemon of daemons) {
const status = connectionStates.get(daemon.id)?.status ?? "idle";
if (status === "online") out.push(daemon.id);
}
return out;
}, [connectionStates, daemons]);
// If the URL contains a legacy host id, redirect to the canonical daemon-provided id.
useEffect(() => {
if (!resolvedServerId) return;
const canonical = resolveCanonicalDaemonId(daemons, resolvedServerId);
if (!canonical || canonical === resolvedServerId) return;
const nextParams: Record<string, string> = {};
for (const [key, value] of Object.entries(params)) {
if (key === "serverId") continue;
if (typeof value === "string") nextParams[key] = value;
if (Array.isArray(value) && typeof value[0] === "string") nextParams[key] = value[0] as string;
}
nextParams.serverId = canonical;
router.replace({ pathname: "/", params: nextParams as any });
}, [daemons, params, resolvedServerId, router]);
const initialValues = useMemo((): CreateAgentInitialValues => {
const values: CreateAgentInitialValues = {};
if (resolvedWorkingDir) {
@@ -187,6 +214,7 @@ export function DraftAgentScreen({
initialValues,
isVisible,
isCreateFlow: true,
onlineServerIds,
});
const hostEntry = selectedServerId
? connectionStates.get(selectedServerId)