diff --git a/docs/expo-router.md b/docs/expo-router.md index df3aa2c5b..cf85f00c0 100644 --- a/docs/expo-router.md +++ b/docs/expo-router.md @@ -73,6 +73,20 @@ only use local param fallback during cold mount (`/` or empty pathname), or a hidden workspace can overwrite the remembered workspace before Settings or History returns. +## Agent Targets + +Notifications and agent URLs enter the router with different authoritative +targets. + +- Notifications carry `serverId`, `workspaceId`, and `agentId`. Route them + directly to the workspace with the agent open intent. +- Agent URLs carry only `serverId` and `agentId`. Route them through + `/h/[serverId]/agent/[agentId]`; that route waits for the named host, resolves + the agent's workspace from the host, and then opens the agent there. + +Both paths converge on `navigateToAgent()`. Do not make notification routing +guess a workspace, and do not add a workspace to the stable agent URL format. + ## Params Required dynamic params belong to the matched route. diff --git a/packages/app/e2e/workspace-navigation-regression.spec.ts b/packages/app/e2e/workspace-navigation-regression.spec.ts index 82b3078fb..7ddbf3ea9 100644 --- a/packages/app/e2e/workspace-navigation-regression.spec.ts +++ b/packages/app/e2e/workspace-navigation-regression.spec.ts @@ -1,4 +1,8 @@ -import { buildHostAgentDetailRoute, buildHostWorkspaceRoute } from "@/utils/host-routes"; +import { + buildHostAgentDetailRoute, + buildHostWorkspaceOpenRoute, + buildHostWorkspaceRoute, +} from "@/utils/host-routes"; import { expect, test, type Page } from "./fixtures"; import { gotoAppShell, openSettings } from "./helpers/app"; import { @@ -34,6 +38,7 @@ import { getServerId } from "./helpers/server-id"; import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates"; import { expectAppRoute } from "./helpers/route-assertions"; import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate"; +import { addOfflineHostAndReload } from "./helpers/hosts"; const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i; type StartupPresentation = "splash" | "app"; @@ -158,6 +163,43 @@ async function expectWorkspaceLocation( test.describe("Workspace navigation regression", () => { test.describe.configure({ timeout: 240_000 }); + test("opens a notification's workspace on a different offline host", async ({ page }) => { + const target = { + serverId: "notification-offline-host", + workspaceId: "notification-workspace", + agentId: "notification-agent", + }; + + await gotoAppShell(page); + await addOfflineHostAndReload(page, { + serverId: target.serverId, + label: "Notification Host", + }); + await expect( + page.getByTestId("sidebar-settings").filter({ visible: true }).first(), + ).toBeVisible({ + timeout: 30_000, + }); + + await page.evaluate((data) => { + globalThis.dispatchEvent( + new CustomEvent("paseo:web-notification-click", { + detail: { data: { ...data, reason: "finished" } }, + cancelable: true, + }), + ); + }, target); + + await expectAppRoute( + page, + buildHostWorkspaceOpenRoute(target.serverId, target.workspaceId, `agent:${target.agentId}`), + { timeout: 30_000 }, + ); + await expect(page.getByText("Connecting", { exact: true })).toBeVisible(); + await expect(page.getByText("Notification Host", { exact: true })).toBeVisible(); + await expect(page.getByText("Add a project", { exact: true })).toHaveCount(0); + }); + test("keeps one replacement draft after returning from settings and closing the last tab", async ({ page, withWorkspace, @@ -420,12 +462,13 @@ test.describe("Workspace navigation regression", () => { await expectWorkspaceDeckEntryCount(page, 2); await page.evaluate( - ({ agentId, serverId: targetServerId }) => { + ({ agentId, serverId: targetServerId, workspaceId }) => { globalThis.dispatchEvent( new CustomEvent("paseo:web-notification-click", { detail: { data: { serverId: targetServerId, + workspaceId, agentId, reason: "finished", }, @@ -434,7 +477,7 @@ test.describe("Workspace navigation regression", () => { }), ); }, - { agentId: secondAgent.id, serverId }, + { agentId: secondAgent.id, serverId, workspaceId: secondWorkspace.workspaceId }, ); await waitForWorkspaceTabsVisible(page); await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), { diff --git a/packages/app/src/app/_layout.tsx b/packages/app/src/app/_layout.tsx index c3346e927..5591949f0 100644 --- a/packages/app/src/app/_layout.tsx +++ b/packages/app/src/app/_layout.tsx @@ -147,9 +147,10 @@ function PushNotificationRouter() { const openNotification = useStableEvent((data: Record | undefined) => { const target = resolveNotificationTarget(data); const serverId = target.serverId; + const workspaceId = target.workspaceId; const agentId = target.agentId; - if (serverId && agentId) { - navigateToAgent({ serverId, agentId, pin: true }); + if (serverId && workspaceId && agentId) { + navigateToAgent({ serverId, workspaceId, agentId, pin: true }); return; } diff --git a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx index 39193bc16..0ad039414 100644 --- a/packages/app/src/app/h/[serverId]/agent/[agentId].tsx +++ b/packages/app/src/app/h/[serverId]/agent/[agentId].tsx @@ -1,10 +1,13 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useMemo, useRef } from "react"; import { useLocalSearchParams, useRouter, type Href } from "expo-router"; import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary"; +import { useFetchQuery } from "@/data/query"; +import { resolveAgentRoute, type AgentRouteLookup } from "@/navigation/agent-route-resolution"; +import { AgentRouteResolutionView } from "@/navigation/agent-route-resolution-view"; import { useSessionStore } from "@/stores/session-store"; -import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime"; -import { buildHostRootRoute } from "@/utils/host-routes"; -import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; +import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime"; +import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes"; +import { toErrorMessage } from "@/utils/error-messages"; import { navigateToAgent } from "@/utils/navigate-to-agent"; export default function HostAgentReadyRoute() { @@ -21,97 +24,128 @@ function HostAgentReadyRouteContent() { serverId?: string; agentId?: string; }>(); - const redirectedRef = useRef(false); + const handledNavigationRef = useRef(null); const serverId = typeof params.serverId === "string" ? params.serverId : ""; const agentId = typeof params.agentId === "string" ? params.agentId : ""; - const client = useHostRuntimeClient(serverId); - const isConnected = useHostRuntimeIsConnected(serverId); + const hosts = useHosts(); + const runtimeSnapshot = useHostRuntimeSnapshot(serverId); + const client = runtimeSnapshot?.client ?? null; + const connectionStatus = runtimeSnapshot?.connectionStatus ?? "connecting"; + const hostName = hosts.find((host) => host.serverId === serverId)?.label ?? serverId; const agentWorkspaceId = useSessionStore((state) => { if (!serverId || !agentId) { return null; } return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null; }); - const hasHydratedWorkspaces = useSessionStore((state) => - serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false, + const shouldLookupAgent = Boolean( + serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId, ); - const resolvedWorkspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId); + const lookupQuery = useFetchQuery({ + queryKey: ["agentRouteResolution", serverId, agentId, runtimeSnapshot?.clientGeneration ?? 0], + queryFn: async () => { + if (!client) { + throw new Error("Target host client is unavailable"); + } + const result = await client.fetchAgent({ agentId }); + return result?.agent?.workspaceId ?? null; + }, + enabled: shouldLookupAgent, + retry: false, + dataShape: "value", + staleTimeMs: 0, + }); + const lookup = useMemo(() => { + if (!shouldLookupAgent) { + return { kind: "idle" }; + } + if (lookupQuery.isFetching) { + return { kind: "fetching" }; + } + if (lookupQuery.isError) { + return { kind: "failed", error: toErrorMessage(lookupQuery.error) }; + } + if (lookupQuery.isSuccess) { + return { kind: "found", workspaceId: lookupQuery.data }; + } + return { kind: "fetching" }; + }, [ + lookupQuery.data, + lookupQuery.error, + lookupQuery.isError, + lookupQuery.isFetching, + lookupQuery.isSuccess, + shouldLookupAgent, + ]); + const resolution = resolveAgentRoute({ + serverId, + agentId, + cachedWorkspaceId: agentWorkspaceId, + connectionStatus, + lookup, + }); useEffect(() => { - if (redirectedRef.current) { - return; - } - if (!serverId || !agentId) { - redirectedRef.current = true; - router.replace("/" as Href); + let navigationKey: string | null = null; + if (resolution.kind === "invalid") { + navigationKey = "invalid"; + } else if (resolution.kind === "resolved") { + navigationKey = `workspace:${resolution.workspaceId}`; + } else if (resolution.kind === "notFound") { + navigationKey = "not-found"; + } + if (!navigationKey || handledNavigationRef.current === navigationKey) { return; } + handledNavigationRef.current = navigationKey; - if (resolvedWorkspaceId) { - redirectedRef.current = true; - navigateToAgent({ - serverId, - agentId, - }); + if (resolution.kind === "resolved") { + navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId }); + return; } - }, [agentId, resolvedWorkspaceId, router, serverId]); + router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId)); + }, [agentId, resolution, router, serverId]); - useEffect(() => { - if (redirectedRef.current) { + const handleRetry = useCallback(() => { + if (resolution.kind === "lookupError") { + void lookupQuery.refetch(); return; } - if (!serverId || !agentId) { + if (serverId) { + void getHostRuntimeStore().runProbeCycleNow(serverId); + } + }, [lookupQuery, resolution.kind, serverId]); + const handleManageHost = useCallback(() => { + if (serverId) { + router.push(buildSettingsHostRoute(serverId)); + } + }, [router, serverId]); + const handleBack = useCallback(() => { + if (router.canGoBack()) { + router.back(); return; } - if (agentWorkspaceId && !hasHydratedWorkspaces) { - return; - } - if (!client || !isConnected) { - redirectedRef.current = true; - router.replace(buildHostRootRoute(serverId)); - } - }, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]); + router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href)); + }, [router, serverId]); - useEffect(() => { - if (redirectedRef.current) { - return; - } - if (!serverId || !agentId || !client || !isConnected) { - return; - } - - let cancelled = false; - void client - .fetchAgent({ agentId }) - .then((result) => { - if (cancelled || redirectedRef.current) { - return; - } - const workspaceId = normalizeWorkspaceOpaqueId(result?.agent?.workspaceId); - redirectedRef.current = true; - if (workspaceId) { - navigateToAgent({ - serverId, - agentId, - workspaceId, - }); - return; - } - router.replace(buildHostRootRoute(serverId)); - return; - }) - .catch(() => { - if (cancelled || redirectedRef.current) { - return; - } - redirectedRef.current = true; - router.replace(buildHostRootRoute(serverId)); - }); - - return () => { - cancelled = true; - }; - }, [agentId, client, isConnected, router, serverId]); + if ( + resolution.kind === "waitingForHost" || + resolution.kind === "fetchingAgent" || + resolution.kind === "lookupError" + ) { + // Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host + // reconnects, then resolve the workspace from the authoritative agent record. + return ( + + ); + } return null; } diff --git a/packages/app/src/contexts/session-context.tsx b/packages/app/src/contexts/session-context.tsx index 606ea94c0..e0819ad7a 100644 --- a/packages/app/src/contexts/session-context.tsx +++ b/packages/app/src/contexts/session-context.tsx @@ -29,6 +29,7 @@ import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages"; import { buildAgentAttentionNotificationPayload, + type AgentAttentionReason, type AgentAttentionNotificationPayload, type NotificationPermissionRequest, } from "@getpaseo/protocol/agent-attention-notification"; @@ -154,6 +155,35 @@ const getLatestPermissionRequest = ( return null; }; +interface AgentAttentionNotificationInput { + notification?: AgentAttentionNotificationPayload; + reason: AgentAttentionReason; + serverId: string; + workspaceId: string | undefined; + agentId: string; + assistantMessage: string | null; + permissionRequest: NotificationPermissionRequest | null; +} + +function resolveAgentAttentionNotification( + input: AgentAttentionNotificationInput, +): AgentAttentionNotificationPayload | null { + if (input.notification) { + return input.notification.data.workspaceId ? input.notification : null; + } + if (!input.workspaceId) { + return null; + } + return buildAgentAttentionNotificationPayload({ + reason: input.reason, + serverId: input.serverId, + workspaceId: input.workspaceId, + agentId: input.agentId, + assistantMessage: input.reason === "finished" ? input.assistantMessage : null, + permissionRequest: input.reason === "permission" ? input.permissionRequest : null, + }); +} + type WorkspaceSetupProgressPayload = Extract< SessionOutboundMessage, { type: "workspace_setup_progress" } @@ -500,16 +530,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider const assistantMessage = findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail); const permissionRequest = getLatestPermissionRequest(session, params.agentId); + const workspaceId = session?.agents?.get(params.agentId)?.workspaceId; - const notification = - params.notification ?? - buildAgentAttentionNotificationPayload({ - reason: params.reason, - serverId, - agentId: params.agentId, - assistantMessage: params.reason === "finished" ? assistantMessage : null, - permissionRequest: params.reason === "permission" ? permissionRequest : null, - }); + const notification = resolveAgentAttentionNotification({ + notification: params.notification, + reason: params.reason, + serverId, + workspaceId, + agentId: params.agentId, + assistantMessage, + permissionRequest, + }); + if (!notification) { + return; + } void sendOsNotification({ title: notification.title, diff --git a/packages/app/src/navigation/agent-route-resolution-view.tsx b/packages/app/src/navigation/agent-route-resolution-view.tsx new file mode 100644 index 000000000..b875e0bef --- /dev/null +++ b/packages/app/src/navigation/agent-route-resolution-view.tsx @@ -0,0 +1,147 @@ +import { Text, View } from "react-native"; +import { ArrowLeftToLine, RotateCw, Settings } from "lucide-react-native"; +import { useTranslation } from "react-i18next"; +import { StyleSheet, withUnistyles } from "react-native-unistyles"; +import { Button } from "@/components/ui/button"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import type { AgentRouteResolution } from "@/navigation/agent-route-resolution"; +import { formatConnectionStatus } from "@/utils/daemons"; +import type { Theme } from "@/styles/theme"; + +type VisibleAgentRouteResolution = Extract< + AgentRouteResolution, + { kind: "waitingForHost" | "fetchingAgent" | "lookupError" } +>; + +const ThemedLoadingSpinner = withUnistyles(LoadingSpinner); +const foregroundMutedColorMapping = (theme: Theme) => ({ + color: theme.colors.foregroundMuted, +}); + +export function AgentRouteResolutionView({ + resolution, + hostName, + lastHostError, + onRetry, + onManageHost, + onBack, +}: { + resolution: VisibleAgentRouteResolution; + hostName: string; + lastHostError: string | null; + onRetry: () => void; + onManageHost: () => void; + onBack: () => void; +}) { + const { t } = useTranslation(); + + if (resolution.kind === "fetchingAgent") { + return ( + + + + + {t("agentPanel.unavailable.preparingSession", { serverLabel: hostName })} + + {t("agentPanel.unavailable.showSoon")} + + + ); + } + + if (resolution.kind === "lookupError") { + return ( + + + {t("agentPanel.states.failedToLoad")} + {resolution.error} + + + + + + + ); + } + + const isConnecting = + resolution.connectionStatus === "connecting" || resolution.connectionStatus === "idle"; + let title = t("workspace.route.cannotReachHost", { hostName }); + if (isConnecting) { + title = t("agentPanel.unavailable.connecting", { serverLabel: hostName }); + } else if (resolution.connectionStatus === "offline") { + title = t("workspace.route.hostOffline", { hostName }); + } + + return ( + + {isConnecting ? ( + + ) : null} + + {title} + + {isConnecting + ? t("agentPanel.unavailable.showWhenOnline") + : t("workspace.route.hostStatus", { + status: formatConnectionStatus(resolution.connectionStatus), + })} + + {lastHostError ? {lastHostError} : null} + + {!isConnecting ? ( + + + + + ) : null} + + ); +} + +const styles = StyleSheet.create((theme) => ({ + emptyState: { + flex: 1, + alignItems: "center", + justifyContent: "center", + gap: theme.spacing[3], + paddingHorizontal: theme.spacing[6], + }, + textStack: { + alignItems: "center", + gap: theme.spacing[2], + maxWidth: 520, + }, + title: { + color: theme.colors.foreground, + fontSize: theme.fontSize.base, + fontWeight: theme.fontWeight.normal, + textAlign: "center", + }, + description: { + color: theme.colors.foregroundMuted, + fontSize: theme.fontSize.sm, + textAlign: "center", + }, + error: { + color: theme.colors.destructive, + fontSize: theme.fontSize.sm, + lineHeight: Math.round(theme.fontSize.sm * 1.4), + textAlign: "center", + }, + actions: { + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + flexWrap: "wrap", + gap: theme.spacing[2], + }, +})); diff --git a/packages/app/src/navigation/agent-route-resolution.test.ts b/packages/app/src/navigation/agent-route-resolution.test.ts new file mode 100644 index 000000000..99fb95ec4 --- /dev/null +++ b/packages/app/src/navigation/agent-route-resolution.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { resolveAgentRoute } from "@/navigation/agent-route-resolution"; + +const VALID_ROUTE = { + serverId: "server-1", + agentId: "agent-1", + cachedWorkspaceId: null, +} as const; + +describe("resolveAgentRoute", () => { + it("opens a cached workspace without waiting for its host", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + cachedWorkspaceId: "workspace-1", + connectionStatus: "offline", + lookup: { kind: "idle" }, + }), + ).toEqual({ kind: "resolved", workspaceId: "workspace-1" }); + }); + + it.each(["idle", "connecting", "offline", "error"] as const)( + "waits for a %s target host instead of abandoning the agent", + (connectionStatus) => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + connectionStatus, + lookup: { kind: "idle" }, + }), + ).toEqual({ kind: "waitingForHost", connectionStatus }); + }, + ); + + it("fetches the agent after its target host connects", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + connectionStatus: "online", + lookup: { kind: "idle" }, + }), + ).toEqual({ kind: "fetchingAgent" }); + }); + + it("opens the workspace returned by the target host", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + connectionStatus: "online", + lookup: { kind: "found", workspaceId: "workspace-2" }, + }), + ).toEqual({ kind: "resolved", workspaceId: "workspace-2" }); + }); + + it("abandons the agent only after the target host says it is missing", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + connectionStatus: "online", + lookup: { kind: "found", workspaceId: null }, + }), + ).toEqual({ kind: "notFound" }); + }); + + it("keeps lookup failures retryable", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + connectionStatus: "online", + lookup: { kind: "failed", error: "connection closed" }, + }), + ).toEqual({ kind: "lookupError", error: "connection closed" }); + }); + + it("rejects incomplete route parameters", () => { + expect( + resolveAgentRoute({ + ...VALID_ROUTE, + agentId: "", + connectionStatus: "online", + lookup: { kind: "idle" }, + }), + ).toEqual({ kind: "invalid" }); + }); +}); diff --git a/packages/app/src/navigation/agent-route-resolution.ts b/packages/app/src/navigation/agent-route-resolution.ts new file mode 100644 index 000000000..88fb848c5 --- /dev/null +++ b/packages/app/src/navigation/agent-route-resolution.ts @@ -0,0 +1,53 @@ +import type { HostRuntimeConnectionStatus } from "@/runtime/host-runtime"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; + +export type AgentRouteLookup = + | { kind: "idle" } + | { kind: "fetching" } + | { kind: "found"; workspaceId: string | null | undefined } + | { kind: "failed"; error: string }; + +export type AgentRouteResolution = + | { kind: "invalid" } + | { kind: "resolved"; workspaceId: string } + | { + kind: "waitingForHost"; + connectionStatus: Exclude; + } + | { kind: "fetchingAgent" } + | { kind: "notFound" } + | { kind: "lookupError"; error: string }; + +export function resolveAgentRoute(input: { + serverId: string; + agentId: string; + cachedWorkspaceId: string | null | undefined; + connectionStatus: HostRuntimeConnectionStatus; + lookup: AgentRouteLookup; +}): AgentRouteResolution { + if (!input.serverId || !input.agentId) { + return { kind: "invalid" }; + } + + const cachedWorkspaceId = normalizeWorkspaceOpaqueId(input.cachedWorkspaceId); + if (cachedWorkspaceId) { + return { kind: "resolved", workspaceId: cachedWorkspaceId }; + } + + if (input.connectionStatus !== "online") { + return { kind: "waitingForHost", connectionStatus: input.connectionStatus }; + } + + if (input.lookup.kind === "found") { + const fetchedWorkspaceId = normalizeWorkspaceOpaqueId(input.lookup.workspaceId); + return fetchedWorkspaceId + ? { kind: "resolved", workspaceId: fetchedWorkspaceId } + : { kind: "notFound" }; + } + + if (input.lookup.kind === "failed") { + return { kind: "lookupError", error: input.lookup.error }; + } + + return { kind: "fetchingAgent" }; +} diff --git a/packages/app/src/utils/host-routes.ts b/packages/app/src/utils/host-routes.ts index 79f78c229..118ea2c40 100644 --- a/packages/app/src/utils/host-routes.ts +++ b/packages/app/src/utils/host-routes.ts @@ -1,4 +1,5 @@ import { Buffer } from "buffer"; +import { buildAgentDeepLinkRoute } from "@getpaseo/protocol/agent-deep-link"; type NullableString = string | null | undefined; const BASE64_WORKSPACE_ID_PREFIX = "b64_"; @@ -389,7 +390,10 @@ export function buildHostAgentDetailRoute(serverId: string, agentId: string, wor if (!normalizedServerId || !normalizedAgentId) { return "/" as const; } - return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}` as const; + return buildAgentDeepLinkRoute({ + serverId: normalizedServerId, + agentId: normalizedAgentId, + }); } export function buildHostRootRoute(serverId: string) { diff --git a/packages/app/src/utils/notification-routing.test.ts b/packages/app/src/utils/notification-routing.test.ts index f492b56e8..92fdad43f 100644 --- a/packages/app/src/utils/notification-routing.test.ts +++ b/packages/app/src/utils/notification-routing.test.ts @@ -56,13 +56,11 @@ describe("buildNotificationRoute", () => { agentId: "agent-1", workspaceId: "ws-main", }), - ).toBe("/h/srv-1/agent/agent-1"); + ).toBe("/h/srv-1/workspace/ws-main?open=agent%3Aagent-1"); }); - it("routes directly to server-scoped agent path when both ids are present", () => { - expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe( - "/h/srv-1/agent/agent-1", - ); + it("does not treat an incomplete notification as an agent URL", () => { + expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe("/h/srv-1"); }); it("routes to the workspace terminal tab when workspace and terminal ids are present", () => { @@ -98,8 +96,9 @@ describe("buildNotificationRoute", () => { expect( buildNotificationRoute({ serverId: "srv/with/slash", + workspaceId: "workspace-1", agentId: "agent with space", }), - ).toBe("/h/srv%2Fwith%2Fslash/agent/agent%20with%20space"); + ).toBe("/h/srv%2Fwith%2Fslash/workspace/workspace-1?open=agent%3Aagent%20with%20space"); }); }); diff --git a/packages/app/src/utils/notification-routing.ts b/packages/app/src/utils/notification-routing.ts index ba6d9f1fa..04c78f591 100644 --- a/packages/app/src/utils/notification-routing.ts +++ b/packages/app/src/utils/notification-routing.ts @@ -1,9 +1,5 @@ import type { Href } from "expo-router"; -import { - buildHostAgentDetailRoute, - buildHostRootRoute, - buildHostWorkspaceOpenRoute, -} from "@/utils/host-routes"; +import { buildHostRootRoute, buildHostWorkspaceOpenRoute } from "@/utils/host-routes"; type NotificationData = Record | null | undefined; type NotificationRoute = Extract; @@ -33,8 +29,8 @@ export function resolveNotificationTarget(data: NotificationData): { export function buildNotificationRoute(data: NotificationData): NotificationRoute { const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data); - if (serverId && agentId) { - return buildHostAgentDetailRoute(serverId, agentId); + if (serverId && workspaceId && agentId) { + return buildHostWorkspaceOpenRoute(serverId, workspaceId, `agent:${agentId}`); } if (serverId && workspaceId && terminalId) { return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`); diff --git a/packages/app/src/utils/os-notifications.test.ts b/packages/app/src/utils/os-notifications.test.ts index e570a4c59..84a8a65bf 100644 --- a/packages/app/src/utils/os-notifications.test.ts +++ b/packages/app/src/utils/os-notifications.test.ts @@ -196,14 +196,20 @@ describe("sendOsNotification", () => { await sendOsNotification({ title: "Agent finished", - data: { serverId: "srv with space", agentId: "agent/1" }, + data: { + serverId: "srv with space", + workspaceId: "workspace-1", + agentId: "agent/1", + }, }); const clicked = created[0]; expect(clicked.clickListeners).toHaveLength(1); clicked.clickListeners[0]?.({} as Event); - expect(assign).toHaveBeenCalledWith("/h/srv%20with%20space/agent/agent%2F1"); + expect(assign).toHaveBeenCalledWith( + "/h/srv%20with%20space/workspace/workspace-1?open=agent%3Aagent%2F1", + ); }); it("returns false when the Notification API is unavailable", async () => { diff --git a/packages/protocol/src/agent-attention-notification.test.ts b/packages/protocol/src/agent-attention-notification.test.ts index 79d19b99c..04aabceb2 100644 --- a/packages/protocol/src/agent-attention-notification.test.ts +++ b/packages/protocol/src/agent-attention-notification.test.ts @@ -6,10 +6,27 @@ import { } from "./agent-attention-notification.js"; describe("buildAgentAttentionNotificationPayload", () => { + it("carries the workspace needed to open a cold agent destination", () => { + const payload = buildAgentAttentionNotificationPayload({ + reason: "finished", + serverId: "srv-1", + workspaceId: "workspace-1", + agentId: "agent-1", + }); + + expect(payload.data).toEqual({ + serverId: "srv-1", + workspaceId: "workspace-1", + agentId: "agent-1", + reason: "finished", + }); + }); + it("builds finished notifications from markdown assistant text", () => { const payload = buildAgentAttentionNotificationPayload({ reason: "finished", serverId: "srv-1", + workspaceId: "workspace-1", agentId: "agent-1", assistantMessage: "**Done**. Updated `README.md` and [link](https://example.com).", }); @@ -19,6 +36,7 @@ describe("buildAgentAttentionNotificationPayload", () => { body: "Done. Updated README.md and link.", data: { serverId: "srv-1", + workspaceId: "workspace-1", agentId: "agent-1", reason: "finished", }, @@ -29,6 +47,7 @@ describe("buildAgentAttentionNotificationPayload", () => { const payload = buildAgentAttentionNotificationPayload({ reason: "permission", serverId: "srv-2", + workspaceId: "workspace-2", agentId: "agent-2", permissionRequest: { id: "perm-1", @@ -45,6 +64,7 @@ describe("buildAgentAttentionNotificationPayload", () => { body: "Approve command - Run git push", data: { serverId: "srv-2", + workspaceId: "workspace-2", agentId: "agent-2", reason: "permission", }, @@ -55,6 +75,7 @@ describe("buildAgentAttentionNotificationPayload", () => { const payload = buildAgentAttentionNotificationPayload({ reason: "error", serverId: "srv-3", + workspaceId: "workspace-3", agentId: "agent-3", }); @@ -63,6 +84,7 @@ describe("buildAgentAttentionNotificationPayload", () => { body: "Encountered an error.", data: { serverId: "srv-3", + workspaceId: "workspace-3", agentId: "agent-3", reason: "error", }, diff --git a/packages/protocol/src/agent-attention-notification.ts b/packages/protocol/src/agent-attention-notification.ts index 60728e1bf..961e864d8 100644 --- a/packages/protocol/src/agent-attention-notification.ts +++ b/packages/protocol/src/agent-attention-notification.ts @@ -5,6 +5,7 @@ export type AgentAttentionReason = "finished" | "error" | "permission"; export interface AgentAttentionNotificationData { [key: string]: unknown; serverId: string; + workspaceId?: string; agentId: string; reason: AgentAttentionReason; } @@ -18,6 +19,7 @@ export interface AgentAttentionNotificationPayload { interface BuildAgentAttentionNotificationPayloadInput { reason: AgentAttentionReason; serverId: string; + workspaceId: string; agentId: string; assistantMessage?: string | null; permissionRequest?: NotificationPermissionRequest | null; @@ -203,6 +205,7 @@ export function buildAgentAttentionNotificationPayload( body, data: { serverId: input.serverId, + workspaceId: input.workspaceId, agentId: input.agentId, reason: input.reason, }, diff --git a/packages/protocol/src/agent-deep-link.ts b/packages/protocol/src/agent-deep-link.ts index bb4980ffe..a8e297f3d 100644 --- a/packages/protocol/src/agent-deep-link.ts +++ b/packages/protocol/src/agent-deep-link.ts @@ -7,18 +7,24 @@ function normalizeSegment(value: string): string { return value.trim(); } -export function buildAgentDeepLink(target: AgentDeepLinkTarget): string { +function normalizeAgentDeepLinkTarget(target: AgentDeepLinkTarget): AgentDeepLinkTarget { const serverId = normalizeSegment(target.serverId); const agentId = normalizeSegment(target.agentId); if (!serverId || !agentId) { throw new Error("Agent deep links require a server ID and agent ID."); } - return `paseo://h/${encodeURIComponent(serverId)}/agent/${encodeURIComponent(agentId)}`; + return { serverId, agentId }; } -export function buildAgentDeepLinkRoute(target: AgentDeepLinkTarget): string { - const link = new URL(buildAgentDeepLink(target)); - return `/h${link.pathname}`; +export function buildAgentDeepLinkRoute( + target: AgentDeepLinkTarget, +): `/h/${string}/agent/${string}` { + const { serverId, agentId } = normalizeAgentDeepLinkTarget(target); + return `/h/${encodeURIComponent(serverId)}/agent/${encodeURIComponent(agentId)}`; +} + +export function buildAgentDeepLink(target: AgentDeepLinkTarget): string { + return `paseo:/${buildAgentDeepLinkRoute(target)}`; } export function parseAgentDeepLink(input: string): AgentDeepLinkTarget | null { diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index d1944179a..f6be9128d 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -658,6 +658,7 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [ body: z.string(), data: z.object({ serverId: z.string(), + workspaceId: z.string().optional(), agentId: z.string(), reason: z.enum(["finished", "error", "permission"]), }), @@ -3564,6 +3565,7 @@ export const AgentAttentionRequiredMessageSchema = z.object({ body: z.string(), data: z.object({ serverId: z.string(), + workspaceId: z.string().optional(), agentId: z.string(), reason: z.enum(["finished", "error", "permission"]), }), diff --git a/packages/protocol/tests/validation/ws-outbound.test.ts b/packages/protocol/tests/validation/ws-outbound.test.ts index 63338ac63..ffc1795f4 100644 --- a/packages/protocol/tests/validation/ws-outbound.test.ts +++ b/packages/protocol/tests/validation/ws-outbound.test.ts @@ -138,6 +138,65 @@ const SourceSchema = z.object({ ); }); + it.each([ + { + name: "dedicated attention message", + message: { + type: "agent_attention_required", + payload: { + agentId: "agent-1", + reason: "finished", + timestamp: "2026-07-22T18:00:00.000Z", + shouldNotify: true, + notification: { + title: "Agent finished", + body: "Done", + data: { + serverId: "server-1", + workspaceId: "workspace-1", + agentId: "agent-1", + reason: "finished", + }, + }, + }, + }, + }, + { + name: "agent stream attention event", + message: { + type: "agent_stream", + payload: { + agentId: "agent-1", + timestamp: "2026-07-22T18:00:00.000Z", + event: { + type: "attention_required", + provider: "codex", + reason: "finished", + timestamp: "2026-07-22T18:00:00.000Z", + shouldNotify: true, + notification: { + title: "Agent finished", + body: "Done", + data: { + serverId: "server-1", + workspaceId: "workspace-1", + agentId: "agent-1", + reason: "finished", + }, + }, + }, + }, + }, + }, + ])("preserves workspaceId in a $name", ({ message }) => { + const envelope = { type: "session", message }; + + expect(GeneratedWSOutboundMessageSchema.safeParse(envelope)).toEqual({ + success: true, + data: envelope, + }); + }); + it("emits runtime imports with .js extensions", async () => { const generated = await readFile(generatedWSOutboundPath, "utf8"); expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"'); diff --git a/packages/server/src/server/websocket-server.notifications.test.ts b/packages/server/src/server/websocket-server.notifications.test.ts index fb517efe5..f662a044a 100644 --- a/packages/server/src/server/websocket-server.notifications.test.ts +++ b/packages/server/src/server/websocket-server.notifications.test.ts @@ -14,6 +14,8 @@ import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js import type { PushNotificationSender, PushPayload } from "./push/notifications.js"; import type { WorkspaceAutoName } from "./workspace-auto-name.js"; +const WORKSPACE_ID = "workspace-1"; + const wsModuleMock = vi.hoisted(() => { class MockWebSocketServer { readonly handlers = new Map void>(); @@ -86,7 +88,7 @@ function createServer(agentManagerOverrides?: Record) { const agentManager = { subscribe: vi.fn(() => () => {}), setAgentAttentionCallback: vi.fn(), - getAgent: vi.fn(() => null), + getAgent: vi.fn(() => ({ workspaceId: WORKSPACE_ID, pendingPermissions: new Map() })), getLastAssistantMessage: vi.fn(async () => null), getMetricsSnapshot: vi.fn(() => ({ total: 0, @@ -225,6 +227,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { getAgent: vi.fn(() => ({ config: { title: null }, cwd: "/tmp/worktree", + workspaceId: WORKSPACE_ID, pendingPermissions: new Map(), })), getLastAssistantMessage, @@ -242,6 +245,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { body: "Done. Updated README.md and link.", data: { serverId: "srv-test", + workspaceId: WORKSPACE_ID, agentId: "agent-1", reason: "finished", }, @@ -256,6 +260,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => { getAgent: vi.fn(() => ({ config: { title: null }, cwd: "/tmp/worktree", + workspaceId: WORKSPACE_ID, labels: {}, pendingPermissions: new Map(), })), diff --git a/packages/server/src/server/websocket-server.ts b/packages/server/src/server/websocket-server.ts index b9c5b1e14..cec656af2 100644 --- a/packages/server/src/server/websocket-server.ts +++ b/packages/server/src/server/websocket-server.ts @@ -2152,13 +2152,17 @@ export class VoiceAssistantWebSocketServer { const allStates = clientEntries.map((e) => e.state); const nowMs = Date.now(); const agent = this.agentManager.getAgent(params.agentId); + if (!agent?.workspaceId) { + return; + } const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId); const notification = buildAgentAttentionNotificationPayload({ reason: params.reason, serverId: this.serverId, + workspaceId: agent.workspaceId, agentId: params.agentId, assistantMessage, - permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null, + permissionRequest: findLatestPermissionRequest(agent.pendingPermissions), }); const plan = computeNotificationPlan({