Fix notifications opening the wrong workspace (#2331)

* fix(app): open notifications on the right host

Agent notifications previously omitted workspace ownership, so a cold target host was treated as missing and fell back to its empty home route. Carry the authoritative workspace and keep older notifications on a target-host resolver until lookup is conclusive.

* fix(app): separate notification and agent URL routing

Notifications use their authoritative workspace target directly. Stable agent URLs remain server-and-agent targets whose workspace is resolved by the agent route.

* fix(protocol): preserve notification workspace targets

Both attention message variants retain workspaceId through outbound validation so notification clicks receive the authoritative route target.

* test(app): use workspace-scoped notification target

* test(server): give notification agents workspace targets

* test(app): target notification workspace in navigation e2e
This commit is contained in:
Mohamed Boudra
2026-07-22 21:32:28 +02:00
committed by GitHub
parent de69b2a2af
commit 5dfe50ca74
19 changed files with 628 additions and 111 deletions

View File

@@ -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.

View File

@@ -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), {

View File

@@ -147,9 +147,10 @@ function PushNotificationRouter() {
const openNotification = useStableEvent((data: Record<string, unknown> | 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;
}

View File

@@ -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<string | null>(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<AgentRouteLookup>(() => {
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 (
<AgentRouteResolutionView
resolution={resolution}
hostName={hostName}
lastHostError={runtimeSnapshot?.lastError ?? null}
onRetry={handleRetry}
onManageHost={handleManageHost}
onBack={handleBack}
/>
);
}
return null;
}

View File

@@ -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,

View File

@@ -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 (
<View style={styles.emptyState} testID="agent-route-fetching">
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
<View style={styles.textStack}>
<Text style={styles.title}>
{t("agentPanel.unavailable.preparingSession", { serverLabel: hostName })}
</Text>
<Text style={styles.description}>{t("agentPanel.unavailable.showSoon")}</Text>
</View>
</View>
);
}
if (resolution.kind === "lookupError") {
return (
<View style={styles.emptyState} testID="agent-route-lookup-error">
<View style={styles.textStack}>
<Text style={styles.title}>{t("agentPanel.states.failedToLoad")}</Text>
<Text style={styles.error}>{resolution.error}</Text>
</View>
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
<Button size="sm" variant="outline" leftIcon={ArrowLeftToLine} onPress={onBack}>
{t("common.actions.back")}
</Button>
</View>
</View>
);
}
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 (
<View style={styles.emptyState} testID="agent-route-waiting-for-host">
{isConnecting ? (
<ThemedLoadingSpinner size="small" uniProps={foregroundMutedColorMapping} />
) : null}
<View style={styles.textStack}>
<Text style={styles.title}>{title}</Text>
<Text style={styles.description}>
{isConnecting
? t("agentPanel.unavailable.showWhenOnline")
: t("workspace.route.hostStatus", {
status: formatConnectionStatus(resolution.connectionStatus),
})}
</Text>
{lastHostError ? <Text style={styles.error}>{lastHostError}</Text> : null}
</View>
{!isConnecting ? (
<View style={styles.actions}>
<Button size="sm" variant="default" leftIcon={RotateCw} onPress={onRetry}>
{t("common.actions.retry")}
</Button>
<Button size="sm" variant="outline" leftIcon={Settings} onPress={onManageHost}>
{t("workspace.route.manageHost")}
</Button>
</View>
) : null}
</View>
);
}
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],
},
}));

View File

@@ -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" });
});
});

View File

@@ -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<HostRuntimeConnectionStatus, "online">;
}
| { 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" };
}

View File

@@ -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) {

View File

@@ -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");
});
});

View File

@@ -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<string, unknown> | null | undefined;
type NotificationRoute = Extract<Href, string>;
@@ -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}`);

View File

@@ -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 () => {

View File

@@ -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",
},

View File

@@ -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,
},

View File

@@ -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 {

View File

@@ -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"]),
}),

View File

@@ -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"');

View File

@@ -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<string, (...args: unknown[]) => void>();
@@ -86,7 +88,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
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(),
})),

View File

@@ -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({