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:
@@ -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
|
hidden workspace can overwrite the remembered workspace before Settings or
|
||||||
History returns.
|
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
|
## Params
|
||||||
|
|
||||||
Required dynamic params belong to the matched route.
|
Required dynamic params belong to the matched route.
|
||||||
|
|||||||
@@ -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 { expect, test, type Page } from "./fixtures";
|
||||||
import { gotoAppShell, openSettings } from "./helpers/app";
|
import { gotoAppShell, openSettings } from "./helpers/app";
|
||||||
import {
|
import {
|
||||||
@@ -34,6 +38,7 @@ import { getServerId } from "./helpers/server-id";
|
|||||||
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
import { injectDesktopBridge, waitForDesktopDaemonStartRequest } from "./helpers/desktop-updates";
|
||||||
import { expectAppRoute } from "./helpers/route-assertions";
|
import { expectAppRoute } from "./helpers/route-assertions";
|
||||||
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
import { installDaemonWebSocketGate } from "./helpers/daemon-websocket-gate";
|
||||||
|
import { addOfflineHostAndReload } from "./helpers/hosts";
|
||||||
|
|
||||||
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
const LOADING_WORKSPACE_TEXT_PATTERN = /Loading workspace/i;
|
||||||
type StartupPresentation = "splash" | "app";
|
type StartupPresentation = "splash" | "app";
|
||||||
@@ -158,6 +163,43 @@ async function expectWorkspaceLocation(
|
|||||||
test.describe("Workspace navigation regression", () => {
|
test.describe("Workspace navigation regression", () => {
|
||||||
test.describe.configure({ timeout: 240_000 });
|
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 ({
|
test("keeps one replacement draft after returning from settings and closing the last tab", async ({
|
||||||
page,
|
page,
|
||||||
withWorkspace,
|
withWorkspace,
|
||||||
@@ -420,12 +462,13 @@ test.describe("Workspace navigation regression", () => {
|
|||||||
await expectWorkspaceDeckEntryCount(page, 2);
|
await expectWorkspaceDeckEntryCount(page, 2);
|
||||||
|
|
||||||
await page.evaluate(
|
await page.evaluate(
|
||||||
({ agentId, serverId: targetServerId }) => {
|
({ agentId, serverId: targetServerId, workspaceId }) => {
|
||||||
globalThis.dispatchEvent(
|
globalThis.dispatchEvent(
|
||||||
new CustomEvent("paseo:web-notification-click", {
|
new CustomEvent("paseo:web-notification-click", {
|
||||||
detail: {
|
detail: {
|
||||||
data: {
|
data: {
|
||||||
serverId: targetServerId,
|
serverId: targetServerId,
|
||||||
|
workspaceId,
|
||||||
agentId,
|
agentId,
|
||||||
reason: "finished",
|
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 waitForWorkspaceTabsVisible(page);
|
||||||
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
await expect(page).toHaveURL(buildHostWorkspaceRoute(serverId, secondWorkspace.workspaceId), {
|
||||||
|
|||||||
@@ -147,9 +147,10 @@ function PushNotificationRouter() {
|
|||||||
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
const openNotification = useStableEvent((data: Record<string, unknown> | undefined) => {
|
||||||
const target = resolveNotificationTarget(data);
|
const target = resolveNotificationTarget(data);
|
||||||
const serverId = target.serverId;
|
const serverId = target.serverId;
|
||||||
|
const workspaceId = target.workspaceId;
|
||||||
const agentId = target.agentId;
|
const agentId = target.agentId;
|
||||||
if (serverId && agentId) {
|
if (serverId && workspaceId && agentId) {
|
||||||
navigateToAgent({ serverId, agentId, pin: true });
|
navigateToAgent({ serverId, workspaceId, agentId, pin: true });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useLocalSearchParams, useRouter, type Href } from "expo-router";
|
||||||
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
|
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 { useSessionStore } from "@/stores/session-store";
|
||||||
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
|
import { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
|
||||||
import { buildHostRootRoute } from "@/utils/host-routes";
|
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
|
||||||
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
|
import { toErrorMessage } from "@/utils/error-messages";
|
||||||
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
import { navigateToAgent } from "@/utils/navigate-to-agent";
|
||||||
|
|
||||||
export default function HostAgentReadyRoute() {
|
export default function HostAgentReadyRoute() {
|
||||||
@@ -21,97 +24,128 @@ function HostAgentReadyRouteContent() {
|
|||||||
serverId?: string;
|
serverId?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
}>();
|
}>();
|
||||||
const redirectedRef = useRef(false);
|
const handledNavigationRef = useRef<string | null>(null);
|
||||||
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
const serverId = typeof params.serverId === "string" ? params.serverId : "";
|
||||||
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
const agentId = typeof params.agentId === "string" ? params.agentId : "";
|
||||||
const client = useHostRuntimeClient(serverId);
|
const hosts = useHosts();
|
||||||
const isConnected = useHostRuntimeIsConnected(serverId);
|
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) => {
|
const agentWorkspaceId = useSessionStore((state) => {
|
||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
return state.sessions[serverId]?.agents?.get(agentId)?.workspaceId ?? null;
|
||||||
});
|
});
|
||||||
const hasHydratedWorkspaces = useSessionStore((state) =>
|
const shouldLookupAgent = Boolean(
|
||||||
serverId ? (state.sessions[serverId]?.hasHydratedWorkspaces ?? false) : false,
|
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(() => {
|
useEffect(() => {
|
||||||
if (redirectedRef.current) {
|
let navigationKey: string | null = null;
|
||||||
return;
|
if (resolution.kind === "invalid") {
|
||||||
|
navigationKey = "invalid";
|
||||||
|
} else if (resolution.kind === "resolved") {
|
||||||
|
navigationKey = `workspace:${resolution.workspaceId}`;
|
||||||
|
} else if (resolution.kind === "notFound") {
|
||||||
|
navigationKey = "not-found";
|
||||||
}
|
}
|
||||||
if (!serverId || !agentId) {
|
if (!navigationKey || handledNavigationRef.current === navigationKey) {
|
||||||
redirectedRef.current = true;
|
|
||||||
router.replace("/" as Href);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
handledNavigationRef.current = navigationKey;
|
||||||
|
|
||||||
if (resolvedWorkspaceId) {
|
if (resolution.kind === "resolved") {
|
||||||
redirectedRef.current = true;
|
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
|
||||||
navigateToAgent({
|
return;
|
||||||
serverId,
|
|
||||||
agentId,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}, [agentId, resolvedWorkspaceId, router, serverId]);
|
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
|
||||||
|
}, [agentId, resolution, router, serverId]);
|
||||||
|
|
||||||
useEffect(() => {
|
const handleRetry = useCallback(() => {
|
||||||
if (redirectedRef.current) {
|
if (resolution.kind === "lookupError") {
|
||||||
|
void lookupQuery.refetch();
|
||||||
return;
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (agentWorkspaceId && !hasHydratedWorkspaces) {
|
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
|
||||||
return;
|
}, [router, serverId]);
|
||||||
}
|
|
||||||
if (!client || !isConnected) {
|
|
||||||
redirectedRef.current = true;
|
|
||||||
router.replace(buildHostRootRoute(serverId));
|
|
||||||
}
|
|
||||||
}, [agentWorkspaceId, agentId, client, hasHydratedWorkspaces, isConnected, router, serverId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
if (
|
||||||
if (redirectedRef.current) {
|
resolution.kind === "waitingForHost" ||
|
||||||
return;
|
resolution.kind === "fetchingAgent" ||
|
||||||
}
|
resolution.kind === "lookupError"
|
||||||
if (!serverId || !agentId || !client || !isConnected) {
|
) {
|
||||||
return;
|
// Agent URLs intentionally omit workspaceId. Keep this route mounted while the target host
|
||||||
}
|
// reconnects, then resolve the workspace from the authoritative agent record.
|
||||||
|
return (
|
||||||
let cancelled = false;
|
<AgentRouteResolutionView
|
||||||
void client
|
resolution={resolution}
|
||||||
.fetchAgent({ agentId })
|
hostName={hostName}
|
||||||
.then((result) => {
|
lastHostError={runtimeSnapshot?.lastError ?? null}
|
||||||
if (cancelled || redirectedRef.current) {
|
onRetry={handleRetry}
|
||||||
return;
|
onManageHost={handleManageHost}
|
||||||
}
|
onBack={handleBack}
|
||||||
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]);
|
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ import type { AgentAttachment, SessionOutboundMessage } from "@getpaseo/protocol
|
|||||||
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
import { parseServerInfoStatusPayload } from "@getpaseo/protocol/messages";
|
||||||
import {
|
import {
|
||||||
buildAgentAttentionNotificationPayload,
|
buildAgentAttentionNotificationPayload,
|
||||||
|
type AgentAttentionReason,
|
||||||
type AgentAttentionNotificationPayload,
|
type AgentAttentionNotificationPayload,
|
||||||
type NotificationPermissionRequest,
|
type NotificationPermissionRequest,
|
||||||
} from "@getpaseo/protocol/agent-attention-notification";
|
} from "@getpaseo/protocol/agent-attention-notification";
|
||||||
@@ -154,6 +155,35 @@ const getLatestPermissionRequest = (
|
|||||||
return null;
|
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<
|
type WorkspaceSetupProgressPayload = Extract<
|
||||||
SessionOutboundMessage,
|
SessionOutboundMessage,
|
||||||
{ type: "workspace_setup_progress" }
|
{ type: "workspace_setup_progress" }
|
||||||
@@ -500,16 +530,20 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
|
|||||||
const assistantMessage =
|
const assistantMessage =
|
||||||
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
|
findLatestAssistantMessageText(head) ?? findLatestAssistantMessageText(tail);
|
||||||
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
|
const permissionRequest = getLatestPermissionRequest(session, params.agentId);
|
||||||
|
const workspaceId = session?.agents?.get(params.agentId)?.workspaceId;
|
||||||
|
|
||||||
const notification =
|
const notification = resolveAgentAttentionNotification({
|
||||||
params.notification ??
|
notification: params.notification,
|
||||||
buildAgentAttentionNotificationPayload({
|
reason: params.reason,
|
||||||
reason: params.reason,
|
serverId,
|
||||||
serverId,
|
workspaceId,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
assistantMessage: params.reason === "finished" ? assistantMessage : null,
|
assistantMessage,
|
||||||
permissionRequest: params.reason === "permission" ? permissionRequest : null,
|
permissionRequest,
|
||||||
});
|
});
|
||||||
|
if (!notification) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
void sendOsNotification({
|
void sendOsNotification({
|
||||||
title: notification.title,
|
title: notification.title,
|
||||||
|
|||||||
@@ -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],
|
||||||
|
},
|
||||||
|
}));
|
||||||
@@ -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" });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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" };
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Buffer } from "buffer";
|
import { Buffer } from "buffer";
|
||||||
|
import { buildAgentDeepLinkRoute } from "@getpaseo/protocol/agent-deep-link";
|
||||||
|
|
||||||
type NullableString = string | null | undefined;
|
type NullableString = string | null | undefined;
|
||||||
const BASE64_WORKSPACE_ID_PREFIX = "b64_";
|
const BASE64_WORKSPACE_ID_PREFIX = "b64_";
|
||||||
@@ -389,7 +390,10 @@ export function buildHostAgentDetailRoute(serverId: string, agentId: string, wor
|
|||||||
if (!normalizedServerId || !normalizedAgentId) {
|
if (!normalizedServerId || !normalizedAgentId) {
|
||||||
return "/" as const;
|
return "/" as const;
|
||||||
}
|
}
|
||||||
return `${buildHostRootRoute(normalizedServerId)}/agent/${encodeSegment(normalizedAgentId)}` as const;
|
return buildAgentDeepLinkRoute({
|
||||||
|
serverId: normalizedServerId,
|
||||||
|
agentId: normalizedAgentId,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildHostRootRoute(serverId: string) {
|
export function buildHostRootRoute(serverId: string) {
|
||||||
|
|||||||
@@ -56,13 +56,11 @@ describe("buildNotificationRoute", () => {
|
|||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
workspaceId: "ws-main",
|
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", () => {
|
it("does not treat an incomplete notification as an agent URL", () => {
|
||||||
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe(
|
expect(buildNotificationRoute({ serverId: "srv-1", agentId: "agent-1" })).toBe("/h/srv-1");
|
||||||
"/h/srv-1/agent/agent-1",
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("routes to the workspace terminal tab when workspace and terminal ids are present", () => {
|
it("routes to the workspace terminal tab when workspace and terminal ids are present", () => {
|
||||||
@@ -98,8 +96,9 @@ describe("buildNotificationRoute", () => {
|
|||||||
expect(
|
expect(
|
||||||
buildNotificationRoute({
|
buildNotificationRoute({
|
||||||
serverId: "srv/with/slash",
|
serverId: "srv/with/slash",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
agentId: "agent with space",
|
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");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,9 +1,5 @@
|
|||||||
import type { Href } from "expo-router";
|
import type { Href } from "expo-router";
|
||||||
import {
|
import { buildHostRootRoute, buildHostWorkspaceOpenRoute } from "@/utils/host-routes";
|
||||||
buildHostAgentDetailRoute,
|
|
||||||
buildHostRootRoute,
|
|
||||||
buildHostWorkspaceOpenRoute,
|
|
||||||
} from "@/utils/host-routes";
|
|
||||||
|
|
||||||
type NotificationData = Record<string, unknown> | null | undefined;
|
type NotificationData = Record<string, unknown> | null | undefined;
|
||||||
type NotificationRoute = Extract<Href, string>;
|
type NotificationRoute = Extract<Href, string>;
|
||||||
@@ -33,8 +29,8 @@ export function resolveNotificationTarget(data: NotificationData): {
|
|||||||
|
|
||||||
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
|
export function buildNotificationRoute(data: NotificationData): NotificationRoute {
|
||||||
const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data);
|
const { serverId, agentId, workspaceId, terminalId } = resolveNotificationTarget(data);
|
||||||
if (serverId && agentId) {
|
if (serverId && workspaceId && agentId) {
|
||||||
return buildHostAgentDetailRoute(serverId, agentId);
|
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `agent:${agentId}`);
|
||||||
}
|
}
|
||||||
if (serverId && workspaceId && terminalId) {
|
if (serverId && workspaceId && terminalId) {
|
||||||
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`);
|
return buildHostWorkspaceOpenRoute(serverId, workspaceId, `terminal:${terminalId}`);
|
||||||
|
|||||||
@@ -196,14 +196,20 @@ describe("sendOsNotification", () => {
|
|||||||
|
|
||||||
await sendOsNotification({
|
await sendOsNotification({
|
||||||
title: "Agent finished",
|
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];
|
const clicked = created[0];
|
||||||
expect(clicked.clickListeners).toHaveLength(1);
|
expect(clicked.clickListeners).toHaveLength(1);
|
||||||
clicked.clickListeners[0]?.({} as Event);
|
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 () => {
|
it("returns false when the Notification API is unavailable", async () => {
|
||||||
|
|||||||
@@ -6,10 +6,27 @@ import {
|
|||||||
} from "./agent-attention-notification.js";
|
} from "./agent-attention-notification.js";
|
||||||
|
|
||||||
describe("buildAgentAttentionNotificationPayload", () => {
|
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", () => {
|
it("builds finished notifications from markdown assistant text", () => {
|
||||||
const payload = buildAgentAttentionNotificationPayload({
|
const payload = buildAgentAttentionNotificationPayload({
|
||||||
reason: "finished",
|
reason: "finished",
|
||||||
serverId: "srv-1",
|
serverId: "srv-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
assistantMessage: "**Done**. Updated `README.md` and [link](https://example.com).",
|
assistantMessage: "**Done**. Updated `README.md` and [link](https://example.com).",
|
||||||
});
|
});
|
||||||
@@ -19,6 +36,7 @@ describe("buildAgentAttentionNotificationPayload", () => {
|
|||||||
body: "Done. Updated README.md and link.",
|
body: "Done. Updated README.md and link.",
|
||||||
data: {
|
data: {
|
||||||
serverId: "srv-1",
|
serverId: "srv-1",
|
||||||
|
workspaceId: "workspace-1",
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
reason: "finished",
|
reason: "finished",
|
||||||
},
|
},
|
||||||
@@ -29,6 +47,7 @@ describe("buildAgentAttentionNotificationPayload", () => {
|
|||||||
const payload = buildAgentAttentionNotificationPayload({
|
const payload = buildAgentAttentionNotificationPayload({
|
||||||
reason: "permission",
|
reason: "permission",
|
||||||
serverId: "srv-2",
|
serverId: "srv-2",
|
||||||
|
workspaceId: "workspace-2",
|
||||||
agentId: "agent-2",
|
agentId: "agent-2",
|
||||||
permissionRequest: {
|
permissionRequest: {
|
||||||
id: "perm-1",
|
id: "perm-1",
|
||||||
@@ -45,6 +64,7 @@ describe("buildAgentAttentionNotificationPayload", () => {
|
|||||||
body: "Approve command - Run git push",
|
body: "Approve command - Run git push",
|
||||||
data: {
|
data: {
|
||||||
serverId: "srv-2",
|
serverId: "srv-2",
|
||||||
|
workspaceId: "workspace-2",
|
||||||
agentId: "agent-2",
|
agentId: "agent-2",
|
||||||
reason: "permission",
|
reason: "permission",
|
||||||
},
|
},
|
||||||
@@ -55,6 +75,7 @@ describe("buildAgentAttentionNotificationPayload", () => {
|
|||||||
const payload = buildAgentAttentionNotificationPayload({
|
const payload = buildAgentAttentionNotificationPayload({
|
||||||
reason: "error",
|
reason: "error",
|
||||||
serverId: "srv-3",
|
serverId: "srv-3",
|
||||||
|
workspaceId: "workspace-3",
|
||||||
agentId: "agent-3",
|
agentId: "agent-3",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -63,6 +84,7 @@ describe("buildAgentAttentionNotificationPayload", () => {
|
|||||||
body: "Encountered an error.",
|
body: "Encountered an error.",
|
||||||
data: {
|
data: {
|
||||||
serverId: "srv-3",
|
serverId: "srv-3",
|
||||||
|
workspaceId: "workspace-3",
|
||||||
agentId: "agent-3",
|
agentId: "agent-3",
|
||||||
reason: "error",
|
reason: "error",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export type AgentAttentionReason = "finished" | "error" | "permission";
|
|||||||
export interface AgentAttentionNotificationData {
|
export interface AgentAttentionNotificationData {
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId?: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
reason: AgentAttentionReason;
|
reason: AgentAttentionReason;
|
||||||
}
|
}
|
||||||
@@ -18,6 +19,7 @@ export interface AgentAttentionNotificationPayload {
|
|||||||
interface BuildAgentAttentionNotificationPayloadInput {
|
interface BuildAgentAttentionNotificationPayloadInput {
|
||||||
reason: AgentAttentionReason;
|
reason: AgentAttentionReason;
|
||||||
serverId: string;
|
serverId: string;
|
||||||
|
workspaceId: string;
|
||||||
agentId: string;
|
agentId: string;
|
||||||
assistantMessage?: string | null;
|
assistantMessage?: string | null;
|
||||||
permissionRequest?: NotificationPermissionRequest | null;
|
permissionRequest?: NotificationPermissionRequest | null;
|
||||||
@@ -203,6 +205,7 @@ export function buildAgentAttentionNotificationPayload(
|
|||||||
body,
|
body,
|
||||||
data: {
|
data: {
|
||||||
serverId: input.serverId,
|
serverId: input.serverId,
|
||||||
|
workspaceId: input.workspaceId,
|
||||||
agentId: input.agentId,
|
agentId: input.agentId,
|
||||||
reason: input.reason,
|
reason: input.reason,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -7,18 +7,24 @@ function normalizeSegment(value: string): string {
|
|||||||
return value.trim();
|
return value.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildAgentDeepLink(target: AgentDeepLinkTarget): string {
|
function normalizeAgentDeepLinkTarget(target: AgentDeepLinkTarget): AgentDeepLinkTarget {
|
||||||
const serverId = normalizeSegment(target.serverId);
|
const serverId = normalizeSegment(target.serverId);
|
||||||
const agentId = normalizeSegment(target.agentId);
|
const agentId = normalizeSegment(target.agentId);
|
||||||
if (!serverId || !agentId) {
|
if (!serverId || !agentId) {
|
||||||
throw new Error("Agent deep links require a server ID and agent ID.");
|
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 {
|
export function buildAgentDeepLinkRoute(
|
||||||
const link = new URL(buildAgentDeepLink(target));
|
target: AgentDeepLinkTarget,
|
||||||
return `/h${link.pathname}`;
|
): `/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 {
|
export function parseAgentDeepLink(input: string): AgentDeepLinkTarget | null {
|
||||||
|
|||||||
@@ -658,6 +658,7 @@ export const AgentStreamEventPayloadSchema = z.discriminatedUnion("type", [
|
|||||||
body: z.string(),
|
body: z.string(),
|
||||||
data: z.object({
|
data: z.object({
|
||||||
serverId: z.string(),
|
serverId: z.string(),
|
||||||
|
workspaceId: z.string().optional(),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
reason: z.enum(["finished", "error", "permission"]),
|
reason: z.enum(["finished", "error", "permission"]),
|
||||||
}),
|
}),
|
||||||
@@ -3564,6 +3565,7 @@ export const AgentAttentionRequiredMessageSchema = z.object({
|
|||||||
body: z.string(),
|
body: z.string(),
|
||||||
data: z.object({
|
data: z.object({
|
||||||
serverId: z.string(),
|
serverId: z.string(),
|
||||||
|
workspaceId: z.string().optional(),
|
||||||
agentId: z.string(),
|
agentId: z.string(),
|
||||||
reason: z.enum(["finished", "error", "permission"]),
|
reason: z.enum(["finished", "error", "permission"]),
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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 () => {
|
it("emits runtime imports with .js extensions", async () => {
|
||||||
const generated = await readFile(generatedWSOutboundPath, "utf8");
|
const generated = await readFile(generatedWSOutboundPath, "utf8");
|
||||||
expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"');
|
expect(generated).toContain('from "../../validation/ws-outbound-schema-metadata.js"');
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ import { createProviderSnapshotManagerStub } from "./test-utils/session-stubs.js
|
|||||||
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
|
import type { PushNotificationSender, PushPayload } from "./push/notifications.js";
|
||||||
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
|
import type { WorkspaceAutoName } from "./workspace-auto-name.js";
|
||||||
|
|
||||||
|
const WORKSPACE_ID = "workspace-1";
|
||||||
|
|
||||||
const wsModuleMock = vi.hoisted(() => {
|
const wsModuleMock = vi.hoisted(() => {
|
||||||
class MockWebSocketServer {
|
class MockWebSocketServer {
|
||||||
readonly handlers = new Map<string, (...args: unknown[]) => void>();
|
readonly handlers = new Map<string, (...args: unknown[]) => void>();
|
||||||
@@ -86,7 +88,7 @@ function createServer(agentManagerOverrides?: Record<string, unknown>) {
|
|||||||
const agentManager = {
|
const agentManager = {
|
||||||
subscribe: vi.fn(() => () => {}),
|
subscribe: vi.fn(() => () => {}),
|
||||||
setAgentAttentionCallback: vi.fn(),
|
setAgentAttentionCallback: vi.fn(),
|
||||||
getAgent: vi.fn(() => null),
|
getAgent: vi.fn(() => ({ workspaceId: WORKSPACE_ID, pendingPermissions: new Map() })),
|
||||||
getLastAssistantMessage: vi.fn(async () => null),
|
getLastAssistantMessage: vi.fn(async () => null),
|
||||||
getMetricsSnapshot: vi.fn(() => ({
|
getMetricsSnapshot: vi.fn(() => ({
|
||||||
total: 0,
|
total: 0,
|
||||||
@@ -225,6 +227,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
|||||||
getAgent: vi.fn(() => ({
|
getAgent: vi.fn(() => ({
|
||||||
config: { title: null },
|
config: { title: null },
|
||||||
cwd: "/tmp/worktree",
|
cwd: "/tmp/worktree",
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
})),
|
})),
|
||||||
getLastAssistantMessage,
|
getLastAssistantMessage,
|
||||||
@@ -242,6 +245,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
|||||||
body: "Done. Updated README.md and link.",
|
body: "Done. Updated README.md and link.",
|
||||||
data: {
|
data: {
|
||||||
serverId: "srv-test",
|
serverId: "srv-test",
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
agentId: "agent-1",
|
agentId: "agent-1",
|
||||||
reason: "finished",
|
reason: "finished",
|
||||||
},
|
},
|
||||||
@@ -256,6 +260,7 @@ describe("VoiceAssistantWebSocketServer notification payloads", () => {
|
|||||||
getAgent: vi.fn(() => ({
|
getAgent: vi.fn(() => ({
|
||||||
config: { title: null },
|
config: { title: null },
|
||||||
cwd: "/tmp/worktree",
|
cwd: "/tmp/worktree",
|
||||||
|
workspaceId: WORKSPACE_ID,
|
||||||
labels: {},
|
labels: {},
|
||||||
pendingPermissions: new Map(),
|
pendingPermissions: new Map(),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -2152,13 +2152,17 @@ export class VoiceAssistantWebSocketServer {
|
|||||||
const allStates = clientEntries.map((e) => e.state);
|
const allStates = clientEntries.map((e) => e.state);
|
||||||
const nowMs = Date.now();
|
const nowMs = Date.now();
|
||||||
const agent = this.agentManager.getAgent(params.agentId);
|
const agent = this.agentManager.getAgent(params.agentId);
|
||||||
|
if (!agent?.workspaceId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
|
const assistantMessage = await this.agentManager.getLastAssistantMessage(params.agentId);
|
||||||
const notification = buildAgentAttentionNotificationPayload({
|
const notification = buildAgentAttentionNotificationPayload({
|
||||||
reason: params.reason,
|
reason: params.reason,
|
||||||
serverId: this.serverId,
|
serverId: this.serverId,
|
||||||
|
workspaceId: agent.workspaceId,
|
||||||
agentId: params.agentId,
|
agentId: params.agentId,
|
||||||
assistantMessage,
|
assistantMessage,
|
||||||
permissionRequest: agent ? findLatestPermissionRequest(agent.pendingPermissions) : null,
|
permissionRequest: findLatestPermissionRequest(agent.pendingPermissions),
|
||||||
});
|
});
|
||||||
|
|
||||||
const plan = computeNotificationPlan({
|
const plan = computeNotificationPlan({
|
||||||
|
|||||||
Reference in New Issue
Block a user