feat(subagents): retain fleet membership and origin through app navigation

Use the parent ledger to attest membership before the first child snapshot so placement cannot race creation. Bind retained conversations, catalog pushes and actions to their selected origin with separate replica, layout and pending-state owners.
This commit is contained in:
Mohamed Boudra
2026-10-20 15:09:50 +02:00
parent 7c4aa058ff
commit 12890f8ab0
117 changed files with 3885 additions and 1637 deletions
+3 -1
View File
@@ -29,7 +29,9 @@ principal. An ordinary direct B consumer does not become that producer by supply
The optional `server_info.sourceId` reports this producer identity separately from the authenticated admission principal.
Trusted server plugins retain the authority of their runtime; they are not sandboxed by this feature.
A managed child has a qualified `{serverId, agentId}` address on every host. The parent's daemon owns membership and lifecycle intent; a destination's parent pointer is metadata. Workspace comparisons include the server ID, so matching workspace strings on different hosts do not share lifecycle ownership. Preserve the archive policy in [agent lifecycle](agent-lifecycle.md).
A managed child has a qualified `{serverId, agentId}` address on every host. The parent's daemon owns membership and lifecycle intent; a destination's parent pointer is metadata. Workspace comparisons include the server ID, so matching workspace strings on different hosts do not share lifecycle ownership. Preserve the archive policy in [agent lifecycle](agent-lifecycle.md). The first child snapshot carries the owner's ledger projection even while creation is pending, because tab placement runs before creation finishes. Explicit clearing survives list and disk-cache round trips; a parent pointer or label cannot recreate membership on a capable daemon.
Retained app conversations bind their stores, queries, initialization and layout to origin authority as well as destination. Two routes to B therefore keep separate cached evidence and command capabilities. Provider catalog pushes belong to that same destination binding, including facts shown in the parent's track. Route restore remembers an origin ID, then reacquires current authority; persisted namespaces grant no access.
Durable relationships retain a stable authority reference and creation ceiling, never live credentials or execution capabilities. Recovery must resolve current authority through its owner. Derived runtime sessions cannot authorize their own recovery. Execution-scoped admissions and non-owner durable references without a supported authority resolver fail explicitly; do not recover them through the daemon owner's MCP runtime.
@@ -854,7 +854,8 @@ async function revealCompactChanges(page: Page) {
await expect(compactReviewSurface(page).getByTestId("diff-file-0-body")).toBeVisible();
}
async function expectReviewEditorBody(page: Page, body: string) {
await expect(page.getByRole("textbox", { name: "Review comment", exact: true })).toHaveValue(
body,
);
const editor = page.getByRole("textbox", { name: "Review comment", exact: true });
// A viewport resize starts the presentation handoff; exactly one editor must remain accessible.
await expect(editor).toHaveCount(1);
await expect(editor).toHaveValue(body);
}
@@ -1,3 +1,13 @@
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { submitMessage } from "../support/helpers/composer";
import { openSessions, clickSessionRow } from "../support/helpers/archive-tab";
import {
seedFleetSubagents,
connectFleetDirectHost,
openFleetParentTrack,
readFleetAuthorityState,
expectFleetRouteUnavailable,
} from "../support/helpers/subagents";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { expect, test, type Page } from "../support/fixtures";
@@ -147,3 +157,104 @@ test("a preferred chat file link still opens beside its right-pane agent", async
await workspace.cleanup();
}
});
test("a fleet child retains its origin beside direct access through collision, catalog push, reload and revocation", async ({
page,
}) => {
test.setTimeout(180_000);
const fleet = await seedFleetSubagents();
try {
const a = fleet.parent.serverId;
const b = fleet.destination.serverId;
await connectFleetDirectHost(page, fleet);
await openFleetParentTrack(page);
const rows = page.getByTestId(`subagents-track-row-${fleet.remote.id}`);
await expect(rows).toHaveCount(2);
const remote = rows.filter({ hasText: "Fleet child on B" });
await expect(remote).toBeVisible();
await expect(remote.getByTestId(`subagents-track-row-facts-${fleet.remote.id}`)).toHaveText(
"Ten second stream · Low · Load test",
);
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status)
.toBe("online");
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion)
.toBeGreaterThan(0);
const beforePush = await readFleetAuthorityState(page, a, b);
await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath });
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.catalogVersion)
.toBeGreaterThan(beforePush.routed.catalogVersion);
await remote.click();
await expect(page).toHaveURL(
(url) => url.pathname.includes(`/h/${b}/workspace/`) && url.searchParams.get("origin") === a,
);
await submitMessage(page, "ROUTED_FLEET_PROMPT");
await expect
.poll(async () => (await fleet.remote.refresh())?.agent.lastUserMessageAt)
.not.toBeNull();
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
const routedUrl = page.url();
await page.reload();
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await fleet.remote.waitForFinish();
// Reopen direct B through ordinary history, then retain it beside the routed conversation.
await openSessions(page);
await clickSessionRow(page, "Fleet child on B");
await expect(page).toHaveURL(
(url) => url.pathname.includes(`/h/${b}/workspace/`) && !url.searchParams.has("origin"),
);
await openFleetParentTrack(page);
await page
.getByTestId(`subagents-track-row-${fleet.remote.id}`)
.filter({ hasText: "Fleet child on B" })
.click();
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.status)
.toBe("online");
await fleet.disable();
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status)
.not.toBe("online");
const withdrawn = await readFleetAuthorityState(page, a, b);
expect(withdrawn.direct.status).toBe("online");
await fleet.direct.providers.refresh({ cwd: fleet.workspace.repoPath });
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).direct.catalogVersion)
.toBeGreaterThan(withdrawn.direct.catalogVersion);
expect((await readFleetAuthorityState(page, a, b)).routed.catalogVersion).toBe(
withdrawn.routed.catalogVersion,
);
await page.reload();
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
// Direct B remains a separate usable authority; its newer evidence cannot refill A's withdrawn scope.
await page.goto(buildHostAgentDetailRoute(b, fleet.remote.id, fleet.workspace.workspaceId));
await submitMessage(page, "DIRECT_ONLY_AFTER_REVOCATION");
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible();
await page.goto(routedUrl);
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
await expectFleetRouteUnavailable(page, a, b);
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toHaveCount(0);
const beforeRecovery = (await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent
.lastUserMessageAt;
await fleet.enable();
await expect
.poll(async () => (await readFleetAuthorityState(page, a, b)).routed.status)
.toBe("online");
await expect(page).toHaveURL((url) => url.searchParams.get("origin") === a);
await expect(page.getByText("DIRECT_ONLY_AFTER_REVOCATION", { exact: true })).toBeVisible();
await submitMessage(page, "AFTER_REENABLE_THROUGH_A");
await expect
.poll(
async () =>
(await fleet.direct.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt,
)
.not.toBe(beforeRecovery);
expect((await fleet.local.refresh())?.agent.lastUserMessageAt).toBeNull();
} finally {
await fleet.close();
}
});
@@ -1,3 +1,4 @@
import { inspectSessionWireRequest } from "./session-wire";
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
@@ -16,11 +17,7 @@ interface SendAgentMessageRequest {
function readSendRequest(message: WebSocketMessage): SendAgentMessageRequest | null {
if (typeof message !== "string") return null;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: Record<string, unknown>;
};
const request = envelope.type === "session" ? envelope.message : null;
const request = inspectSessionWireRequest(message)?.request;
if (
request?.type !== "send_agent_message_request" ||
typeof request.requestId !== "string" ||
@@ -74,3 +74,12 @@ export async function loadProtocolSchemas(): Promise<typeof import("@getpaseo/pr
).href;
return import(moduleUrl);
}
export async function loadPaseoApiFactory(): Promise<
(typeof import("@getpaseo/client"))["createPaseoApi"]
> {
const moduleUrl = pathToFileURL(
path.resolve(__dirname, "../../../../../packages/client/dist/index.js"),
).href;
return (await import(moduleUrl)).createPaseoApi;
}
@@ -1,3 +1,4 @@
import { inspectSessionWireRequest } from "./session-wire";
import type { Page, WebSocketRoute } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
@@ -71,12 +72,11 @@ function readSessionMessage(message: string | Buffer): ClientRequest | null {
function readClientRequest(message: string | Buffer): ClientRequest | null {
if (typeof message !== "string") return null;
const session = inspectSessionWireRequest(message);
if (session) return session.request;
try {
const envelope = JSON.parse(message) as {
type?: unknown;
message?: ClientRequest;
};
return envelope.type === "session" ? (envelope.message ?? null) : envelope;
const envelope = JSON.parse(message) as ClientRequest;
return envelope.type === "session" ? null : envelope;
} catch {
return null;
}
@@ -0,0 +1,44 @@
/** Inspect an ordinary session request while preserving any owning transport envelopes. */
export function inspectSessionWireRequest(message: string | Buffer): {
request: Record<string, unknown>;
reply(response: unknown): string;
} | null {
const routes: string[] = [];
let raw = typeof message === "string" ? message : message.toString("utf8");
try {
for (;;) {
const envelope = JSON.parse(raw);
if (envelope?.type !== "session" || !envelope.message || typeof envelope.message !== "object")
return null;
const request: Record<string, unknown> = envelope.message;
if (request.type !== "hosts.route.frame.request") {
return {
request,
reply(response) {
let data = JSON.stringify({ type: "session", message: response });
for (const subscriptionId of routes.toReversed()) {
data = JSON.stringify({
type: "session",
message: {
type: "hosts.route.frame",
payload: { subscriptionId, data, binary: false },
},
});
}
return data;
},
};
}
if (
typeof request.routeId !== "string" ||
typeof request.data !== "string" ||
request.binary !== false
)
return null;
routes.push(request.routeId);
raw = request.data;
}
} catch {
return null;
}
}
+189 -32
View File
@@ -1,3 +1,15 @@
import type { HostRuntimeStore } from "@/runtime/host-runtime";
import { addConnectedHostAndReload } from "./hosts";
import { buildSeededHost } from "./daemon-registry";
import { openAgentRoute } from "./mock-agent";
import { openSessions, clickSessionRow } from "./archive-tab";
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { randomUUID } from "node:crypto";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { connectDaemonClient, loadPaseoApiFactory } from "./daemon-client-loader";
import { startIsolatedHostDaemon } from "./isolated-host-daemon";
import { seedWorkspace } from "./seed-client";
import { inspectSessionWireRequest } from "./session-wire";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { expect, type Page } from "@playwright/test";
import { daemonWsRoutePattern } from "./daemon-port";
@@ -251,22 +263,19 @@ export async function rejectNextManagedSubagentArchiveRequest(
await page.routeWebSocket(daemonWsRoutePattern(), (ws) => {
const server = ws.connectToServer();
ws.onMessage((message) => {
const requestId = archiveRequestId(message, subagentId);
if (requestId && !rejected) {
const intercepted = archiveRequest(message, subagentId);
if (intercepted && !rejected) {
rejected = true;
// A daemon-rejected archive RPC: the request never reaches the daemon, and its normal
// correlated error travels back through the real browser client and mutation rollback.
ws.send(
JSON.stringify({
type: "session",
message: {
type: "rpc_error",
payload: {
requestId,
requestType: "archive_agent_request",
error: "Archive rejected for test",
code: "archive_rejected",
},
intercepted.reply({
type: "rpc_error",
payload: {
requestId: intercepted.request.requestId,
requestType: "archive_agent_request",
error: "Archive rejected for test",
code: "archive_rejected",
},
}),
);
@@ -282,28 +291,18 @@ export async function rejectNextManagedSubagentArchiveRequest(
}
function isArchiveRequestForSubagent(message: WebSocketMessage, subagentId: string): boolean {
return archiveRequestId(message, subagentId) !== null;
return archiveRequest(message, subagentId) !== null;
}
function archiveRequestId(message: WebSocketMessage, subagentId: string): string | null {
const rawMessage = typeof message === "string" ? message : message.toString("utf8");
try {
const envelope = JSON.parse(rawMessage) as {
type?: unknown;
message?: { type?: unknown; agentId?: unknown; requestId?: unknown };
};
if (
envelope.type === "session" &&
envelope.message?.type === "archive_agent_request" &&
envelope.message.agentId === subagentId &&
typeof envelope.message.requestId === "string"
) {
return envelope.message.requestId;
}
return null;
} catch {
return null;
}
function archiveRequest(message: WebSocketMessage, subagentId: string) {
const inspected = inspectSessionWireRequest(message);
if (
inspected?.request.type === "archive_agent_request" &&
inspected.request.agentId === subagentId &&
typeof inspected.request.requestId === "string"
)
return inspected;
return null;
}
export async function detachSubagentFromTrack(page: Page, childId: string): Promise<void> {
@@ -320,3 +319,161 @@ export async function detachSubagentFromTrack(page: Page, childId: string): Prom
await expect(detachButton).toBeVisible({ timeout: 30_000 });
await detachButton.click();
}
/** Real equal-ID children in equal-ID workspaces, with both route authorities independently live. */
export async function seedFleetSubagents() {
const workspace = await seedWorkspace({ repoPrefix: "fleet-track-" });
const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`);
const clients: DaemonClient[] = [];
const apis: ReturnType<Awaited<ReturnType<typeof loadPaseoApiFactory>>>[] = [];
const close = async () => {
await Promise.allSettled(apis.map((api) => api.dispose()));
await Promise.allSettled(clients.map((client) => client.close()));
await destination.close();
await workspace.cleanup();
};
try {
const createApi = await loadPaseoApiFactory();
const aClient = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "fleet-origin" });
clients.push(aClient);
const bClient = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "fleet-destination",
port: destination.port,
});
clients.push(bClient);
const origin = createApi(aClient);
const direct = createApi(bClient);
apis.push(origin, direct);
const registered = await origin.hosts.add({ connection: `127.0.0.1:${destination.port}` });
await expect
.poll(
async () =>
(await origin.hosts.list()).hosts.find(
(host) => host.registrationId === registered.registrationId,
)?.status,
)
.toBe("online");
const destinationWorkspace = await direct.workspaces.create({
workspaceId: workspace.workspaceId,
source: { kind: "directory", path: workspace.repoPath },
});
expect(destinationWorkspace.id).toBe(workspace.workspaceId);
const config = { provider: "mock/ten-second-stream", modeId: "load-test" };
const parent = await origin.workspaces
.ref(workspace.workspaceId)
.agents.create({ title: "Fleet parent", config });
const agentId = randomUUID();
const local = await parent.subagents.create({ agentId, title: "Fleet child on A", config });
const remote = await parent.subagents.create({
agentId,
serverId: destination.serverId,
workspaceId: destinationWorkspace.id,
title: "Fleet child on B",
config,
});
return {
workspace,
destination,
origin,
direct,
parent,
local,
remote,
disable: () => origin.hosts.update(registered.registrationId, { enabled: false }),
enable: () => origin.hosts.update(registered.registrationId, { enabled: true }),
close,
};
} catch (error) {
await close();
throw error;
}
}
export async function connectFleetDirectHost(
page: Page,
fleet: Awaited<ReturnType<typeof seedFleetSubagents>>,
) {
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await addConnectedHostAndReload(page, {
serverId: fleet.destination.serverId,
port: fleet.destination.port,
label: "Fleet B",
});
await page.evaluate(
(host) => localStorage.setItem("@paseo:e2e-extra-hosts", JSON.stringify([host])),
buildSeededHost({
serverId: fleet.destination.serverId,
endpoint: `127.0.0.1:${fleet.destination.port}`,
nowIso: new Date().toISOString(),
label: "Fleet B",
}),
);
await page.goto(
buildHostAgentDetailRoute(
fleet.destination.serverId,
fleet.remote.id,
fleet.workspace.workspaceId,
),
);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
}
export async function openFleetParentTrack(page: Page) {
await openSessions(page);
await clickSessionRow(page, "Fleet parent");
await openSubagentsTrack(page);
}
export async function readFleetAuthorityState(
page: Page,
originServerId: string,
serverId: string,
) {
return page.evaluate(
({ originServerId: sourceId, serverId: destinationId }) => {
const runtime = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore;
const read = (origin: string) => {
const binding = runtime
.getAuthorityBindings()
.find(
(candidate) =>
candidate.reference.originServerId === origin &&
candidate.reference.serverId === destinationId,
);
const queries =
binding?.queries
.getQueryCache()
.findAll({ queryKey: ["providersSnapshot", destinationId] }) ?? [];
return {
status: binding?.runtime.getSnapshot(destinationId)?.connectionStatus ?? "absent",
catalogVersion: Math.max(0, ...queries.map((query) => query.state.dataUpdatedAt)),
};
};
return { direct: read(destinationId), routed: read(sourceId) };
},
{ originServerId, serverId },
);
}
export async function expectFleetRouteUnavailable(
page: Page,
originServerId: string,
serverId: string,
) {
await expect
.poll(async () => (await readFleetAuthorityState(page, originServerId, serverId)).routed.status)
.toMatch(/^(offline|error)$/);
const unavailable = page.getByText(/^(Cannot reach .+|.+ is offline)$/).filter({ visible: true });
const composer = page.getByRole("textbox", { name: "Message agent..." });
await expect(unavailable.or(composer)).toBeVisible();
if (await composer.isVisible()) {
if (await composer.isEditable()) {
await composer.fill("MUST_NOT_USE_DIRECT_AUTHORITY");
await expect(page.getByRole("button", { name: "Send message", exact: true })).toBeDisabled();
} else await expect(composer).toBeDisabled();
} else
await expect(page.getByRole("button", { name: "Send message", exact: true })).toHaveCount(0);
}
+2
View File
@@ -1,3 +1,4 @@
import { HostAuthoritySessions } from "@/runtime/host-authority-sessions";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import { useIsExplorerSidebarOpen } from "@/workspace-tabs/explorer-sidebar";
@@ -313,6 +314,7 @@ function HostSessionManager() {
return (
<>
<HostAuthoritySessions />
{hosts.map((daemon) => (
<ManagedDaemonSession key={daemon.serverId} daemon={daemon} />
))}
@@ -19,13 +19,20 @@ export default function HostRouteLayout() {
}
function KnownHostRoute() {
const params = useLocalSearchParams<{ serverId?: string | string[] }>();
const params = useLocalSearchParams<{
serverId?: string | string[];
origin?: string | string[];
}>();
const hosts = useHosts();
const hostRegistryStatus = useHostRegistryStatus();
const bootstrapState = useHostRuntimeBootstrapState();
const routeServerId = typeof params.serverId === "string" ? params.serverId : null;
const startupRoute = resolveStartupRoute({
route: { kind: "host", serverId: routeServerId },
route: {
kind: "host",
serverId: routeServerId,
...(typeof params.origin === "string" ? { originServerId: params.origin } : {}),
},
startupBlocker: bootstrapState.startupBlocker,
hostRegistryStatus,
hosts,
@@ -1,3 +1,4 @@
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
import { useCallback, useEffect, useMemo, useRef } from "react";
import { useLocalSearchParams, useRouter, type Href } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
@@ -5,21 +6,34 @@ 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 { getHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import { buildHostRootRoute, buildSettingsHostRoute } from "@/utils/host-routes";
import { useHostRuntimeStore, useHostRuntimeSnapshot, useHosts } from "@/runtime/host-runtime";
import {
buildHostRootRoute,
buildSettingsHostRoute,
withHostRouteAuthority,
} from "@/utils/host-routes";
import { toErrorMessage } from "@/utils/error-messages";
import { navigateToAgent } from "@/utils/navigate-to-agent";
export default function HostAgentReadyRoute() {
const { serverId, origin } = useLocalSearchParams<{ serverId?: string; origin?: string }>();
const reference = useMemo(
() => ({ originServerId: origin ?? serverId ?? "", serverId: serverId ?? "" }),
[origin, serverId],
);
if (!serverId) return null;
return (
<HostRouteBootstrapBoundary>
<HostAgentReadyRouteContent />
<HostAuthorityRoute reference={reference}>
<HostAgentReadyRouteContent />
</HostAuthorityRoute>
</HostRouteBootstrapBoundary>
);
}
function HostAgentReadyRouteContent() {
const router = useRouter();
const runtime = useHostRuntimeStore();
const params = useLocalSearchParams<{
serverId?: string;
agentId?: string;
@@ -40,10 +54,8 @@ function HostAgentReadyRouteContent() {
});
useEffect(() => {
if (!serverId || !agentId || agentWorkspaceId) return;
void getHostRuntimeStore()
.prepareAgentRoute(serverId, agentId)
.catch(() => undefined);
}, [agentId, agentWorkspaceId, serverId]);
void runtime.prepareAgentRoute(serverId, agentId).catch(() => undefined);
}, [agentId, agentWorkspaceId, serverId, runtime]);
const shouldLookupAgent = Boolean(
serverId && agentId && client && connectionStatus === "online" && !agentWorkspaceId,
);
@@ -106,11 +118,23 @@ function HostAgentReadyRouteContent() {
handledNavigationRef.current = navigationKey;
if (resolution.kind === "resolved") {
navigateToAgent({ serverId, agentId, workspaceId: resolution.workspaceId });
navigateToAgent({
serverId,
agentId,
workspaceId: resolution.workspaceId,
authority: runtime.authorityFor(serverId),
});
return;
}
router.replace(resolution.kind === "invalid" ? ("/" as Href) : buildHostRootRoute(serverId));
}, [agentId, resolution, router, serverId]);
router.replace(
resolution.kind === "invalid"
? ("/" as Href)
: (withHostRouteAuthority(
buildHostRootRoute(serverId),
runtime.authorityFor(serverId),
) as Href),
);
}, [agentId, resolution, router, serverId, runtime]);
const handleRetry = useCallback(() => {
if (resolution.kind === "lookupError") {
@@ -118,21 +142,28 @@ function HostAgentReadyRouteContent() {
return;
}
if (serverId) {
void getHostRuntimeStore().runProbeCycleNow(serverId);
void runtime.runProbeCycleNow(serverId);
}
}, [lookupQuery, resolution.kind, serverId]);
}, [lookupQuery, resolution.kind, serverId, runtime]);
const handleManageHost = useCallback(() => {
if (serverId) {
router.push(buildSettingsHostRoute(serverId));
router.push(buildSettingsHostRoute(runtime.authorityFor(serverId).originServerId));
}
}, [router, serverId]);
}, [router, serverId, runtime]);
const handleBack = useCallback(() => {
if (router.canGoBack()) {
router.back();
return;
}
router.replace(serverId ? buildHostRootRoute(serverId) : ("/" as Href));
}, [router, serverId]);
router.replace(
serverId
? (withHostRouteAuthority(
buildHostRootRoute(serverId),
runtime.authorityFor(serverId),
) as Href)
: ("/" as Href),
);
}, [router, serverId, runtime]);
if (
resolution.kind === "waitingForHost" ||
+31 -4
View File
@@ -1,4 +1,10 @@
import { Redirect } from "expo-router";
import { useMemo } from "react";
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
import {
workspaceSelectionAuthority,
type ActiveWorkspaceSelection,
} from "@/stores/last-workspace-selection";
import { Redirect, useLocalSearchParams } from "expo-router";
import { useHostRouteServerId } from "@/navigation/host-route-context";
import {
resolveHostIndexRoute,
@@ -13,10 +19,31 @@ import {
export default function HostIndexRoute() {
const serverId = useHostRouteServerId();
const workspaceSelection = useLastWorkspaceSelection();
const remembered = useLastWorkspaceSelection();
const { origin } = useLocalSearchParams<{ origin?: string }>();
const selection = useMemo(
() =>
remembered?.serverId === serverId
? { ...remembered, ...(origin ? { authority: { originServerId: origin } } : {}) }
: null,
[remembered, serverId, origin],
);
if (!selection) return <HostIndexContent workspaceSelection={null} />;
return (
<HostAuthorityRoute reference={workspaceSelectionAuthority(selection)}>
<HostIndexContent workspaceSelection={selection} />
</HostAuthorityRoute>
);
}
function HostIndexContent({
workspaceSelection,
}: {
workspaceSelection: ActiveWorkspaceSelection | null;
}) {
const serverId = useHostRouteServerId();
const isWorkspaceSelectionLoaded = useIsLastWorkspaceSelectionHydrated();
const workspaceSelectionWorkspaceId =
workspaceSelection?.serverId === serverId ? workspaceSelection.workspaceId : null;
const workspaceSelectionWorkspaceId = workspaceSelection?.workspaceId ?? null;
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceSelectionExists = useWorkspaceExists(serverId, workspaceSelectionWorkspaceId);
@@ -1,7 +1,9 @@
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection";
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useNavigation } from "expo-router/react-navigation";
import { StyleSheet, View } from "react-native";
import { useGlobalSearchParams, useLocalSearchParams, useRootNavigationState } from "expo-router";
import { useGlobalSearchParams, useRootNavigationState } from "expo-router";
import { HostRouteBootstrapBoundary } from "@/components/host-route-bootstrap-boundary";
import { RetainedPanel } from "@/components/retained-panel";
import {
@@ -24,16 +26,12 @@ import {
resolveWorkspaceDeckEntries,
shouldKeepWorkspaceDeckEntryMounted,
} from "@/screens/workspace/workspace-deck-retention";
import {
decodeWorkspaceIdFromPathSegment,
parseWorkspaceOpenIntent,
type WorkspaceOpenIntent,
} from "@/utils/host-routes";
import { parseWorkspaceOpenIntent, type WorkspaceOpenIntent } from "@/utils/host-routes";
import {
replaceBrowserRouteWithCanonicalHostWorkspaceRoute,
stripHostWorkspaceRouteEchoSearchFromBrowserUrlAfterCommit,
} from "@/utils/host-route-browser";
import { prepareWorkspaceTab } from "@/utils/workspace-navigation";
import { useWorkspaceTabNavigation } from "@/workspace-tabs/navigation";
import { isNative, isWeb } from "@/constants/platform";
import { RenderProfile } from "@/utils/render-profiler";
@@ -94,24 +92,34 @@ export default function HostWorkspaceIndexRoute() {
}
function HostWorkspaceRouteContent() {
const { open } = useGlobalSearchParams<{ open?: string | string[] }>();
return (
<WorkspaceDeck
recoveryRequested={parseWorkspaceOpenIntent(getParamValue(open))?.kind === "agent"}
/>
);
}
function WorkspaceOpenIntentBoundary({
selection,
active,
children,
}: {
selection: ActiveWorkspaceSelection;
active: boolean;
children: React.ReactNode;
}) {
const { prepareWorkspaceTab } = useWorkspaceTabNavigation();
const navigation = useNavigation();
const rootNavigationState = useRootNavigationState();
const hasHydratedWorkspaceLayoutStore = useWorkspaceLayoutStoreHydrated();
const consumedIntentRef = useRef<string | null>(null);
const [intentConsumed, setIntentConsumed] = useState(false);
const params = useLocalSearchParams<{
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const globalParams = useGlobalSearchParams<{
open?: string | string[];
}>();
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
const workspaceId = workspaceValue
? (decodeWorkspaceIdFromPathSegment(workspaceValue) ?? "")
: "";
const openValue = getParamValue(globalParams.open);
const { serverId, workspaceId } = selection;
const openValue = active ? getParamValue(globalParams.open) : "";
const hasHydratedWorkspaces = useHasHydratedWorkspaces(serverId);
const workspaceExists = useWorkspaceExists(serverId, workspaceId);
const openIntent = useMemo(() => parseWorkspaceOpenIntent(openValue), [openValue]);
@@ -178,6 +186,7 @@ function HostWorkspaceRouteContent() {
openIntent,
openValue,
rootNavigationState?.key,
prepareWorkspaceTab,
serverId,
workspaceId,
]);
@@ -190,7 +199,7 @@ function HostWorkspaceRouteContent() {
return null;
}
return <WorkspaceDeck recoveryRequested={isAgentOpenIntent} />;
return children;
}
function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
@@ -274,7 +283,20 @@ function WorkspaceDeck({ recoveryRequested }: { recoveryRequested: boolean }) {
);
}
function WorkspaceDeckEntry({
function WorkspaceDeckEntry(props: {
selection: ActiveWorkspaceSelection;
active: boolean;
recoveryRequested: boolean;
onUnmountInactive: (selection: ActiveWorkspaceSelection) => void;
}) {
return (
<HostAuthorityRoute reference={workspaceSelectionAuthority(props.selection)}>
<WorkspaceDeckEntryContent {...props} />
</HostAuthorityRoute>
);
}
function WorkspaceDeckEntryContent({
selection,
active,
recoveryRequested,
@@ -308,12 +330,14 @@ function WorkspaceDeckEntry({
active={active}
testID={`workspace-deck-entry-${selection.serverId}:${selection.workspaceId}`}
>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={active}
recoveryRequested={active && recoveryRequested}
/>
<WorkspaceOpenIntentBoundary selection={selection} active={active}>
<WorkspaceScreen
serverId={selection.serverId}
workspaceId={selection.workspaceId}
isRouteFocused={active}
recoveryRequested={active && recoveryRequested}
/>
</WorkspaceOpenIntentBoundary>
</RetainedPanel>
);
}
+8 -4
View File
@@ -39,10 +39,14 @@ export default function Index() {
hosts,
anyOnlineHostServerId,
workspaceSelection,
workspaceSelectionStatus: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
workspaceExists: workspaceSelectionExists,
}),
// COMPAT(unboundStartupSelection): added in v0.12, remove after 2027-04-20.
// Qualified restore reacquires its origin at the host boundary before proving absence.
workspaceSelectionStatus: workspaceSelection?.authority
? "unknown"
: resolveWorkspaceSelectionStatus({
hasHydratedWorkspaces: hasHydratedWorkspaceSelectionHost,
workspaceExists: workspaceSelectionExists,
}),
isWorkspaceSelectionLoaded,
hasGivenUpWaitingForHost: bootstrapState.hasGivenUpWaitingForHost,
});
@@ -50,7 +50,8 @@ function createAgent(overrides: Partial<Agent> = {}): Agent {
options: [{ id: "high", label: "High" }],
},
],
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
...overrides,
turn: overrides.turn ?? { phase: "idle", cancellationRequestId: null },
@@ -5,9 +5,9 @@ import { useToast } from "@/contexts/toast-context";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { RewindMode } from "./use-rewind-capabilities";
import { useRewindComposerRestore } from "./composer-restore";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStoreApi } from "@/stores/session-store";
import { shouldRestoreComposerForRewindMode } from "./rewind-mode";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useHostRuntimeStore } from "@/runtime/host-runtime";
interface UseRewindAgentMutationInput {
serverId?: string;
@@ -26,6 +26,8 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
isPending: boolean;
} {
const toast = useToast();
const sessionStore = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const { t } = useTranslation();
const composerRestore = useRewindComposerRestore();
const { isPending, mutateAsync } = useMutation({
@@ -36,12 +38,10 @@ export function useRewindAgentMutation(input: UseRewindAgentMutationInput): {
await input.client.rewindAgent(input.agentId, input.messageId, mode);
if (mode !== "files") {
const cursor = input.serverId
? useSessionStore
.getState()
.sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
? sessionStore.getState().sessions[input.serverId]?.agentTimelineCursor.get(input.agentId)
: undefined;
if (!input.serverId) throw new Error(t("common.errors.daemonClientUnavailable"));
await getHostRuntimeStore().fetchAgentTimeline(input.serverId, input.agentId, {
await runtime.fetchAgentTimeline(input.serverId, input.agentId, {
direction: "tail",
projection: "projected",
...(cursor ? { cursor: { epoch: cursor.epoch, seq: cursor.endSeq } } : {}),
+14 -4
View File
@@ -5,9 +5,13 @@ import {
resolveComposerAttachmentSubmitFormat,
splitComposerAttachmentsForSubmit,
} from "@/composer/attachments/submit";
import { isActiveCreateFlowForDraft, useCreateFlowStore } from "@/stores/create-flow-store";
import {
isActiveCreateFlowForDraft,
useCreateFlowStore,
useCreateFlowStoreApi,
} from "@/stores/create-flow-store";
import { handoffCreatedAgentMessageSubmission } from "@/composer/submission/writer";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStoreApi } from "@/stores/session-store";
import {
createUserMessage,
type StreamItem,
@@ -122,6 +126,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
onCreateError,
}: UseDraftAgentCreateFlowOptions<TDraftAgent, TCreateResult>) {
const { t } = useTranslation();
const sessionStore = useSessionStoreApi();
const flowStore = useCreateFlowStoreApi();
const [localMachine, dispatch] = useReducer(
reducer<TDraftAgent>,
initialAttempt,
@@ -235,6 +241,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
images: attempt.images,
attachments: attempt.attachments,
}),
sessionStore,
);
markPendingCreateLifecycle({ draftId, lifecycle: "sent" });
}
@@ -255,6 +262,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
},
[
createRequest,
sessionStore,
draftId,
getPendingServerId,
markPendingCreateLifecycle,
@@ -268,7 +276,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
const handleCreateFromInput = useCallback(
async ({ text, attachments, cwd }: SubmitContext) => {
const existing = useCreateFlowStore.getState().pendingByDraftId[draftId];
const existing = flowStore.getState().pendingByDraftId[draftId];
if (
isSubmitting ||
isActiveCreateFlowForDraft({ pending: existing, serverId: getPendingServerId(), draftId })
@@ -285,7 +293,7 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
throw error;
}
const supportsForgeSearch =
useSessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
sessionStore.getState().sessions[pendingServerId]?.serverInfo?.features?.forgeSearch ===
true;
const wirePayload = splitComposerAttachmentsForSubmit(attachments, {
format: resolveComposerAttachmentSubmitFormat({
@@ -342,6 +350,8 @@ export function useDraftAgentCreateFlow<TDraftAgent, TCreateResult>({
draftId,
getPendingServerId,
isSubmitting,
flowStore,
sessionStore,
onCreateStart,
runCreateAttempt,
setPendingCreateAttempt,
@@ -243,7 +243,8 @@ function buildDraftAgentSnapshot(input: {
model,
features: composerState.agentControls.features,
thinkingOptionId,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
};
}
+31 -20
View File
@@ -6,7 +6,7 @@ import {
import type { ComposerTextSource } from "./text-source";
import { createStore, type StoreApi } from "zustand/vanilla";
import { useStore } from "zustand";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useHostRuntimeStore } from "@/runtime/host-runtime";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import {
View,
@@ -55,7 +55,11 @@ import {
} from "@/composer/agent-controls";
import { ContextWindowMeter } from "@/components/context-window-meter";
import { useImageAttachmentPicker } from "@/hooks/use-image-attachment-picker";
import { selectAgentTurnPresentation, useSessionStore } from "@/stores/session-store";
import {
selectAgentTurnPresentation,
useSessionStore,
useSessionStoreApi,
} from "@/stores/session-store";
import { useFilePicker } from "@/hooks/use-file-picker";
import { useFileDrop } from "@/components/file-drop/use-file-drop";
import type { DroppedItem } from "@/components/file-drop/types";
@@ -1264,6 +1268,8 @@ function ComposerContentImpl({
const mode = resolveComposerInputMode(inputMode);
const { t } = useTranslation();
const buttonIconSize = resolveComposerButtonIconSize();
const sessionStore = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const client = useHostRuntimeClient(serverId);
const isConnected = useHostRuntimeIsConnected(serverId);
const agentDirectoryStatus = useHostRuntimeAgentDirectoryStatus(serverId);
@@ -1560,19 +1566,27 @@ function ComposerContentImpl({
supportsForgeAttachments: supportsForgeSearch,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
submission: createMessageSubmissionWriter(serverId, sessionStore),
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
sessionStore.getState().sessions[serverId],
targetAgentId,
).turnId ?? undefined)
: undefined,
});
onAttentionPromptSend?.();
};
}, [appSettings.sendBehavior, client, onAttentionPromptSend, serverId, supportsForgeSearch, t]);
}, [
appSettings.sendBehavior,
client,
onAttentionPromptSend,
serverId,
sessionStore,
supportsForgeSearch,
t,
]);
useEffect(() => {
onSubmitMessageRef.current = onSubmitMessage;
@@ -1603,10 +1617,10 @@ function ComposerContentImpl({
const queueWriter = useMemo<QueueWriter>(
() => ({
read: (id) => useSessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
read: (id) => sessionStore.getState().sessions[serverId]?.queuedMessages?.get(id) ?? [],
write: (updater) => setQueuedMessages(serverId, updater),
}),
[serverId, setQueuedMessages],
[serverId, sessionStore, setQueuedMessages],
);
const queueMessage = useCallback(
@@ -1875,26 +1889,23 @@ function ComposerContentImpl({
const handleCancelAgent = useCallback(() => {
const targetAgentId = agentIdRef.current;
const cancellation = cancelComposerAgent({
client,
client: client
? { cancelAgent: (id) => runtime.cancelAgent({ serverId, agentId: id }) }
: null,
agentId: targetAgentId,
isAgentRunning,
isCancellingAgent,
isConnected,
});
if (!cancellation) return;
const requestId = getHostRuntimeStore().beginAgentCancellation(serverId, targetAgentId);
void cancellation
.catch((error) => {
const message = resolveErrorMessage(error);
if (message && message.trim().length > 0) {
toastErrorRef.current(message);
}
})
.finally(() => {
getHostRuntimeStore().settleAgentCancellation(serverId, targetAgentId, requestId);
});
void cancellation.catch((error) => {
const message = resolveErrorMessage(error);
if (message && message.trim().length > 0) {
toastErrorRef.current(message);
}
});
messageInputRef.current?.focus();
}, [client, isAgentRunning, isCancellingAgent, isConnected, serverId]);
}, [client, runtime, isAgentRunning, isCancellingAgent, isConnected, serverId]);
const focusMessageInputForKeyboardAction = useCallback(() => {
focusMessageInputWithPlatformStrategy(messageInputRef);
@@ -1,6 +1,6 @@
import { afterEach, expect, test } from "vitest";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore } from "@/stores/session-store";
import { createSessionStore, useSessionStore } from "@/stores/session-store";
import type { UserMessageItem } from "@/types/stream";
import { createMessageSubmissionWriter } from "./writer";
@@ -40,10 +40,45 @@ afterEach(() => {
test("tracks submissions when canonical prompts are supported", () => {
initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: true });
createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID).begin(
createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID, useSessionStore).begin(
"agent-1",
submittedMessage("canonical-only-message"),
);
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual(["canonical-only-message"]);
});
test.each([true, false])(
"writes and rejects only in its captured authority store (tracked=%s)",
(tracked) => {
initializeSession(CANONICAL_ONLY_SERVER_ID, { canonicalSubmittedPrompts: tracked });
const scoped = createSessionStore();
scoped.getState().initializeSession(CANONICAL_ONLY_SERVER_ID, null);
scoped.getState().updateSessionServerInfo(CANONICAL_ONLY_SERVER_ID, {
serverId: CANONICAL_ONLY_SERVER_ID,
hostname: null,
version: "0.12.0",
features: { canonicalSubmittedPrompts: tracked },
});
const writer = createMessageSubmissionWriter(CANONICAL_ONLY_SERVER_ID, scoped);
writer.begin("agent-1", submittedMessage("scoped-message"));
const session = scoped.getState().sessions[CANONICAL_ONLY_SERVER_ID];
if (tracked)
expect(
session.messageSubmissions.get("agent-1")?.map((submission) => submission.clientMessageId),
).toEqual(["scoped-message"]);
else
expect(
session.agentStreamTail
.get("agent-1")
?.map((item) => (item.kind === "user_message" ? item.clientMessageId : null)),
).toEqual(["scoped-message"]);
expect(submissionIds(CANONICAL_ONLY_SERVER_ID)).toEqual([]);
expect(
useSessionStore
.getState()
.sessions[CANONICAL_ONLY_SERVER_ID]?.agentStreamTail.get("agent-1") ?? [],
).toEqual([]);
expect(writer.reject("agent-1", "scoped-message")).toBe("rejected");
},
);
+29 -17
View File
@@ -1,31 +1,37 @@
import type { MessageSubmissionWriter } from "@/composer/actions";
import { useSessionStore } from "@/stores/session-store";
import type { SessionStoreApi } from "@/stores/session-store";
import {
appendSubmittedUserMessage,
removeSubmittedUserMessage,
type UserMessageItem,
} from "@/types/stream";
function appendUntrackedSubmission(serverId: string, agentId: string, message: UserMessageItem) {
const session = useSessionStore.getState().sessions[serverId];
function appendUntrackedSubmission(
store: SessionStoreApi,
serverId: string,
agentId: string,
message: UserMessageItem,
) {
const session = store.getState().sessions[serverId];
if (!session) return;
const tail = session.agentStreamTail.get(agentId) ?? [];
const head = session.agentStreamHead.get(agentId) ?? [];
useSessionStore
store
.getState()
.setAgentStreamState(serverId, agentId, appendSubmittedUserMessage({ tail, head, message }));
}
function removeUntrackedSubmission(
store: SessionStoreApi,
serverId: string,
agentId: string,
clientMessageId: string,
): void {
const session = useSessionStore.getState().sessions[serverId];
const session = store.getState().sessions[serverId];
if (!session) return;
const tail = session.agentStreamTail.get(agentId) ?? [];
const head = session.agentStreamHead.get(agentId) ?? [];
useSessionStore
store
.getState()
.setAgentStreamState(
serverId,
@@ -34,32 +40,37 @@ function removeUntrackedSubmission(
);
}
function createUntrackedMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
function createUntrackedMessageSubmissionWriter(
serverId: string,
store: SessionStoreApi,
): MessageSubmissionWriter {
return {
begin: (agentId, message) => appendUntrackedSubmission(serverId, agentId, message),
begin: (agentId, message) => appendUntrackedSubmission(store, serverId, agentId, message),
accept: () => undefined,
reject: (agentId, clientMessageId) => {
removeUntrackedSubmission(serverId, agentId, clientMessageId);
removeUntrackedSubmission(store, serverId, agentId, clientMessageId);
return "rejected";
},
};
}
export function createMessageSubmissionWriter(serverId: string): MessageSubmissionWriter {
export function createMessageSubmissionWriter(
serverId: string,
store: SessionStoreApi,
): MessageSubmissionWriter {
const supportsTrackedMessageSubmissions =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features
?.canonicalSubmittedPrompts === true;
store.getState().sessions[serverId]?.serverInfo?.features?.canonicalSubmittedPrompts === true;
if (!supportsTrackedMessageSubmissions) {
// COMPAT(canonicalSubmittedPrompts): added in v0.2.6; remove the gate after 2027-01-31 once daemon floor >= v0.2.6.
return createUntrackedMessageSubmissionWriter(serverId);
return createUntrackedMessageSubmissionWriter(serverId, store);
}
return {
begin: (agentId, message) =>
useSessionStore.getState().beginAgentMessageSubmission(serverId, agentId, message),
store.getState().beginAgentMessageSubmission(serverId, agentId, message),
accept: (agentId, clientMessageId) =>
useSessionStore.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId),
store.getState().acceptAgentMessageSubmission(serverId, agentId, clientMessageId),
reject: (agentId, clientMessageId) =>
useSessionStore.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
store.getState().rejectAgentMessageSubmission(serverId, agentId, clientMessageId),
};
}
@@ -67,6 +78,7 @@ export function handoffCreatedAgentMessageSubmission(
serverId: string,
agentId: string,
message: UserMessageItem,
store: SessionStoreApi,
): boolean {
return useSessionStore.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
return store.getState().handoffCreatedAgentUserMessage(serverId, agentId, message);
}
+15 -6
View File
@@ -1,7 +1,10 @@
import { useRef, ReactNode, useCallback, useEffect } from "react";
import { Buffer } from "buffer";
import { AppState } from "react-native";
import { observeOpenWorkspaceAgentIds } from "@/stores/workspace-layout-store";
import {
observeOpenWorkspaceAgentIds,
useWorkspaceLayoutStoreApi,
} from "@/stores/workspace-layout-store";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useClientActivity } from "@/hooks/use-client-activity";
@@ -45,7 +48,7 @@ import { useToast } from "@/contexts/toast-context";
import { toErrorMessage } from "@/utils/error-messages";
import { showProviderNoticeToast } from "@/utils/provider-notice-toast";
import { applyCheckoutStatusUpdateFromEvent } from "@/git/checkout-status-cache";
import { resyncProviderSubagents, useProviderSubagentStore } from "@/subagents/provider-store";
import { resyncProviderSubagents, useProviderSubagentStoreApi } from "@/subagents/provider-store";
// Re-export types from session-store and draft-store for backward compatibility
export type { DraftInput } from "@/stores/draft-store";
@@ -198,6 +201,8 @@ export function SessionProvider(props: SessionProviderProps) {
function SessionProviderInternal({ children, serverId, client }: SessionProviderClientProps) {
const sessionStore = useSessionStoreApi();
const providerStore = useProviderSubagentStoreApi();
const layoutStore = useWorkspaceLayoutStoreApi();
const runtime = useHostRuntimeStore();
const { t } = useTranslation();
const voiceRuntime = useVoiceRuntimeOptional();
@@ -476,8 +481,10 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
viewedTimelineSyncRef.current = sync;
setViewedTimelineSync(serverId, sync);
sync.setActive(getIsAppVisible(appStateRef.current));
const stopObservingOpenChats = observeOpenWorkspaceAgentIds(serverId, (agentIds) =>
sync.replaceOpenTabAgentIds(agentIds),
const stopObservingOpenChats = observeOpenWorkspaceAgentIds(
serverId,
(agentIds) => sync.replaceOpenTabAgentIds(agentIds),
layoutStore,
);
return () => {
@@ -494,6 +501,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
sessionStore,
runtime,
sessionStore.initializations,
layoutStore,
setInitializingAgents,
setViewedTimelineSync,
voiceRuntime,
@@ -538,11 +546,11 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
const unsubProviderSubagentUpdate = feeds.subscribe({
snapshot: () => {
void resyncProviderSubagents(client, serverId);
void resyncProviderSubagents(client, serverId, providerStore);
},
update: (message) => {
if (message.type !== "agent.provider_subagents.update") return;
useProviderSubagentStore.getState().applyUpdate(serverId, message.payload);
providerStore.getState().applyUpdate(serverId, message.payload);
},
});
@@ -728,6 +736,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
clearAgentStreamHead,
setInitializingAgents,
setAgents,
providerStore,
setPendingPermissions,
notifyAgentAttention,
updateSessionServerInfo,
@@ -28,7 +28,8 @@ function createAgent(status: Agent["status"]): Agent {
title: "Agent",
cwd: "/tmp",
model: null,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
projectPlacement: null,
};
@@ -1,3 +1,4 @@
import { managedAgentFields } from "@/subagents/test-fixtures";
import { describe, expect, it } from "vitest";
import type { Agent, WorkspaceDescriptor } from "@/stores/session-store";
import type { WorkspaceStructureProject } from "@/projects/workspace-structure";
@@ -769,7 +770,9 @@ function agent(input: {
cwd: "/repo",
workspaceId: input.workspaceId,
model: null,
parentAgentId: input.parentAgentId ?? null,
...managedAgentFields(
input.parentAgentId ? { serverId: "srv", agentId: input.parentAgentId } : null,
),
archivedAt: input.archivedAt ?? null,
labels: {},
};
+2 -2
View File
@@ -7,7 +7,7 @@ import { useInfiniteQuery } from "@tanstack/react-query";
import { useCallback, useMemo, useSyncExternalStore } from "react";
import { useTranslation } from "react-i18next";
import type { AggregatedAgent } from "@/hooks/use-aggregated-agents";
import { getHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { useHostRuntimeStore, isHostRuntimeConnected, useHosts } from "@/runtime/host-runtime";
import { useSessionStore } from "@/stores/session-store";
import { buildAgentDirectoryState } from "@/utils/agent-directory-sync";
import { agentHistoryQueryKey, allAgentHistoryQueryKey } from "./agent-history-query-key";
@@ -221,7 +221,7 @@ export function useAgentHistory(options: {
}): AgentHistoryResult {
const { t } = useTranslation();
const daemons = useHosts();
const runtime = getHostRuntimeStore();
const runtime = useHostRuntimeStore();
const runtimeVersion = useSyncExternalStore(
(onStoreChange) => runtime.subscribeAll(onStoreChange),
() => runtime.getVersion(),
@@ -43,7 +43,8 @@ function createAgent(id: string): Agent {
title: "Agent",
cwd: "/repo",
model: null,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
};
}
@@ -39,7 +39,8 @@ function makeAgent(overrides: Partial<Agent> = {}): Agent {
title: "Agent 1",
cwd: "/repo",
model: null,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
archivedAt: null,
...overrides,
@@ -5,10 +5,11 @@ import { i18n } from "@/i18n/i18next";
import {
selectAgentTimelineState,
useSessionStore,
useSessionStoreApi,
type AgentTimelineCursorState,
} from "@/stores/session-store";
import { planTimelineOlderFetch } from "@/timeline/timeline-sync-plan";
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useHostRuntimeStore } from "@/runtime/host-runtime";
export interface LoadOlderAgentHistoryClient {
fetchAgentTimeline: (
@@ -78,6 +79,8 @@ export function useLoadOlderAgentHistory({
toast?: ToastApi | null;
}) {
const { t } = useTranslation();
const sessionStore = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const hasOlder = useSessionStore((state) => {
const timeline = selectAgentTimelineState(state.sessions[serverId], agentId);
return timeline.status === "synced" && timeline.older === "available";
@@ -110,13 +113,13 @@ export function useLoadOlderAgentHistory({
);
const loadOlder = useCallback(async (): Promise<boolean> => {
const session = useSessionStore.getState().sessions[serverId];
const session = sessionStore.getState().sessions[serverId];
const timeline = selectAgentTimelineState(session, agentId);
return await loadOlderAgentHistory(agentId, {
client: session?.client
? {
fetchAgentTimeline: (timelineAgentId, request) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, timelineAgentId, request),
runtime.fetchAgentTimeline(serverId, timelineAgentId, request),
}
: null,
cursor: timeline.status === "synced" ? (timeline.range ?? undefined) : undefined,
@@ -126,7 +129,7 @@ export function useLoadOlderAgentHistory({
toast,
failedMessage: t("loadOlderHistory.failed"),
});
}, [agentId, serverId, setInFlight, toast, t]);
}, [agentId, serverId, setInFlight, toast, t, runtime, sessionStore]);
return {
isLoadingOlder,
@@ -428,3 +428,41 @@ describe("host runtime app lifecycle", () => {
},
);
});
it("restores a qualified destination through its saved origin host boundary", () => {
const selection = { serverId: "B", workspaceId: "workspace", authority: { originServerId: "A" } };
expect(
resolveStartupRoute({
route: { kind: "index", pathname: "/" },
startupBlocker: { kind: "none" },
hostRegistryStatus: "ready",
hosts: [{ serverId: "A" }],
anyOnlineHostServerId: "A",
workspaceSelection: selection,
workspaceSelectionStatus: "unknown",
isWorkspaceSelectionLoaded: true,
hasGivenUpWaitingForHost: false,
}),
).toEqual({ kind: "redirect", href: "/h/B?origin=A" });
expect(
resolveHostIndexRoute({
serverId: "B",
workspaceSelection: selection,
workspaceSelectionStatus: "exists",
}),
).toBe("/h/B/workspace/workspace?origin=A");
});
it("authorizes a named host route by its intended origin and never substitutes a directly connected destination", () => {
const route = { kind: "host" as const, serverId: "B", originServerId: "A" };
const base = {
route,
startupBlocker: { kind: "none" as const },
hostRegistryStatus: "ready" as const,
};
expect(resolveStartupRoute({ ...base, hosts: [{ serverId: "A" }] })).toEqual({ kind: "render" });
expect(resolveStartupRoute({ ...base, hosts: [{ serverId: "B" }] })).toEqual({
kind: "redirect",
href: "/open-project",
});
});
@@ -1,4 +1,5 @@
import type { AppState } from "react-native";
import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
import type {
DaemonStartCondition,
@@ -10,6 +11,7 @@ import {
buildHostRootRoute,
buildHostWorkspaceRoute,
buildOpenProjectRoute,
withHostRouteAuthority,
} from "@/utils/host-routes";
export interface HostRuntimeBootstrapStore {
@@ -100,6 +102,7 @@ export interface IndexStartupRouteTarget {
export interface HostStartupRouteTarget {
kind: "host";
serverId: string | null;
originServerId?: string;
}
export type StartupRouteTarget = IndexStartupRouteTarget | HostStartupRouteTarget;
@@ -161,7 +164,10 @@ export function resolveHostIndexRoute(input: {
input.workspaceSelection?.serverId === input.serverId &&
shouldRestoreWorkspaceSelection(input)
) {
return buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
const route = buildHostWorkspaceRoute(input.serverId, input.workspaceSelection.workspaceId);
return input.workspaceSelection.authority
? (withHostRouteAuthority(route, input.workspaceSelection.authority) as Href)
: route;
}
return buildOpenProjectRoute();
}
@@ -188,13 +194,18 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
if (
shouldRestoreWorkspaceSelection(input) &&
hostExists(input.hosts, input.workspaceSelection.serverId)
hostExists(input.hosts, workspaceSelectionAuthority(input.workspaceSelection).originServerId)
) {
// Native cold launch must enter the host boundary first. The host index
// owns workspace restore after its local dynamic params exist.
return {
kind: "redirect",
href: buildHostRootRoute(input.workspaceSelection.serverId),
href: (input.workspaceSelection.authority
? withHostRouteAuthority(
buildHostRootRoute(input.workspaceSelection.serverId),
input.workspaceSelection.authority,
)
: buildHostRootRoute(input.workspaceSelection.serverId)) as Href,
};
}
@@ -215,7 +226,7 @@ function resolveReadyIndexStartupRoute(input: ResolveIndexStartupRouteInput): St
}
function resolveReadyHostStartupRoute(input: ResolveHostStartupRouteInput): StartupRouteDecision {
if (hostExists(input.hosts, input.route.serverId)) {
if (hostExists(input.hosts, input.route.originServerId ?? input.route.serverId)) {
return { kind: "render" };
}
@@ -131,6 +131,7 @@ function dispatchHostWorkspacePopTo(
serverId: selection.serverId,
workspaceId: encodeWorkspaceIdForPathSegment(selection.workspaceId),
...(open ? { open } : {}),
...(selection.authority ? { origin: selection.authority.originServerId } : {}),
},
// React Navigation consumes this nested hint when resolving the host child screen.
// The browser-route canonicalizer strips the resulting ?pop=true URL artifact.
+33 -18
View File
@@ -4,7 +4,7 @@ import { formatAgentModeLabel } from "@/agent-controls/labels";
import { formatTokenCount } from "@/components/context-window-meter.utils";
import { resolveAgentModelSelection } from "@/composer/agent-controls/utils";
import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
export interface AgentFactsInput {
models: AgentModelDefinition[] | null;
@@ -36,30 +36,45 @@ export function buildAgentFactsLine(input: AgentFactsInput): string | null {
return parts.join(" · ");
}
export interface AgentFactsSnapshot extends Omit<AgentFactsInput, "models"> {
provider: string;
cwd: string;
}
export function agentFactsSnapshot(agent: Agent): AgentFactsSnapshot {
return {
provider: agent.provider,
cwd: agent.cwd,
runtimeModelId: agent.runtimeInfo?.model ?? null,
configuredModelId: agent.model,
runtimeThinkingOptionId: agent.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: agent.thinkingOptionId ?? null,
availableModes: agent.availableModes,
currentModeId: agent.currentModeId,
contextWindowUsedTokens: agent.lastUsage?.contextWindowUsedTokens ?? null,
contextWindowMaxTokens: agent.lastUsage?.contextWindowMaxTokens ?? null,
};
}
/** Uses the destination binding's catalog query and push owner for the supplied snapshot. */
export function useAgentSnapshotFactsLine(
serverId: string,
agent: AgentFactsSnapshot | null,
): string | null {
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
return agent ? buildAgentFactsLine({ ...agent, models }) : null;
}
/** The facts line for a Paseo agent in the session store, kept current as the agent reports. */
export function useAgentFactsLine(serverId: string, agentId: string): string | null {
const agent = useSessionStore(
useShallow((state) => {
const current = state.sessions[serverId]?.agents.get(agentId);
return current
? {
provider: current.provider,
cwd: current.cwd,
runtimeModelId: current.runtimeInfo?.model ?? null,
configuredModelId: current.model ?? null,
runtimeThinkingOptionId: current.runtimeInfo?.thinkingOptionId ?? null,
explicitThinkingOptionId: current.thinkingOptionId ?? null,
availableModes: current.availableModes,
currentModeId: current.currentModeId,
contextWindowUsedTokens: current.lastUsage?.contextWindowUsedTokens ?? null,
contextWindowMaxTokens: current.lastUsage?.contextWindowMaxTokens ?? null,
}
: null;
return current ? agentFactsSnapshot(current) : null;
}),
);
const { entries } = useProvidersSnapshot(serverId, { cwd: agent?.cwd });
const models = entries?.find((entry) => entry.provider === agent?.provider)?.models ?? null;
return agent ? buildAgentFactsLine({ ...agent, models }) : null;
return useAgentSnapshotFactsLine(serverId, agent);
}
/** The mode control shows the current mode, or the provider's first one until the agent reports. */
+19 -14
View File
@@ -7,6 +7,8 @@ import { useIsCompactFormFactor } from "@/constants/layout";
import { usePaneContext } from "@/panels/pane-context";
import { useSettings } from "@/hooks/use-settings";
import { PluginComposerPills } from "@/plugins";
import { subagentRowBelongsToWorkspace } from "@/subagents/opens-in-sheet";
import type { PaseoSubagentRow } from "@/subagents/select";
import { useSessionStore } from "@/stores/session-store";
import {
type ArchiveFinishedStatus,
@@ -19,8 +21,8 @@ import { SubagentsTrack } from "@/subagents/track";
import type { TodoEntry } from "@/types/stream";
import { navigateToAgent } from "@/utils/navigate-to-agent";
import { buildWorkspaceTabPersistenceKey, type WorkspaceTabTarget } from "@/workspace-tabs/model";
import { openPreferredWorkspaceTarget } from "@/workspace-tabs/open-beside";
import { openComposerChanges } from "@/workspace-tabs/open-supporting-view";
import { useWorkspaceTargetActions } from "@/workspace-tabs/open-beside";
import { useSupportingViewActions } from "@/workspace-tabs/open-supporting-view";
/**
* The pane's ambient context — workspace changes, subagents, and tasks — as a row of pills above
@@ -49,6 +51,8 @@ export const AgentTracks = memo(function AgentTracks({
hasPluginComposerPills: boolean;
}): ReactElement | null {
const { tabId } = usePaneContext();
const { openPreferredWorkspaceTarget } = useWorkspaceTargetActions();
const { openComposerChanges } = useSupportingViewActions();
const hasWorkspaceDiffStat = useWorkspaceHasDiffStat(serverId, workspaceId);
const isCompact = useIsCompactFormFactor();
const openInSidePane = useSettings((settings) => settings.openInSidePane);
@@ -56,8 +60,8 @@ export const AgentTracks = memo(function AgentTracks({
const canDetachSubagents = useSessionStore(
(state) => state.sessions[serverId]?.serverInfo?.features?.agentDetach === true,
);
const archiveSubagent = useArchiveSubagent({ serverId });
const detachSubagent = useDetachSubagent({ serverId });
const archiveSubagent = useArchiveSubagent({ parent: { serverId, agentId } });
const detachSubagent = useDetachSubagent({ parent: { serverId, agentId } });
const openSubagentTarget = useCallback(
(target: WorkspaceTabTarget) => {
openPreferredWorkspaceTarget({
@@ -68,18 +72,19 @@ export const AgentTracks = memo(function AgentTracks({
parentTabId: tabId,
});
},
[openInSidePane, tabId, workspaceKey],
[openInSidePane, openPreferredWorkspaceTarget, tabId, workspaceKey],
);
const handleOpenSubagent = useCallback(
(subagentId: string) => {
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(subagentId) ?? session?.agentDetails.get(subagentId);
// A subagent in another workspace lives in that workspace's tabs.
if (agent?.workspaceId && agent.workspaceId !== workspaceId) {
navigateToAgent({ serverId, agentId: subagentId });
(row: PaseoSubagentRow) => {
if (!subagentRowBelongsToWorkspace(row, { serverId, workspaceId })) {
navigateToAgent({
...row.address,
workspaceId: row.workspace?.workspaceId,
authority: row.authority,
});
return;
}
openSubagentTarget({ kind: "agent", agentId: subagentId });
openSubagentTarget({ kind: "agent", agentId: row.address.agentId });
},
[openSubagentTarget, serverId, workspaceId],
);
@@ -89,7 +94,7 @@ export const AgentTracks = memo(function AgentTracks({
},
[openSubagentTarget],
);
const opensInSheet = useSubagentOpensInSheet(workspaceKey);
const opensInSheet = useSubagentOpensInSheet(workspaceKey, { serverId, workspaceId });
const handleOpenChanges = useCallback(() => {
if (!workspaceKey) {
return;
@@ -99,7 +104,7 @@ export const AgentTracks = memo(function AgentTracks({
workspaceKey,
preferences: openInSidePane,
});
}, [isCompact, openInSidePane, workspaceKey]);
}, [isCompact, openComposerChanges, openInSidePane, workspaceKey]);
if (
!hasWorkspaceDiffStat &&
@@ -25,6 +25,7 @@ import {
refreshProviderSubagents,
observeProviderSubagentTimeline,
useProviderSubagentStore,
useProviderSubagentStoreApi,
} from "@/subagents/provider-store";
import { useTranslation } from "react-i18next";
import type { PendingPermission } from "@/types/shared";
@@ -114,6 +115,7 @@ function useProviderSubagentDescriptor(
}
function ProviderSubagentPanel() {
const providerStore = useProviderSubagentStoreApi();
const { t } = useTranslation();
const { serverId, workspaceId, target, openFileInWorkspace, openTab } = usePaneContext();
invariant(target.kind === "provider_subagent", "ProviderSubagentPanel requires provider target");
@@ -145,6 +147,7 @@ function ProviderSubagentPanel() {
const childTrackClearance = resolveChildTrackClearance(childRows.length, isCompact);
const opensInSheet = useSubagentOpensInSheet(
buildWorkspaceTabPersistenceKey({ serverId, workspaceId }),
{ serverId, workspaceId },
);
const openProviderChild = useCallback(
(parentAgentId: string, subagentId: string) => {
@@ -155,13 +158,16 @@ function ProviderSubagentPanel() {
useEffect(() => {
if (!client || !supported) return;
void refreshProviderSubagents(client, serverId, target.parentAgentId).catch(() => undefined);
}, [client, serverId, supported, target.parentAgentId]);
void refreshProviderSubagents(client, serverId, target.parentAgentId, providerStore).catch(
() => undefined,
);
}, [client, providerStore, serverId, supported, target.parentAgentId]);
useEffect(() => {
if (!client || !supported) return;
return observeProviderSubagentTimeline({
client,
store: providerStore,
serverId,
parentAgentId: target.parentAgentId,
subagentId: target.subagentId,
@@ -175,7 +181,7 @@ function ProviderSubagentPanel() {
});
},
});
}, [client, supported, serverId, target.parentAgentId, target.subagentId]);
}, [client, providerStore, supported, serverId, target.parentAgentId, target.subagentId]);
const loadOlder = useCallback((): boolean => {
if (!client || !supported || isLoadingOlder || !timeline?.hasOlder || !timeline.epoch) {
@@ -191,7 +197,7 @@ function ProviderSubagentPanel() {
limit: TIMELINE_FETCH_PAGE_SIZE,
})
.then((payload) => {
useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
providerStore.getState().replaceTimeline(serverId, payload);
return undefined;
})
.catch(() => undefined)
@@ -199,6 +205,7 @@ function ProviderSubagentPanel() {
return true;
}, [
client,
providerStore,
isLoadingOlder,
serverId,
supported,
@@ -49,7 +49,8 @@ const agent: Agent = {
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: { phase: "implementation" },
};
@@ -103,6 +104,7 @@ describe("plugin context snapshots", () => {
thinkingOptionId: "high",
requiresAttention: true,
attentionReason: "permission",
parent: null,
parentAgentId: null,
labels: { phase: "implementation" },
});
@@ -47,7 +47,9 @@ export function createPluginAgentSnapshot(agent: Agent, workspaceId: string): Pl
thinkingOptionId: agent.thinkingOptionId ?? null,
requiresAttention: agent.requiresAttention ?? false,
attentionReason: agent.attentionReason ?? null,
parentAgentId: agent.parentAgentId,
parent: agent.parent ? Object.freeze({ ...agent.parent }) : null,
// COMPAT(pluginParentId): added in v0.12.0, remove after 2027-04-20.
parentAgentId: agent.parent?.agentId ?? null,
labels: Object.freeze({ ...agent.labels }),
});
const byWorkspace = agentSnapshots.get(agent) ?? new Map<string, PluginAgentSnapshot>();
@@ -43,6 +43,7 @@ const agent: PluginAgentSnapshot = {
thinkingOptionId: null,
requiresAttention: false,
attentionReason: null,
parent: null,
parentAgentId: null,
labels: {},
};
@@ -4,14 +4,18 @@ import {
getReviewDraftComments,
clearSentReviewDraftComments,
} from "../store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreApi,
type WorkspaceLayoutStoreApi,
} from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { deriveWorkspaceTabModel } from "@/screens/workspace/workspace-tab-model";
import { useHostRuntimeClient, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useAppSettings } from "@/hooks/use-settings";
import { dispatchComposerAgentMessage } from "@/composer/actions";
import { createMessageSubmissionWriter } from "@/composer/submission/writer";
import { useSessionStore, selectAgentTurnPresentation } from "@/stores/session-store";
import { useSessionStoreApi, selectAgentTurnPresentation } from "@/stores/session-store";
import { FLOATING_ACTION_BUTTON_CLEARANCE } from "@/components/ui/floating-action-button";
import { useIsCompactFormFactor } from "@/constants/layout";
import { buttonControlHeight } from "@/components/ui/control-geometry";
@@ -35,6 +39,8 @@ export function useReviewFeedback(input: {
}) {
const { serverId, reviewDraftKey, workspaceId } = input;
const toast = useToast();
const layoutStore = useWorkspaceLayoutStoreApi();
const sessionStore = useSessionStoreApi();
const isCompact = useIsCompactFormFactor();
const workspaceKey = workspaceId
? buildWorkspaceTabPersistenceKey({ serverId, workspaceId })
@@ -68,7 +74,7 @@ export function useReviewFeedback(input: {
comments: getReviewDraftComments(reviewDraftKey) ?? [],
attachment: live.current.attachment,
connected: live.current.connected,
recipients: readRecipients(workspaceKey),
recipients: readRecipients(workspaceKey, layoutStore),
}),
send: async (recipient, attachment) => {
const { client: targetClient, sendBehavior } = live.current;
@@ -80,12 +86,12 @@ export function useReviewFeedback(input: {
text: i18n.t("review.feedback.prompt"),
attachments: [attachment],
encodeImages: async () => [],
submission: createMessageSubmissionWriter(serverId),
submission: createMessageSubmissionWriter(serverId, sessionStore),
activeTurnBehavior,
activeTurnId:
activeTurnBehavior === "steer"
? (selectAgentTurnPresentation(
useSessionStore.getState().sessions[serverId],
sessionStore.getState().sessions[serverId],
recipient.agentId,
).turnId ?? undefined)
: undefined,
@@ -103,7 +109,7 @@ export function useReviewFeedback(input: {
clearSentReviewDraftComments({ key: reviewDraftKey, comments: sentComments }),
failedMessage: () => i18n.t("review.feedback.failed"),
}),
[reviewDraftKey, serverId, workspaceKey, workspaceId],
[layoutStore, sessionStore, reviewDraftKey, serverId, workspaceKey, workspaceId],
);
useEffect(() => {
model.refresh();
@@ -119,10 +125,11 @@ export function useReviewFeedback(input: {
};
}
function readRecipients(workspaceKey: string | null): FeedbackRecipient[] {
const tabs = workspaceKey
? useWorkspaceLayoutStore.getState().getWorkspaceTabs(workspaceKey)
: [];
function readRecipients(
workspaceKey: string | null,
store: WorkspaceLayoutStoreApi,
): FeedbackRecipient[] {
const tabs = workspaceKey ? store.getState().getWorkspaceTabs(workspaceKey) : [];
const descriptors = deriveWorkspaceTabModel({ tabs }).tabs;
const recipients: FeedbackRecipient[] = [];
for (const { descriptor } of descriptors) {
@@ -38,7 +38,7 @@ export class AgentDirectoryReplica {
private readonly serverId: string,
private readonly onStoppedRunning: (agentId: string) => void,
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
store: SessionStoreApi = useSessionStore,
private readonly store: SessionStoreApi = useSessionStore,
) {
this.storeProjection = new AgentStoreProjection(serverId, store);
}
@@ -81,7 +81,11 @@ export class AgentDirectoryReplica {
const existing = this.storeProjection.get(token.agentId);
const timelineAgent = applyLegacyDaemonWorkspaceOwnership({
serverId: this.serverId,
agent: normalizeAgentSnapshot(payload, this.serverId),
agent: normalizeAgentSnapshot(
payload,
this.serverId,
this.store.getState().sessions[this.serverId]?.serverInfo,
),
});
const normalized: Agent = {
...timelineAgent,
@@ -80,7 +80,11 @@ export class AgentStoreProjection {
this.removeFromDirectory(delta.agentId);
return { agentId: delta.agentId, stoppedRunning: false };
}
const normalized = normalizeAgentSnapshot(delta.agent, this.serverId);
const normalized = normalizeAgentSnapshot(
delta.agent,
this.serverId,
this.store.getState().sessions[this.serverId]?.serverInfo,
);
const session = this.store.getState().sessions[this.serverId];
const previous = session?.agents.get(normalized.id) ?? session?.agentDetails.get(normalized.id);
const legacyWorkspaceId =
@@ -113,6 +117,7 @@ export class AgentStoreProjection {
const { agents: fetched, pendingPermissions } = buildAgentDirectoryState({
serverId: this.serverId,
entries,
serverInfo: this.store.getState().sessions[this.serverId]?.serverInfo,
});
const store = this.store.getState();
const current = store.sessions[this.serverId]?.agents ?? new Map<string, Agent>();
@@ -1,4 +1,12 @@
import {
createWorkspaceLayoutStore,
useWorkspaceLayoutStoreApi,
} from "@/stores/workspace-layout-store";
import React from "react";
import {
createProviderSubagentStore,
useProviderSubagentStoreApi,
} from "@/subagents/provider-store";
import { renderToStaticMarkup } from "react-dom/server";
import { expect, it } from "vitest";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
@@ -15,6 +23,8 @@ it("ordinary consumer hooks retain the runtime, store and creation owner of each
namespace: JSON.stringify(["host-authority-v1", originServerId, "B"]),
sessionStore,
queries: new QueryClient(),
providerSubagents: createProviderSubagentStore(),
layout: createWorkspaceLayoutStore(),
runtime: new HostRuntimeStore({ sessionStore }),
ready: Promise.resolve(),
};
@@ -26,6 +36,8 @@ it("ordinary consumer hooks retain the runtime, store and creation owner of each
const store = useSessionStoreApi();
const creates = useCreateFlowStoreApi();
expect(useQueryClient()).toBe(expected.queries);
expect(useProviderSubagentStoreApi()).toBe(expected.providerSubagents);
expect(useWorkspaceLayoutStoreApi()).toBe(expected.layout);
expect(runtime).toBe(expected.runtime);
expect(store).toBe(expected.sessionStore);
expect(creates).toBe(expected.sessionStore.createFlows);
@@ -1,3 +1,5 @@
import { WorkspaceLayoutStoreContext } from "@/stores/workspace-layout-store";
import { ProviderSubagentStoreContext } from "@/subagents/provider-store";
import type { ReactNode } from "react";
import { QueryClientProvider } from "@tanstack/react-query";
import { SessionStoreContext } from "@/stores/session-store";
@@ -14,13 +16,17 @@ export function HostAuthorityProvider({
}) {
return (
<QueryClientProvider client={binding.queries}>
<HostRuntimeContext.Provider value={binding.runtime}>
<SessionStoreContext.Provider value={binding.sessionStore}>
<CreateFlowStoreContext.Provider value={binding.sessionStore.createFlows}>
{children}
</CreateFlowStoreContext.Provider>
</SessionStoreContext.Provider>
</HostRuntimeContext.Provider>
<ProviderSubagentStoreContext.Provider value={binding.providerSubagents}>
<HostRuntimeContext.Provider value={binding.runtime}>
<SessionStoreContext.Provider value={binding.sessionStore}>
<CreateFlowStoreContext.Provider value={binding.sessionStore.createFlows}>
<WorkspaceLayoutStoreContext.Provider value={binding.layout}>
{children}
</WorkspaceLayoutStoreContext.Provider>
</CreateFlowStoreContext.Provider>
</SessionStoreContext.Provider>
</HostRuntimeContext.Provider>
</ProviderSubagentStoreContext.Provider>
</QueryClientProvider>
);
}
@@ -0,0 +1,55 @@
import { withUnistyles } from "react-native-unistyles";
import type { Theme } from "@/styles/theme";
import { useEffect, useState, useSyncExternalStore, type ReactNode } from "react";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { HostAuthorityProvider } from "./host-authority-provider";
import {
getHostRuntimeStore,
type HostAuthorityReference,
type OwnedHostAuthorityBinding,
} from "./host-runtime";
const ThemedLoadingSpinner = withUnistyles(LoadingSpinner);
const spinnerColors = (theme: Theme) => ({ color: theme.colors.foregroundMuted });
/** The retained route owns its lease; destination identity never selects another origin. */
export function HostAuthorityRoute({
reference,
children,
pending = <ThemedLoadingSpinner uniProps={spinnerColors} />,
}: {
reference: HostAuthorityReference;
children: ReactNode;
pending?: ReactNode;
}) {
const registry = getHostRuntimeStore();
const { originServerId, serverId } = reference;
useSyncExternalStore(
(changed) => registry.subscribe(originServerId, changed),
() => registry.getSnapshot(originServerId),
() => registry.getSnapshot(originServerId),
);
const serverInfo = registry.getServerInfo(originServerId);
const features = serverInfo?.features;
// COMPAT(preFleetDirectRoutes): added in v0.12, remove after 2027-04-20.
// Old direct hosts retain their existing shell/session owner. Owned SDK scopes still reject unsupported capability.
const legacy =
originServerId === serverId && serverInfo !== null && features?.hostSessionScopes !== true;
const [lease, setLease] = useState<OwnedHostAuthorityBinding | null>(null);
useEffect(() => {
if (legacy) return;
const owned = registry.acquireBinding({ originServerId, serverId });
setLease(owned);
return () => {
void owned.release();
};
}, [legacy, originServerId, registry, serverId]);
if (legacy) return children;
if (
!lease ||
lease.binding.reference.originServerId !== originServerId ||
lease.binding.reference.serverId !== serverId
)
return pending;
return <HostAuthorityProvider binding={lease.binding}>{children}</HostAuthorityProvider>;
}
@@ -0,0 +1,35 @@
import { useSyncExternalStore } from "react";
import { SessionProvider } from "@/contexts/session-context";
import { WorkspaceLayoutModePublisher } from "@/workspace-tabs/layout-mode-publisher";
import { HostAuthorityProvider } from "./host-authority-provider";
import {
getHostRuntimeStore,
useHostRuntimeClient,
type HostAuthorityBinding,
} from "./host-runtime";
/** One ordinary session adapter per runtime-owned binding, shared by all retained screens. */
export function HostAuthoritySessions() {
const registry = getHostRuntimeStore();
const bindings = useSyncExternalStore(
(changed) => registry.subscribeAll(changed),
registry.getAuthorityBindings,
registry.getAuthorityBindings,
);
return bindings.map((binding) => (
<HostAuthorityProvider key={binding.namespace} binding={binding}>
<WorkspaceLayoutModePublisher />
<AuthoritySession binding={binding} />
</HostAuthorityProvider>
));
}
function AuthoritySession({ binding }: { binding: HostAuthorityBinding }) {
const client = useHostRuntimeClient(binding.reference.serverId);
if (!client) return null;
return (
<SessionProvider serverId={binding.reference.serverId} client={client}>
{null}
</SessionProvider>
);
}
+62 -1
View File
@@ -1,3 +1,4 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import { indexedDB as fakeIndexedDb, IDBKeyRange as FakeIDBKeyRange } from "fake-indexeddb";
import { afterEach, describe, expect, it, vi } from "vitest";
import { QueryObserver } from "@tanstack/react-query";
@@ -31,6 +32,7 @@ import {
readInitialDaemonConnectionHint,
type HostRuntimeControllerDeps,
type HostRuntimeStorage,
type HostAuthorityBinding,
} from "./host-runtime";
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
@@ -61,6 +63,12 @@ class FakeDaemonClient {
publishEvent(message: SessionOutboundMessage): void {
for (const receive of this.eventListeners) receive(message);
}
readonly cancelCalls: string[] = [];
cancelResponse: Promise<void> | null = null;
async cancelAgent(agentId: string): Promise<void> {
this.cancelCalls.push(agentId);
await this.cancelResponse;
}
readonly archiveCalls: string[] = [];
archiveResponse: Promise<{ archivedAt: string }> | null = null;
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
@@ -3434,7 +3442,8 @@ describe("HostRuntimeStore", () => {
lastActivityAt: new Date(stale.updatedAt),
archivedAt: stale.archivedAt ? new Date(stale.archivedAt) : null,
attentionTimestamp: stale.attentionTimestamp ? new Date(stale.attentionTimestamp) : null,
parentAgentId: null,
parent: null,
managedRelationship: null,
};
return new Map([[stale.id, staleAgent]]);
});
@@ -4484,6 +4493,14 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
});
});
function agentInBinding(binding: HostAuthorityBinding, address: AgentAddress): Agent {
const agent = binding.sessionStore
.getState()
.sessions[address.serverId]?.agents.get(address.agentId);
if (!agent) throw new Error(`Expected agent in binding ${binding.namespace}`);
return agent;
}
it("retains one authority binding for direct and routed consumers without changing a retained direct destination", async () => {
globalThis.indexedDB = fakeIndexedDb;
globalThis.IDBKeyRange = FakeIDBKeyRange;
@@ -4628,6 +4645,50 @@ it("retains one authority binding for direct and routed consumers without changi
queryClient: routed.binding.queries,
unavailableMessage: "Authority unavailable",
});
const running = {
...agent,
status: "running" as const,
turn: {
phase: "open" as const,
turnId: "owned-turn",
startedAt: new Date(),
cancellationRequestId: null,
},
};
direct.binding.runtime.acceptAgentSnapshot(bId, running);
routed.binding.runtime.acceptAgentSnapshot(bId, { ...running, title: "Routed" });
const cancelGate = createDeferred<void>();
routeClient.cancelResponse = cancelGate.promise;
const canceling = routed.binding.runtime.cancelAgent({ serverId: bId, agentId: agent.id });
void canceling.catch(() => {});
await expect.poll(() => routeClient.cancelCalls).toEqual([agent.id]);
expect(clients.get(bId)!.cancelCalls).toEqual([]);
expect(
agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn
.cancellationRequestId,
).not.toBeNull();
expect(
agentInBinding(direct.binding, { serverId: bId, agentId: agent.id }).turn
.cancellationRequestId,
).toBeNull();
cancelGate.reject(new Error("Selected cancellation rejected"));
await expect(canceling).rejects.toThrow("Selected cancellation rejected");
expect(
agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn
.cancellationRequestId,
).toBeNull();
expect(agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn.phase).toBe(
"open",
);
expect(agentInBinding(direct.binding, { serverId: bId, agentId: agent.id }).turn.phase).toBe(
"open",
);
routeClient.cancelResponse = Promise.resolve();
await routed.binding.runtime.cancelAgent({ serverId: bId, agentId: agent.id });
expect(
agentInBinding(routed.binding, { serverId: bId, agentId: agent.id }).turn
.cancellationRequestId,
).toBeNull();
const gate = createDeferred<{ archivedAt: string }>();
routeClient.archiveResponse = gate.promise;
const archiving = archive({ serverId: bId, agentId: agent.id });
+138 -6
View File
@@ -1,9 +1,20 @@
import {
createWorkspaceLayoutStore,
type WorkspaceLayoutStoreApi,
} from "@/stores/workspace-layout-store";
import { createPaseoApi, type PaseoAgentHandle } from "@getpaseo/client";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import {
acquireHostConnection,
type HostConnectionLease,
} from "@getpaseo/client/internal/host-connection";
import { createContext, useContext, useSyncExternalStore, useMemo } from "react";
import { QueryClient } from "@tanstack/react-query";
import {
createProviderSubagentStore,
type ProviderSubagentStoreApi,
} from "@/subagents/provider-store";
import { ManagedSubagents } from "@/subagents/managed";
import AsyncStorage from "@react-native-async-storage/async-storage";
import equal from "fast-deep-equal/es6";
import {
@@ -1490,6 +1501,8 @@ export interface HostAuthorityBinding {
readonly namespace: string;
readonly sessionStore: SessionStoreApi;
readonly queries: QueryClient;
readonly providerSubagents: ProviderSubagentStoreApi;
readonly layout: WorkspaceLayoutStoreApi;
readonly runtime: HostRuntimeStore;
/** Initial acquisition only. Current availability comes from runtime snapshots. */
readonly ready: Promise<void>;
@@ -1523,11 +1536,18 @@ function initialAuthoritySnapshot(serverId: string): HostRuntimeSnapshot {
}
export class HostRuntimeStore {
readonly subagents: ManagedSubagents;
private readonly authority: HostAuthorityReference | null;
private readonly bindingOwner: HostRuntimeStore;
private disposed = false;
private readonly lifetime = new AbortController();
private controllers = new Map<string, HostRuntimeController>();
private readonly controllerObservations = new WeakMap<HostRuntimeController, () => void>();
private readonly authorityBindings = new Map<
string,
{ binding: HostAuthorityBinding; refs: number; close(): Promise<void> }
>();
private bindingSnapshot: readonly HostAuthorityBinding[] = [];
private serverListeners = new Map<string, Set<() => void>>();
private globalListeners = new Set<() => void>();
private hostListListeners = new Set<() => void>();
@@ -1563,12 +1583,17 @@ export class HostRuntimeStore {
replicaRowStore?: ReplicaRowStore;
sessionStore?: SessionStoreApi;
queries?: QueryClient;
authority?: HostAuthorityReference;
bindingOwner?: HostRuntimeStore;
resolveHostConnection?: typeof acquireHostConnection;
revokePushNotifications?: typeof revokePushNotifications;
}) {
this.deps = input?.deps ?? createDefaultDeps();
this.sessionStore = input?.sessionStore ?? useSessionStore;
this.queries = input?.queries ?? queryClient;
this.authority = input?.authority ?? null;
this.bindingOwner = input?.bindingOwner ?? this;
this.subagents = new ManagedSubagents(this);
this.resolveHostConnection = input?.resolveHostConnection ?? acquireHostConnection;
this.storage = input?.storage ?? AsyncStorage;
this.replicaCache = new ReplicaCache(input?.replicaRowStore ?? createReplicaRowStore());
@@ -1576,6 +1601,59 @@ export class HostRuntimeStore {
}
/** A retained screen owns a lease; route identity never grants access by itself. */
authorityFor(serverId: string): HostAuthorityReference {
return { originServerId: this.authority?.originServerId ?? serverId, serverId };
}
/** Resolve an ordinary agent action through the retained origin, owning its entire scope. */
async withAgent<T>(
address: AgentAddress,
operation: (agent: PaseoAgentHandle) => Promise<T>,
): Promise<T> {
if (this.disposed) throw new Error("Host authority binding is disposed");
const authority = this.authorityFor(address.serverId);
if (this.bindingOwner.getSnapshot(authority.originServerId)?.connectionStatus !== "online")
throw new Error("Selected host authority is unavailable");
const lease = this.bindingOwner.acquireBinding(authority);
let api: ReturnType<typeof createPaseoApi> | null = null;
try {
const client = await lease.binding.runtime.awaitActiveClient(
address.serverId,
this.lifetime.signal,
);
if (this.disposed) throw new Error("Host authority binding is disposed");
api = createPaseoApi(client);
return await operation(api.agents.ref(address.agentId));
} finally {
// Cleanup cannot erase the acknowledged outcome of a submitted action.
await Promise.allSettled([api?.dispose(), lease.release()]);
}
}
private awaitActiveClient(serverId: string, signal: AbortSignal): Promise<DaemonClient> {
return new Promise((resolve, reject) => {
let stop = () => {};
const finish = (failure: Error | null, client?: DaemonClient) => {
stop();
signal.removeEventListener("abort", aborted);
if (failure) reject(failure);
else resolve(client!);
};
const aborted = () => finish(new Error("Host authority binding is disposed"));
const changed = () => {
if (signal.aborted || this.disposed) return aborted();
const snapshot = this.getSnapshot(serverId);
if (snapshot?.connectionStatus === "online" && snapshot.client)
finish(null, snapshot.client);
else if (snapshot?.connectionStatus !== "connecting")
finish(new Error(snapshot?.lastError ?? "Selected host authority is unavailable"));
};
stop = this.subscribe(serverId, changed);
signal.addEventListener("abort", aborted, { once: true });
changed();
});
}
acquireBinding(reference: HostAuthorityReference): OwnedHostAuthorityBinding {
const normalized = { originServerId: reference.originServerId, serverId: reference.serverId };
const namespace = authorityNamespace(normalized);
@@ -1589,6 +1667,15 @@ export class HostRuntimeStore {
namespace,
sessionStore: scope.runtime.sessionStore,
queries,
providerSubagents: createProviderSubagentStore(),
layout: createWorkspaceLayoutStore(undefined, this.storage, {
namespace,
sessionStore: scope.runtime.sessionStore,
// COMPAT(preFleetDirectLayout): added in v0.12, remove after 2027-04-20.
...(normalized.originServerId === normalized.serverId
? { legacyServerId: normalized.serverId }
: {}),
}),
runtime: scope.runtime,
ready: scope.ready,
},
@@ -1602,6 +1689,7 @@ export class HostRuntimeStore {
},
};
this.authorityBindings.set(namespace, entry);
this.publishAuthorityBindings();
}
entry.refs++;
const retained = entry;
@@ -1612,8 +1700,10 @@ export class HostRuntimeStore {
if (!released) {
retained.refs--;
if (retained.refs === 0) {
if (this.authorityBindings.get(namespace) === retained)
if (this.authorityBindings.get(namespace) === retained) {
this.authorityBindings.delete(namespace);
this.publishAuthorityBindings();
}
released = retained.close();
} else released = Promise.resolve();
}
@@ -1622,6 +1712,14 @@ export class HostRuntimeStore {
};
}
getAuthorityBindings = (): readonly HostAuthorityBinding[] => this.bindingSnapshot;
private publishAuthorityBindings(): void {
this.bindingSnapshot = [...this.authorityBindings.values()].map((entry) => entry.binding);
this.version++;
for (const listener of this.globalListeners) listener();
}
private createAuthorityScope(
reference: HostAuthorityReference,
namespace: string,
@@ -1634,6 +1732,8 @@ export class HostRuntimeStore {
const scoped = new HostRuntimeStore({
sessionStore: createSessionStore(),
queries,
authority: reference,
bindingOwner: this.bindingOwner,
replicaRowStore: createReplicaRowStore(namespace),
storage: this.storage,
deps: this.deps,
@@ -1800,6 +1900,8 @@ export class HostRuntimeStore {
runtime: scoped,
ready,
close: async () => {
scoped.disposed = true;
scoped.lifetime.abort();
stopped = true;
generation++;
stopOrigin();
@@ -1817,7 +1919,13 @@ export class HostRuntimeStore {
new Error("Host authority binding is disposed"),
);
}
await Promise.allSettled([lease?.release(), directory?.release(), acquiring, ...retired]);
await Promise.allSettled([
lease?.release(),
directory?.release(),
acquiring,
...retired,
scoped.subagents.releaseServer(serverId),
]);
await scoped.replicaCache.flush();
scoped.sessionStore.getState().clearSession(serverId);
},
@@ -2546,6 +2654,8 @@ export class HostRuntimeStore {
if (nextIds.has(serverId)) {
continue;
}
this.controllerObservations.get(controller)?.();
this.controllerObservations.delete(controller);
this.controllers.delete(serverId);
this.snapshots.delete(serverId);
this.lastConnectionStatusByServer.delete(serverId);
@@ -2587,12 +2697,13 @@ export class HostRuntimeStore {
this.syncSessionReplica(host.serverId, initialSnapshot);
this.lastConnectionStatusByServer.set(host.serverId, initialSnapshot.connectionStatus);
this.connectionStatusStartedAtByServer.set(host.serverId, Date.now());
controller.subscribe(() => {
const stopObservation = controller.subscribe(() => {
const snapshot = controller.getSnapshot();
this.syncSessionReplica(snapshot.serverId, snapshot);
this.syncDirectoryConnection(snapshot.serverId);
this.emit(snapshot.serverId);
});
this.controllerObservations.set(controller, stopObservation);
void controller
.start(
initialConnection
@@ -2657,6 +2768,7 @@ export class HostRuntimeStore {
private clearHostReplica(serverId: string): void {
this.syncServerDataClient(serverId, null);
void this.subagents.releaseServer(serverId);
this.sessionStore.getState().clearSession(serverId);
useWorkspaceSetupStore.getState().clearServer(serverId);
}
@@ -2733,7 +2845,7 @@ export class HostRuntimeStore {
supportsForgeAttachments,
}),
encodeImages,
submission: createMessageSubmissionWriter(serverId),
submission: createMessageSubmissionWriter(serverId, this.sessionStore),
// The queue drains when the agent goes idle, but the agent can start a turn of its own
// in that same moment (a wake-up after background work). Steering folds the message
// into that turn instead of interrupting it; with no turn running, the daemon starts
@@ -2765,13 +2877,29 @@ export class HostRuntimeStore {
this.directorySyncByServer.get(serverId)?.applyAgentTurnLiveness(agentId, transition);
}
beginAgentCancellation(serverId: string, agentId: string): number {
async cancelAgent(address: AgentAddress): Promise<void> {
const client = this.getClient(address.serverId);
if (
this.disposed ||
!client ||
this.getSnapshot(address.serverId)?.connectionStatus !== "online"
)
throw new Error("Selected host authority is unavailable");
const requestId = this.beginAgentCancellation(address.serverId, address.agentId);
try {
await client.cancelAgent(address.agentId);
} finally {
this.settleAgentCancellation(address.serverId, address.agentId, requestId);
}
}
private beginAgentCancellation(serverId: string, agentId: string): number {
const requestId = ++this.nextCancellationRequestId;
this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_started", requestId });
return requestId;
}
settleAgentCancellation(serverId: string, agentId: string, requestId: number): void {
private settleAgentCancellation(serverId: string, agentId: string, requestId: number): void {
this.applyAgentTurnLiveness(serverId, agentId, { type: "cancellation_settled", requestId });
}
@@ -2819,6 +2947,10 @@ export class HostRuntimeStore {
return this.version;
}
getServerInfo(serverId: string) {
return this.sessionStore.getState().sessions[serverId]?.serverInfo ?? null;
}
getClient(serverId: string): DaemonClient | null {
return this.snapshots.get(serverId)?.client ?? null;
}
@@ -1124,3 +1124,28 @@ it("retains semantic agent messages across disk-cache reconstruction", async ()
await writer.flush();
expect(await createCache(storage).readTimeline(SERVER_ID, "agent-1")).toEqual(value);
});
it("retains owner membership and explicit clearing across disk-cache reconstruction", async () => {
const storage = new MemoryStorage();
const writer = createCache(storage);
const value = directory();
const original = agent();
const parent = { serverId: SERVER_ID, agentId: "parent" };
const managedRelationship = {
id: "relationship",
parent,
parentWorkspace: { serverId: SERVER_ID, workspaceId: "workspace-1" },
};
value.agents.set(original.id, { ...original, parent, managedRelationship });
commitDirectory(writer, SERVER_ID, value);
await writer.flush();
expect(
(await createCache(storage).readDirectory(SERVER_ID)).agents.get(original.id),
).toMatchObject({ parent, managedRelationship });
value.agents.set(original.id, { ...original, parent, managedRelationship: null });
commitDirectory(writer, SERVER_ID, value);
await writer.flush();
expect(
(await createCache(storage).readDirectory(SERVER_ID)).agents.get(original.id),
).toMatchObject({ parent, managedRelationship: null });
});
@@ -1,6 +1,8 @@
import { AgentAddressSchema } from "@getpaseo/protocol/addresses";
import { z } from "zod";
import {
AgentStatusSchema,
ManagedAgentRelationshipSchema,
AgentTimelineItemPayloadSchema,
WorkspaceGitHubRuntimePayloadSchema,
} from "@getpaseo/protocol/messages";
@@ -221,6 +223,8 @@ const StoredProjectPlacementSchema = z.strictObject({
});
const StoredAgentSnapshotSchema = z.strictObject({
parent: AgentAddressSchema.nullable().optional(),
managedRelationship: ManagedAgentRelationshipSchema.nullable().optional(),
id: z.string(),
provider: AgentProviderSchema,
cwd: z.string(),
@@ -611,6 +615,8 @@ function serializeAgentTurn(agent: Agent): NonNullable<StoredAgent["turn"]> {
function serializeAgent(agent: Agent): StoredAgent {
const snapshot = {
id: agent.id,
parent: agent.parent,
managedRelationship: agent.managedRelationship,
provider: agent.provider,
cwd: agent.cwd,
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
@@ -36,7 +36,11 @@ import {
WorkspacePaneContent,
type WorkspacePaneContentModel,
} from "@/screens/workspace/workspace-pane-content";
import { useWorkspaceLayoutStore, type SheetTab } from "@/stores/workspace-layout-store";
import {
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreApi,
type SheetTab,
} from "@/stores/workspace-layout-store";
const SNAP_POINTS = ["92%"];
const NO_SHEETS: readonly SheetTab[] = [];
@@ -108,6 +112,7 @@ interface SheetTabSheetProps extends Omit<SheetTabsProps, "workspaceKey" | "onCl
* `SheetHeaderView`) keep them looking and dragging like every other sheet.
*/
export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps) {
const layout = useWorkspaceLayoutStoreApi();
const sheets = useWorkspaceLayoutStore((state) =>
workspaceKey ? (state.sheetsByWorkspace[workspaceKey] ?? NO_SHEETS) : NO_SHEETS,
);
@@ -122,12 +127,10 @@ export function SheetTabs({ workspaceKey, onCloseTab, ...rest }: SheetTabsProps)
async (tabId: string) => {
await onCloseTab(tabId);
// A declined unsaved-changes prompt leaves the tab in its sheet.
const open = workspaceKey
? useWorkspaceLayoutStore.getState().sheetsByWorkspace[workspaceKey]
: undefined;
const open = workspaceKey ? layout.getState().sheetsByWorkspace[workspaceKey] : undefined;
return open?.some((sheet) => sheet.tab.tabId === tabId) ?? false;
},
[onCloseTab, workspaceKey],
[layout, onCloseTab, workspaceKey],
);
return (
<>
@@ -219,3 +219,15 @@ describe("shouldKeepWorkspaceDeckEntryMounted", () => {
).toBe(true);
});
});
it("retains separate screens for the same destination workspace under distinct origins", () => {
const direct = { serverId: "B", workspaceId: "workspace", authority: { originServerId: "B" } };
const throughA = { serverId: "B", workspaceId: "workspace", authority: { originServerId: "A" } };
const entries = reconcileRetainedWorkspaceSelections({
currentEntries: [{ selection: direct, inactiveSince: null }],
activeSelection: throughA,
now: 10,
});
expect(entries).toHaveLength(2);
expect(entries.map((entry) => entry.selection)).toEqual([throughA, direct]);
});
@@ -1,3 +1,4 @@
import { workspaceSelectionAuthority } from "@/stores/last-workspace-selection";
import type { ActiveWorkspaceSelection } from "@/stores/navigation-active-workspace-store";
export const WORKSPACE_DECK_MAX_MOUNTED_WORKSPACES = 10;
@@ -39,14 +40,19 @@ interface ResolveWorkspaceDeckEntriesInput {
}
export function getWorkspaceSelectionKey(selection: ActiveWorkspaceSelection): string {
return `${selection.serverId}:${selection.workspaceId}`;
return JSON.stringify([
workspaceSelectionAuthority(selection).originServerId,
selection.serverId,
selection.workspaceId,
]);
}
export function areWorkspaceSelectionsEqual(
left: ActiveWorkspaceSelection | null,
right: ActiveWorkspaceSelection | null,
): boolean {
return left?.serverId === right?.serverId && left?.workspaceId === right?.workspaceId;
if (!left || !right) return left === right;
return getWorkspaceSelectionKey(left) === getWorkspaceSelectionKey(right);
}
export function areRetainedWorkspaceSelectionListsEqual(
@@ -5,7 +5,7 @@ import {
type InlinePathTarget,
} from "@/assistant-file-links";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { revealWorkspaceTab } from "@/workspace-tabs/navigation";
import { useWorkspaceTabNavigation } from "@/workspace-tabs/navigation";
import type { LayoutChangeEvent } from "react-native";
import { ExplorerSidebarDock } from "@/screens/workspace/explorer-sidebar";
import { ExplorerFrame } from "./explorer-sidebar/frame";
@@ -67,19 +67,17 @@ import { getOrCreateClientId } from "@/utils/client-id";
import { selectIsAgentListOpen, usePanelStore } from "@/stores/panel-store";
import { toggleDesktopSidebarsWithCheckoutIntent } from "@/utils/desktop-sidebar-toggle";
import {
isExplorerSidebarOpen,
openExplorerSidebarView,
toggleExplorerSidebar,
useExplorerSidebarActions,
useIsExplorerSidebarOpen,
} from "@/workspace-tabs/explorer-sidebar";
import {
openPreferredWorkspacePreview,
openPreferredWorkspaceTarget,
openWorkspaceTargetBeside,
} from "@/workspace-tabs/open-beside";
import { openWorkspacePullRequest } from "@/workspace-tabs/open-supporting-view";
import { useWorkspaceTargetActions } from "@/workspace-tabs/open-beside";
import { useSupportingViewActions } from "@/workspace-tabs/open-supporting-view";
import { traceInstant } from "@/performance/native-trace";
import { useSessionStore, type WorkspaceDescriptor } from "@/stores/session-store";
import {
useSessionStore,
useSessionStoreApi,
type WorkspaceDescriptor,
} from "@/stores/session-store";
import {
canDismissPaneInLayout,
collectAllTabs,
@@ -111,7 +109,7 @@ import { useCreateFlowStore } from "@/stores/create-flow-store";
import { normalizeWorkspaceTabTarget, workspaceTabTargetsEqual } from "@/workspace-tabs/identity";
import { useVisibleAgentIds } from "./visible-agent-ids";
import {
getHostRuntimeStore,
useHostRuntimeStore,
useHostRuntimeClient,
useHostRuntimeIsConnected,
useHostRuntimeSnapshot,
@@ -680,12 +678,13 @@ function useWorkspaceRouteActions(normalizedServerId: string): {
handleDismissMissingWorkspace: () => void;
} {
const router = useRouter();
const runtime = useHostRuntimeStore();
const handleRetryHost = useCallback(() => {
if (!normalizedServerId) {
return;
}
void getHostRuntimeStore().runProbeCycleNow(normalizedServerId);
}, [normalizedServerId]);
void runtime.runProbeCycleNow(normalizedServerId);
}, [normalizedServerId, runtime]);
const handleManageHost = useCallback(() => {
if (!normalizedServerId) {
return;
@@ -1018,6 +1017,14 @@ function WorkspaceScreenContent({
recoveryRequested,
}: WorkspaceScreenContentProps) {
const { t } = useTranslation();
const runtime = useHostRuntimeStore();
const sessionStore = useSessionStoreApi();
const { revealWorkspaceTab } = useWorkspaceTabNavigation();
const { openPreferredWorkspacePreview, openPreferredWorkspaceTarget, openWorkspaceTargetBeside } =
useWorkspaceTargetActions();
const { openWorkspacePullRequest } = useSupportingViewActions();
const { isExplorerSidebarOpen, openExplorerSidebarView, toggleExplorerSidebar } =
useExplorerSidebarActions();
const _insets = useSafeAreaInsets();
const toast = useToast();
const isMobile = useIsCompactFormFactor();
@@ -1040,19 +1047,16 @@ function WorkspaceScreenContent({
const workspaceDescriptor = useWorkspace(normalizedServerId, normalizedWorkspaceId);
useEffect(() => {
if (!normalizedServerId || !normalizedWorkspaceId || workspaceDescriptor) return;
void getHostRuntimeStore()
void runtime
.prepareWorkspaceRoute(normalizedServerId, normalizedWorkspaceId)
.catch(() => undefined);
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor]);
}, [normalizedServerId, normalizedWorkspaceId, workspaceDescriptor, runtime]);
// The focused workspace keeps its script status live even when no sidebar or viewed agent
// timeline demands the directory. Retained, hidden workspaces release it.
useEffect(() => {
if (!isRouteFocused || !normalizedServerId || !normalizedWorkspaceId) return;
return getHostRuntimeStore().acquireScriptStatusDemand(
normalizedServerId,
normalizedWorkspaceId,
);
}, [isRouteFocused, normalizedServerId, normalizedWorkspaceId]);
return runtime.acquireScriptStatusDemand(normalizedServerId, normalizedWorkspaceId);
}, [isRouteFocused, normalizedServerId, normalizedWorkspaceId, runtime]);
const workspaceScripts = getWorkspaceScripts(workspaceDescriptor);
const { handleRetryHost, handleManageHost, handleDismissMissingWorkspace } =
useWorkspaceRouteActions(normalizedServerId);
@@ -1362,7 +1366,7 @@ function WorkspaceScreenContent({
isCompact: isMobile,
workspaceKey: persistenceKey,
});
}, [isMobile, persistenceKey]);
}, [isMobile, persistenceKey, toggleExplorerSidebar]);
const paneFocusSuppressedRef = useRef(false);
const resizeWorkspaceSplit = useWorkspaceLayoutStore((state) => state.resizeSplit);
const reorderWorkspaceTabsInPane = useWorkspaceLayoutStore((state) => state.reorderTabsInPane);
@@ -1428,11 +1432,9 @@ function WorkspaceScreenContent({
});
useEffect(() => {
for (const agentId of visibleAgentIds) {
void getHostRuntimeStore()
.prepareAgentTimeline(normalizedServerId, agentId)
.catch(() => undefined);
void runtime.prepareAgentTimeline(normalizedServerId, agentId).catch(() => undefined);
}
}, [normalizedServerId, visibleAgentIds]);
}, [normalizedServerId, visibleAgentIds, runtime]);
useLayoutEffect(() => {
if (!persistenceKey || !viewedTimelineSync) {
return;
@@ -1586,7 +1588,7 @@ function WorkspaceScreenContent({
}
revealWorkspaceTab(persistenceKey, tabId);
},
[persistenceKey],
[persistenceKey, revealWorkspaceTab],
);
const selectTabInPane = useCallback(
(paneId: string, tabId: string) => {
@@ -1715,7 +1717,7 @@ function WorkspaceScreenContent({
requestFileNavigation(tabId);
}
},
[openInSidePane, persistenceKey, requestFileNavigation],
[openInSidePane, persistenceKey, requestFileNavigation, openPreferredWorkspaceTarget],
);
const handleOpenWorkspaceFileFromPane = useStableEvent(function handleOpenWorkspaceFileFromPane({
@@ -1980,7 +1982,7 @@ function WorkspaceScreenContent({
}
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
let closePolicy = resolveCloseAgentTabPolicy(agent);
const isRunning = agent?.status === "running";
@@ -1998,7 +2000,7 @@ function WorkspaceScreenContent({
}
if (closePolicy.kind === "layout-only") {
const sessionClient = useSessionStore.getState().sessions[normalizedServerId]?.client;
const sessionClient = sessionStore.getState().sessions[normalizedServerId]?.client;
if (!sessionClient) {
toast.error(t("common.errors.daemonClientUnavailable"));
return;
@@ -2009,7 +2011,7 @@ function WorkspaceScreenContent({
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
});
const latestAgent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
closePolicy = resolveCloseAgentTabPolicy(latestAgent);
} catch (error) {
console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId });
@@ -2042,6 +2044,7 @@ function WorkspaceScreenContent({
persistenceKey,
t,
toast,
sessionStore,
],
);
@@ -2157,7 +2160,7 @@ function WorkspaceScreenContent({
async (agentId: string) => {
if (!agentId) return;
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
const providerSessionId =
agent?.runtimeInfo?.sessionId ?? agent?.persistence?.sessionId ?? null;
if (!agent || !providerSessionId) {
@@ -2182,7 +2185,7 @@ function WorkspaceScreenContent({
toast.error(t("workspace.tabs.toasts.copyFailed"));
}
},
[normalizedServerId, toast, t],
[normalizedServerId, toast, t, sessionStore],
);
const handleReloadAgent = useCallback(
@@ -2199,9 +2202,9 @@ function WorkspaceScreenContent({
// returns reset:true. Without a cursor, the server returns reset:false
// and the client takes the incremental path, where new-epoch rows are
// dropped against the stale cursor.
const sessionState = useSessionStore.getState().sessions[normalizedServerId];
const sessionState = sessionStore.getState().sessions[normalizedServerId];
const currentCursor = sessionState?.agentTimelineCursor.get(agentId);
await getHostRuntimeStore().fetchAgentTimeline(normalizedServerId, agentId, {
await runtime.fetchAgentTimeline(normalizedServerId, agentId, {
direction: "tail",
projection: "projected",
...(currentCursor
@@ -2215,7 +2218,7 @@ function WorkspaceScreenContent({
);
}
},
[client, isConnected, normalizedServerId, toast, t],
[client, isConnected, normalizedServerId, toast, t, runtime, sessionStore],
);
const handleCopyWorkspacePath = useCallback(async () => {
@@ -2272,7 +2275,7 @@ function WorkspaceScreenContent({
}
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
const agent = sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive";
});
const modifiedCount = tabsToClose.filter(
@@ -2311,7 +2314,7 @@ function WorkspaceScreenContent({
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
});
const latestAgent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
sessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") {
await archiveAgent({ serverId: normalizedServerId, agentId });
}
@@ -2342,6 +2345,7 @@ function WorkspaceScreenContent({
normalizedWorkspaceId,
persistenceKey,
t,
sessionStore,
],
);
@@ -2492,6 +2496,9 @@ function WorkspaceScreenContent({
openWorkspaceTabFocused,
persistenceKey,
pullRequestOpenLocation,
openWorkspaceTargetBeside,
openWorkspacePullRequest,
openExplorerSidebarView,
],
);
@@ -2651,6 +2658,7 @@ function WorkspaceScreenContent({
isGitCheckout,
isMobile,
persistenceKey,
openExplorerSidebarView,
],
);
@@ -2678,7 +2686,7 @@ function WorkspaceScreenContent({
});
return true;
},
[handleToggleExplorerSidebar, isMobile, persistenceKey],
[handleToggleExplorerSidebar, isMobile, persistenceKey, isExplorerSidebarOpen],
);
const handleWorkspacePaneAction = useCallback(
@@ -3030,6 +3038,8 @@ function WorkspaceScreenContent({
setWorkspaceTabState,
explorerSidebarPaneId,
lastMainPaneId,
openPreferredWorkspacePreview,
openWorkspaceTargetBeside,
],
);
const focusedPaneId = useMemo(
@@ -71,3 +71,20 @@ describe("last workspace selection", () => {
});
});
});
it("persists and restores selected origin independently of a destination and distinguishes authority changes", async () => {
const storage = new DelayedWorkspaceSelectionStorage();
const store = createLastWorkspaceSelectionStore(storage);
store.remember({ serverId: "B", workspaceId: "workspace", authority: { originServerId: "B" } });
store.remember({ serverId: "B", workspaceId: "workspace", authority: { originServerId: "A" } });
expect(store.getSelection()).toEqual({
serverId: "B",
workspaceId: "workspace",
authority: { originServerId: "A" },
});
const restored = createLastWorkspaceSelectionStore(storage);
const hydration = restored.hydrate();
storage.finishHydrationWith(storage.getSavedSelection());
await hydration;
expect(restored.getSelection()).toEqual(store.getSelection());
});
@@ -3,6 +3,20 @@ import { z } from "zod";
export interface ActiveWorkspaceSelection {
serverId: string;
workspaceId: string;
authority?: { originServerId: string };
}
/** COMPAT(workspaceRouteOrigin): added in v0.12, remove after 2027-04-20.
* Pre-fleet routes and stored selections name the destination's own authority.
*/
export function workspaceSelectionAuthority(selection: ActiveWorkspaceSelection): {
originServerId: string;
serverId: string;
} {
return {
originServerId: selection.authority?.originServerId ?? selection.serverId,
serverId: selection.serverId,
};
}
export const LAST_WORKSPACE_SELECTION_STORAGE_KEY = "paseo:last-workspace-route-selection";
@@ -16,6 +30,7 @@ export interface LastWorkspaceSelectionStorage {
const ActiveWorkspaceSelectionSchema: z.ZodType<ActiveWorkspaceSelection> = z.strictObject({
serverId: z.string().trim().min(1),
workspaceId: z.string().trim().min(1),
authority: z.strictObject({ originServerId: z.string().trim().min(1) }).optional(),
});
function normalizeWorkspaceSelection(input: unknown): ActiveWorkspaceSelection | null {
@@ -54,7 +69,9 @@ export function createLastWorkspaceSelectionStore(storage: LastWorkspaceSelectio
}
if (
selection?.serverId === normalized.serverId &&
selection.workspaceId === normalized.workspaceId
selection.workspaceId === normalized.workspaceId &&
workspaceSelectionAuthority(selection).originServerId ===
workspaceSelectionAuthority(normalized).originServerId
) {
return;
}
@@ -1,5 +1,5 @@
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useLocalSearchParams, usePathname } from "expo-router";
import { useGlobalSearchParams, useLocalSearchParams, usePathname } from "expo-router";
import { useEffect, useSyncExternalStore } from "react";
import {
createLastWorkspaceSelectionStore,
@@ -75,15 +75,24 @@ export function useActiveWorkspaceSelection(): ActiveWorkspaceSelection | null {
serverId?: string | string[];
workspaceId?: string | string[];
}>();
const selection = parseActiveWorkspaceSelection({ pathname: usePathname(), params });
const { origin } = useGlobalSearchParams<{ origin?: string | string[] }>();
const selection = parseActiveWorkspaceSelection({
pathname: usePathname(),
params: { ...params, origin },
});
const serverId = selection?.serverId ?? null;
const workspaceId = selection?.workspaceId ?? null;
const originServerId = selection?.authority?.originServerId;
useEffect(() => {
if (!serverId || !workspaceId) {
return;
}
lastWorkspaceSelectionStore.remember({ serverId, workspaceId });
}, [serverId, workspaceId]);
lastWorkspaceSelectionStore.remember({
serverId,
workspaceId,
...(originServerId ? { authority: { originServerId } } : {}),
});
}, [serverId, workspaceId, originServerId]);
return selection;
}
@@ -5,6 +5,7 @@ import {
buildHostWorkspaceRoute,
decodeWorkspaceIdFromPathSegment,
parseHostWorkspaceRouteFromPathname,
withHostRouteAuthority,
} from "@/utils/host-routes";
import {
normalizeWorkspaceOpaqueId,
@@ -20,6 +21,7 @@ export interface RouteSelectionInput {
params: {
serverId?: string | string[];
workspaceId?: string | string[];
origin?: string | string[];
};
}
@@ -29,6 +31,7 @@ export interface NavigateToWorkspaceInput {
target?: WorkspaceTabTarget;
pin?: boolean;
placement?: WorkspaceTabPlacement;
authority?: { originServerId: string };
}
export interface NavigateToWorkspaceDeps extends PrepareWorkspaceTabDeps {
@@ -57,6 +60,7 @@ function getParamValue(value: string | string[] | undefined): string {
function parseWorkspaceSelectionFromRouteParams(params: {
serverId?: string | string[];
workspaceId?: string | string[];
origin?: string | string[];
}): ActiveWorkspaceSelection | null {
const serverId = getParamValue(params.serverId);
const workspaceValue = getParamValue(params.workspaceId);
@@ -64,7 +68,8 @@ function parseWorkspaceSelectionFromRouteParams(params: {
if (!serverId || !workspaceId) {
return null;
}
return { serverId, workspaceId };
const originServerId = getParamValue(params.origin);
return { serverId, workspaceId, ...(originServerId ? { authority: { originServerId } } : {}) };
}
export function parseActiveWorkspaceSelection(
@@ -72,7 +77,10 @@ export function parseActiveWorkspaceSelection(
): ActiveWorkspaceSelection | null {
const routeSelection = parseHostWorkspaceRouteFromPathname(input.pathname);
if (routeSelection) {
return routeSelection;
const originServerId = getParamValue(input.params.origin);
return routeSelection.authority || !originServerId
? routeSelection
: { ...routeSelection, authority: { originServerId } };
}
if (input.pathname !== "/" && input.pathname !== "") {
@@ -86,13 +94,16 @@ export function navigateToWorkspace(
input: NavigateToWorkspaceInput,
deps: NavigateToWorkspaceDeps,
): string {
const workspaces = deps.getSessionWorkspaces(input.serverId);
// COMPAT(unboundWorkspaceNavigation): added in v0.12, remove after 2027-04-20.
// Bound routes prepare their intent only after reacquiring the selected authority.
const workspaces = input.authority ? null : deps.getSessionWorkspaces(input.serverId);
const resolvedWorkspaceId = resolveWorkspaceMapKeyByIdentity({
workspaces,
workspaceId: input.workspaceId,
});
const shouldDeferAgentOpen = Boolean(
input.target?.kind === "agent" && (!resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()),
input.target?.kind === "agent" &&
(input.authority || !resolvedWorkspaceId || !deps.isWorkspaceLayoutHydrated()),
);
if (input.target) {
if (!shouldDeferAgentOpen) {
@@ -114,7 +125,7 @@ export function navigateToWorkspace(
}
}
const route =
const baseRoute =
input.target?.kind === "agent" && shouldDeferAgentOpen
? buildHostWorkspaceOpenRoute(
input.serverId,
@@ -122,7 +133,12 @@ export function navigateToWorkspace(
`agent:${input.target.agentId}`,
)
: buildHostWorkspaceRoute(input.serverId, input.workspaceId);
deps.rememberLastWorkspace({ serverId: input.serverId, workspaceId: input.workspaceId });
const route = input.authority ? withHostRouteAuthority(baseRoute, input.authority) : baseRoute;
deps.rememberLastWorkspace({
serverId: input.serverId,
workspaceId: input.workspaceId,
...(input.authority ? { authority: input.authority } : {}),
});
deps.navigateToRoute(route);
return route;
}
@@ -40,7 +40,8 @@ function createTestAgent(agentId: string): Agent {
title: null,
cwd: "/repo",
model: null,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
};
}
+4 -1
View File
@@ -1,3 +1,5 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import type { ManagedAgentRelationship } from "@getpaseo/protocol/messages";
import { createCreateFlowStore, defaultCreateFlowStore } from "@/stores/create-flow-store";
import {
createAgentInitializations,
@@ -103,7 +105,8 @@ export interface Agent {
attentionReason?: "finished" | "error" | "permission" | null;
attentionTimestamp?: Date | null;
archivedAt?: Date | null;
parentAgentId: string | null;
parent: AgentAddress | null;
managedRelationship: ManagedAgentRelationship | null;
labels: Record<string, string>;
projectPlacement?: ProjectPlacementPayload | null;
}
@@ -1,3 +1,4 @@
import type { StateStorage } from "zustand/middleware";
import { z } from "zod";
import type { SplitNode, WorkspaceLayout } from "./workspace-layout-actions";
@@ -113,3 +114,42 @@ export const WorkspaceLayoutPersistedStateSchema = z.strictObject({
// and ignored so upgrading does not discard the layout. Remove after 2027-08-20.
acknowledgedPullRequestByWorkspace: z.record(z.string(), z.string()).optional(),
});
// COMPAT(preFleetDirectLayout): added in v0.12, remove after 2027-04-20.
// Only an existing self-origin layout may seed its new namespace. Once written, the namespace wins.
export function workspaceLayoutStorageWithLegacySeed(
storage: StateStorage,
serverId: string,
): StateStorage {
return {
setItem: (key, value) => storage.setItem(key, value),
removeItem: (key) => storage.removeItem(key),
async getItem(key) {
const current = await storage.getItem(key);
if (current !== null) return current;
const legacy = await storage.getItem("workspace-layout-state");
if (!legacy) return null;
try {
const parsed = z
.object({ state: WorkspaceLayoutPersistedStateSchema, version: z.number() })
.safeParse(JSON.parse(legacy));
if (!parsed.success) return null;
const prefix = `${serverId}:`;
const state = Object.fromEntries(
Object.entries(parsed.data.state).map(([field, records]) => [
field,
records &&
Object.fromEntries(
Object.entries(records).filter(([workspaceKey]) => workspaceKey.startsWith(prefix)),
),
]),
);
const seeded = JSON.stringify({ state, version: parsed.data.version });
await storage.setItem(key, seeded);
return seeded;
} catch {
return null;
}
},
};
}
@@ -1,3 +1,4 @@
import { managedAgentFields } from "@/subagents/test-fixtures";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
vi.mock("@react-native-async-storage/async-storage", () => {
@@ -17,7 +18,7 @@ vi.mock("@react-native-async-storage/async-storage", () => {
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { createSessionStore, useSessionStore, type Agent } from "@/stores/session-store";
import { setPanelInstanceAttributes } from "@/panels/panel-instance-attributes";
import { createViewedTimelineSync } from "@/timeline/viewed-timeline-sync";
import { buildWorkspaceTabPersistenceKey, type WorkspaceTab } from "@/workspace-tabs/model";
@@ -4477,7 +4478,15 @@ describe("compact workspace sheet", () => {
);
}
function seedAgents(agents: Array<Pick<Agent, "id" | "parentAgentId" | "workspaceId">>): void {
function seedAgents(
agents: Array<
Pick<Agent, "id" | "workspaceId"> & {
parentAgentId?: string;
parent?: null;
managedRelationship?: null;
}
>,
): void {
const timestamp = new Date("2026-04-21T10:00:00.000Z");
useSessionStore.getState().initializeSession("server-1", null as unknown as DaemonClient);
useSessionStore.getState().setAgents(
@@ -4521,6 +4530,9 @@ describe("compact workspace sheet", () => {
labels: {},
projectPlacement: null,
...agent,
...managedAgentFields(
agent.parentAgentId ? { serverId: "server-1", agentId: agent.parentAgentId } : null,
),
} satisfies Agent,
]),
),
@@ -4719,7 +4731,7 @@ describe("compact workspace sheet", () => {
it("opens subagents and provider subagents in the sheet but keeps root agents as tabs", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
{ id: "other-workspace-child", parentAgentId: "root", workspaceId: "other-ws" },
]);
@@ -4756,7 +4768,7 @@ describe("compact workspace sheet", () => {
it("gives a sheet target opened from inside the sheet the sheet's own parent", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
]);
const store = createCompactStore();
@@ -4785,9 +4797,9 @@ describe("compact workspace sheet", () => {
it("closes the sheet and reveals the tab when a tab target is opened from inside the sheet", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
{ id: "second-root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "second-root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
]);
const store = createCompactStore();
const rootTabId = store
@@ -4963,7 +4975,7 @@ describe("compact workspace sheet", () => {
it("closes the sheet when its agent leaves the workspace", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
]);
const store = createCompactStore();
@@ -4990,7 +5002,7 @@ describe("compact workspace sheet", () => {
it("drops a departed agent's sheet from the middle of the stack", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
]);
const store = createCompactStore();
@@ -5018,7 +5030,7 @@ describe("compact workspace sheet", () => {
it("closes the sheet when its agent stops being a subagent, leaving one tab for it", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
]);
const store = createCompactStore();
@@ -5041,8 +5053,8 @@ describe("compact workspace sheet", () => {
expect(topSheet(store)?.tab.tabId).toBe(sheetTabId);
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
{ id: "child", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
]);
store.getState().reconcileTabs(workspaceKey, snapshot);
@@ -5057,7 +5069,7 @@ describe("compact workspace sheet", () => {
it("re-resolves the sheet policy when the sheet tab gets another target", () => {
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
]);
const store = createCompactStore();
@@ -5111,10 +5123,35 @@ describe("compact workspace sheet", () => {
).toBe(tabId);
});
it("uses its authority's membership for compact placement beside an equal direct address", () => {
seedAgents([
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
]);
const boundStore = createSessionStore();
boundStore.getState().initializeSession("server-1", null as unknown as DaemonClient);
boundStore
.getState()
.setAgents("server-1", new Map(useSessionStore.getState().sessions["server-1"].agents));
const directChild = useSessionStore.getState().sessions["server-1"].agents.get("child")!;
useSessionStore
.getState()
.setAgents("server-1", new Map([["child", { ...directChild, managedRelationship: null }]]));
const layout = createWorkspaceLayoutStore(undefined, undefined, { sessionStore: boundStore });
layout.getState().setLayoutMode("compact");
const input = {
workspaceKey,
target: { kind: "agent" as const, agentId: "child" },
intent: "reveal" as const,
};
expect(layout.getState().opensInSheet(input)).toBe(true);
expect(createCompactStore().getState().opensInSheet(input)).toBe(false);
});
it("keeps a chat shown in the sheet open for timeline sync until the sheet closes", () => {
const store = createCompactStore();
seedAgents([
{ id: "root", parentAgentId: null, workspaceId: "sheet-ws" },
{ id: "root", parent: null, managedRelationship: null, workspaceId: "sheet-ws" },
{ id: "child", parentAgentId: "root", workspaceId: "sheet-ws" },
]);
const received: string[][] = [];
@@ -5131,3 +5168,105 @@ describe("compact workspace sheet", () => {
expect(received).toEqual([[], ["child"], []]);
});
});
it("persists independent layouts for equal destination workspaces under separate authority namespaces", async () => {
const values = new Map<string, string>();
const storage = {
getItem: async (key: string) => values.get(key) ?? null,
setItem: async (key: string, value: string) => {
values.set(key, value);
},
removeItem: async (key: string) => {
values.delete(key);
},
};
const direct = createWorkspaceLayoutStore(undefined, storage, { namespace: "B-B" });
const routed = createWorkspaceLayoutStore(undefined, storage, { namespace: "A-B" });
await Promise.all([direct.persist.rehydrate(), routed.persist.rehydrate()]);
direct.getState().openTab({
workspaceKey: "B:workspace",
target: { kind: "agent", agentId: "direct" },
intent: "reveal",
});
routed.getState().openTab({
workspaceKey: "B:workspace",
target: { kind: "agent", agentId: "routed" },
intent: "reveal",
});
const reopenedDirect = createWorkspaceLayoutStore(undefined, storage, { namespace: "B-B" });
await reopenedDirect.persist.rehydrate();
const reopened = createWorkspaceLayoutStore(undefined, storage, { namespace: "A-B" });
await reopened.persist.rehydrate();
expect(
reopenedDirect
.getState()
.getWorkspaceTabs("B:workspace")
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.target),
).toEqual([{ kind: "agent", agentId: "direct" }]);
expect(
reopened
.getState()
.getWorkspaceTabs("B:workspace")
.filter((tab) => tab.target.kind === "agent")
.map((tab) => tab.target),
).toEqual([{ kind: "agent", agentId: "routed" }]);
});
it("upgrades pre-feature direct layout once without importing another server's tabs", async () => {
const values = new Map<string, string>();
const storage = {
getItem: async (key: string) => values.get(key) ?? null,
setItem: async (key: string, value: string) => {
values.set(key, value);
},
removeItem: async (key: string) => {
values.delete(key);
},
};
const legacy = createWorkspaceLayoutStore(undefined, storage);
await legacy.persist.rehydrate();
legacy.getState().openTab({
workspaceKey: "B:workspace",
target: { kind: "agent", agentId: "saved" },
intent: "reveal",
});
legacy.getState().openTab({
workspaceKey: "C:workspace",
target: { kind: "agent", agentId: "unrelated" },
intent: "reveal",
});
const upgraded = createWorkspaceLayoutStore(undefined, storage, {
namespace: "direct-B",
legacyServerId: "B",
});
await upgraded.persist.rehydrate();
expect(
upgraded
.getState()
.getWorkspaceTabs("B:workspace")
.some((tab) => tab.target.kind === "agent" && tab.target.agentId === "saved"),
).toBe(true);
expect(upgraded.getState().layoutByWorkspace["C:workspace"]).toBeUndefined();
upgraded.getState().openTab({
workspaceKey: "B:workspace",
target: { kind: "agent", agentId: "new" },
intent: "reveal",
});
legacy.getState().openTab({
workspaceKey: "B:workspace",
target: { kind: "agent", agentId: "later-global" },
intent: "reveal",
});
const restored = createWorkspaceLayoutStore(undefined, storage, {
namespace: "direct-B",
legacyServerId: "B",
});
await restored.persist.rehydrate();
expect(
restored
.getState()
.getWorkspaceTabs("B:workspace")
.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
).toEqual(["saved", "new"]);
});
@@ -1,9 +1,13 @@
import { useSessionStore, type SessionStoreApi } from "@/stores/session-store";
import AsyncStorage from "@react-native-async-storage/async-storage";
import { useEffect, useState } from "react";
import { create } from "zustand";
import { createContext, useContext, useEffect, useState } from "react";
import { create, useStore } from "zustand";
import { persist, type StateStorage } from "zustand/middleware";
import type { z } from "zod";
import { WorkspaceLayoutPersistedStateSchema } from "./workspace-layout-storage";
import {
WorkspaceLayoutPersistedStateSchema,
workspaceLayoutStorageWithLegacySeed,
} from "./workspace-layout-storage";
import type { JsonValue } from "@getpaseo/protocol/agent-types";
import type { WorkspaceTab, WorkspaceTabTarget } from "@/workspace-tabs/model";
import {
@@ -763,12 +767,13 @@ function resolveSheetPolicy(
workspaceKey: string,
target: WorkspaceTabTarget,
intent: WorkspaceTabOpenIntent,
policy: typeof resolveCompactSheetPolicy,
): CompactSheetPolicy | null {
if (state.layoutMode !== "compact" || intent === "background") return null;
if (intent === "reveal" && isTargetOpenAsTab(state.layoutByWorkspace[workspaceKey], target)) {
return null;
}
return resolveCompactSheetPolicy(workspaceKey, target);
return policy(workspaceKey, target);
}
/**
@@ -880,8 +885,9 @@ function resolveSheetPlacement(
workspaceKey: string,
target: WorkspaceTabTarget,
intent: WorkspaceTabOpenIntent,
resolvePolicy: typeof resolveCompactSheetPolicy,
): { policy: CompactSheetPolicy; keptIndex: number | null } | null {
const policy = resolveSheetPolicy(state, workspaceKey, target, intent);
const policy = resolveSheetPolicy(state, workspaceKey, target, intent, resolvePolicy);
if (!policy) return null;
if (intent !== "reveal") return { policy, keptIndex: null };
const sheets = getSheets(state, workspaceKey);
@@ -926,10 +932,11 @@ function sheetAgentNoLongerFits(
workspaceKey: string,
sheet: SheetTab,
snapshot: WorkspaceTabSnapshot,
policy: typeof resolveCompactSheetPolicy,
): boolean {
if (sheet.tab.target.kind !== "agent" || !snapshot.agentsHydrated) return false;
if (!new Set(snapshot.activeAgentIds).has(sheet.tab.target.agentId)) return true;
return resolveCompactSheetPolicy(workspaceKey, sheet.tab.target) === null;
return policy(workspaceKey, sheet.tab.target) === null;
}
/**
@@ -997,7 +1004,10 @@ function createPersistedStateProjection() {
export function createWorkspaceLayoutStore(
ids: WorkspaceLayoutIdSource = defaultWorkspaceLayoutIds,
storage: StateStorage = AsyncStorage,
persistence: { namespace?: string; legacyServerId?: string; sessionStore?: SessionStoreApi } = {},
) {
const compactSheetPolicy: typeof resolveCompactSheetPolicy = (workspaceKey, target) =>
resolveCompactSheetPolicy(workspaceKey, target, persistence.sessionStore ?? useSessionStore);
return create<WorkspaceLayoutStore>()(
persist(
(set, get) => ({
@@ -1050,6 +1060,7 @@ export function createWorkspaceLayoutStore(
normalizedWorkspaceKey,
normalizedTarget,
input.intent,
compactSheetPolicy,
);
if (sheetPlacement) {
const sheets = getSheets(get(), normalizedWorkspaceKey);
@@ -1163,8 +1174,13 @@ export function createWorkspaceLayoutStore(
const normalizedTarget = normalizeWorkspaceTabTarget(input.target);
if (!normalizedWorkspaceKey || !normalizedTarget) return false;
return (
resolveSheetPlacement(get(), normalizedWorkspaceKey, normalizedTarget, input.intent) !==
null
resolveSheetPlacement(
get(),
normalizedWorkspaceKey,
normalizedTarget,
input.intent,
compactSheetPolicy,
) !== null
);
},
setLayoutMode: (mode) => {
@@ -1493,7 +1509,7 @@ export function createWorkspaceLayoutStore(
const normalizedTarget = normalizeWorkspaceTabTarget(target);
if (!normalizedWorkspaceKey || !normalizedTabId || !normalizedTarget) return null;
if (findSheet(get(), normalizedWorkspaceKey, normalizedTabId)) {
const policy = resolveCompactSheetPolicy(normalizedWorkspaceKey, normalizedTarget);
const policy = compactSheetPolicy(normalizedWorkspaceKey, normalizedTarget);
const next: WorkspaceLayoutStore = {
...get(),
...withSheetTab(get(), normalizedWorkspaceKey, normalizedTabId, (sheet) => ({
@@ -1650,7 +1666,13 @@ export function createWorkspaceLayoutStore(
}
const sheets = getSheets(state, normalizedWorkspaceKey);
const fittingSheets = sheets.filter(
(sheet) => !sheetAgentNoLongerFits(normalizedWorkspaceKey, sheet, snapshot),
(sheet) =>
!sheetAgentNoLongerFits(
normalizedWorkspaceKey,
sheet,
snapshot,
compactSheetPolicy,
),
);
const sheetAgentGone = fittingSheets.length !== sheets.length;
if (
@@ -2213,11 +2235,19 @@ export function createWorkspaceLayoutStore(
},
}),
{
name: "workspace-layout-state",
name: persistence.namespace
? `workspace-layout-state:${persistence.namespace}`
: "workspace-layout-state",
version: WORKSPACE_LAYOUT_PERSIST_VERSION,
storage: createValidatedPersistStorage(storage, WorkspaceLayoutPersistedStateSchema, {
skipUnchanged: true,
}),
storage: createValidatedPersistStorage(
persistence.legacyServerId
? workspaceLayoutStorageWithLegacySeed(storage, persistence.legacyServerId)
: storage,
WorkspaceLayoutPersistedStateSchema,
{
skipUnchanged: true,
},
),
migrate: (persistedState, version) =>
migrateWorkspaceLayoutPersistedState(persistedState, version, ids),
partialize: createPersistedStateProjection(),
@@ -2280,7 +2310,19 @@ export function createWorkspaceLayoutStore(
);
}
export const useWorkspaceLayoutStore = createWorkspaceLayoutStore();
export type WorkspaceLayoutStoreApi = ReturnType<typeof createWorkspaceLayoutStore>;
export const defaultWorkspaceLayoutStore = createWorkspaceLayoutStore();
export const WorkspaceLayoutStoreContext = createContext<WorkspaceLayoutStoreApi | null>(null);
export function useWorkspaceLayoutStoreApi(): WorkspaceLayoutStoreApi {
return useContext(WorkspaceLayoutStoreContext) ?? defaultWorkspaceLayoutStore;
}
// COMPAT(unboundWorkspaceLayout): added in v0.12, remove after 2027-04-20.
// Existing app-shell callers retain the default store; bound consumers capture their owner.
export const useWorkspaceLayoutStore = Object.assign(function useWorkspaceLayoutStore<T>(
selector: (state: WorkspaceLayoutStore) => T,
): T {
return useStore(useWorkspaceLayoutStoreApi(), selector);
}, defaultWorkspaceLayoutStore);
/**
* The agent tabs that exist right now, including sheet tabs, across every workspace of this host,
@@ -2331,20 +2373,19 @@ export function observeOpenWorkspaceAgentIds(
}
export function useWorkspaceLayoutStoreHydrated(): boolean {
const [hasHydrated, setHasHydrated] = useState(() =>
useWorkspaceLayoutStore.persist.hasHydrated(),
);
const store = useWorkspaceLayoutStoreApi();
const [hasHydrated, setHasHydrated] = useState(() => store.persist.hasHydrated());
useEffect(() => {
if (useWorkspaceLayoutStore.persist.hasHydrated()) {
if (store.persist.hasHydrated()) {
setHasHydrated(true);
return;
}
return useWorkspaceLayoutStore.persist.onFinishHydration(() => {
return store.persist.onFinishHydration(() => {
setHasHydrated(true);
});
}, []);
}, [store]);
return hasHydrated;
}
@@ -1,3 +1,4 @@
import { managedAgentFields } from "@/subagents/test-fixtures";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
@@ -7,6 +8,7 @@ import {
} from "@/workspace-tabs/agent-visibility";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { selectSubagentsForParent } from "@/subagents/select";
import { membershipFixture, relationshipFixture } from "@/subagents/test-fixtures";
import { findPaneById, findPaneContainingTab } from "./workspace-layout-actions";
import { useWorkspaceLayoutStore } from "./workspace-layout-store";
import { useSessionStore, type Agent } from "./session-store";
@@ -67,7 +69,8 @@ const AGENT_DEFAULTS: Agent = {
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
projectPlacement: null,
};
@@ -142,7 +145,7 @@ describe("workspace subagents integration", () => {
const child = makeAgent({
id: "child-agent",
parentAgentId: "parent-agent",
...managedAgentFields({ serverId: SERVER_ID, agentId: "parent-agent" }),
title: "Child agent",
});
const parent = makeAgent({
@@ -163,13 +166,12 @@ describe("workspace subagents integration", () => {
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-agent",
},
membershipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, [
relationshipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, child),
]),
{ originServerId: SERVER_ID, serverId: SERVER_ID },
new Set(),
).map((row) => row.id),
).map((row) => row.address.agentId),
).toEqual(["child-agent"]);
});
@@ -186,7 +188,7 @@ describe("workspace subagents integration", () => {
});
const child = makeAgent({
id: "child-agent",
parentAgentId: "parent-agent",
...managedAgentFields({ serverId: SERVER_ID, agentId: "parent-agent" }),
title: "Child agent",
});
@@ -196,16 +198,15 @@ describe("workspace subagents integration", () => {
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_parent-agent"]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-agent",
},
membershipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, [
relationshipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, child),
]),
{ originServerId: SERVER_ID, serverId: SERVER_ID },
new Set(),
).map((row) => row.id),
).map((row) => row.address.agentId),
).toEqual(["child-agent"]);
appendAgent({ ...child, parentAgentId: null, labels: {} });
appendAgent({ ...child, parent: null, managedRelationship: null, labels: {} });
reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession());
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual([
@@ -214,11 +215,12 @@ describe("workspace subagents integration", () => {
]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-agent",
},
membershipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, [
relationshipFixture({ serverId: SERVER_ID, agentId: "parent-agent" }, child, {
phase: "detached",
}),
]),
{ originServerId: SERVER_ID, serverId: SERVER_ID },
new Set(),
),
).toEqual([]);
@@ -238,7 +240,7 @@ describe("workspace subagents integration", () => {
});
const child = makeAgent({
id: "child-agent",
parentAgentId: parent.id,
...managedAgentFields({ serverId: SERVER_ID, agentId: parent.id }),
title: "Cross-workspace child",
});
@@ -248,13 +250,12 @@ describe("workspace subagents integration", () => {
expect(getWorkspaceAgentTabIds(workspaceKey!)).toEqual(["agent_child-agent"]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: parent.id,
},
membershipFixture({ serverId: SERVER_ID, agentId: parent.id }, [
relationshipFixture({ serverId: SERVER_ID, agentId: parent.id }, child),
]),
{ originServerId: SERVER_ID, serverId: SERVER_ID },
new Set(),
).map((row) => row.id),
).map((row) => row.address.agentId),
).toEqual([child.id]);
});
@@ -271,7 +272,7 @@ describe("workspace subagents integration", () => {
});
const child = makeAgent({
id: "child-agent",
parentAgentId: parent.id,
...managedAgentFields({ serverId: SERVER_ID, agentId: parent.id }),
title: "Child agent",
});
@@ -2,10 +2,20 @@ import { describe, expect, it } from "vitest";
import { createArchiveFinishedSubagents, type ManagedSubagentSnapshot } from "./archive-finished";
import type { PaseoSubagentRow, ProviderSubagentRow } from "./select";
const address = (agentId: string) => ({ serverId: "server-1", agentId });
const parent = address("parent");
function paseo(id: string, status: PaseoSubagentRow["status"] = "idle"): PaseoSubagentRow {
return {
kind: "paseo",
id,
facts: null,
address: address(id),
authority: { originServerId: "server-1", serverId: "server-1" },
relationshipId: id,
workspace: null,
freshness: "current",
phase: "attached",
error: null,
provider: "codex",
title: id,
description: null,
@@ -44,9 +54,11 @@ function managed(
overrides: Partial<ManagedSubagentSnapshot> = {},
): ManagedSubagentSnapshot {
return {
id,
address: address(id),
status,
parentAgentId: "parent",
freshness: "current",
phase: "attached",
parent,
archivedAt: null,
...overrides,
};
@@ -75,9 +87,9 @@ describe("createArchiveFinishedSubagents", () => {
const archive = createArchiveFinishedSubagents(
[provider("native"), paseo("first"), paseo("second", "error"), paseo("resumed")],
{
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: (id) => {
parent,
getManagedSubagent: (child) => current.get(child.agentId),
archiveManagedSubagent: ({ agentId: id }) => {
events.push(`archive:${id}`);
return id === "first" ? first.promise : second.promise;
},
@@ -109,9 +121,9 @@ describe("createArchiveFinishedSubagents", () => {
second.resolve();
await expect(operation).resolves.toEqual({
archivedPaseoIds: ["first", "second"],
archivedPaseoAgents: [address("first"), address("second")],
dismissedProviderIds: ["native"],
skippedPaseoIds: ["resumed"],
skippedPaseoAgents: [address("resumed")],
failures: [],
});
expect(progress).toEqual(["0/4", "1/4", "2/4", "3/4", "4/4"]);
@@ -129,9 +141,9 @@ describe("createArchiveFinishedSubagents", () => {
const responses = [first.promise, second.promise, retry.promise];
const calls: string[] = [];
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: (id) => {
parent,
getManagedSubagent: (child) => current.get(child.agentId),
archiveManagedSubagent: ({ agentId: id }) => {
calls.push(id);
const response = responses.shift();
if (!response) throw new Error("Unexpected archive");
@@ -148,10 +160,10 @@ describe("createArchiveFinishedSubagents", () => {
second.resolve();
archive.setRows([]);
await expect(firstAttempt).resolves.toEqual({
archivedPaseoIds: ["second"],
archivedPaseoAgents: [address("second")],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [{ id: "first", error: firstError }],
skippedPaseoAgents: [],
failures: [{ address: address("first"), error: firstError }],
});
expect(archive.getState()).toEqual({
@@ -181,10 +193,10 @@ describe("createArchiveFinishedSubagents", () => {
const retryError = new Error("retry failed");
retry.reject(retryError);
await expect(retryAttempt).resolves.toEqual({
archivedPaseoIds: [],
archivedPaseoAgents: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [{ id: "first", error: retryError }],
skippedPaseoAgents: [],
failures: [{ address: address("first"), error: retryError }],
});
expect(archive.getState()).toEqual({
@@ -203,9 +215,9 @@ describe("createArchiveFinishedSubagents", () => {
["second", managed("second")],
]);
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async (id) => {
parent,
getManagedSubagent: (child) => current.get(child.agentId),
archiveManagedSubagent: async ({ agentId: id }) => {
throw id === "first" ? firstError : secondError;
},
dismissProviderSubagents: () => undefined,
@@ -229,8 +241,8 @@ describe("createArchiveFinishedSubagents", () => {
it("clears retry state when the authoritative failed child is running", async () => {
const current = new Map([["failed", managed("failed")]]);
const archive = createArchiveFinishedSubagents([paseo("failed")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
parent,
getManagedSubagent: (child) => current.get(child.agentId),
archiveManagedSubagent: async () => {
current.set("failed", managed("failed", "running"));
throw new Error("archive failed");
@@ -245,9 +257,9 @@ describe("createArchiveFinishedSubagents", () => {
expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } });
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: [],
archivedPaseoAgents: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
skippedPaseoAgents: [],
failures: [],
});
});
@@ -255,8 +267,8 @@ describe("createArchiveFinishedSubagents", () => {
it("clears retry state when the authoritative failed child is initializing", async () => {
const current = new Map([["failed", managed("failed")]]);
const archive = createArchiveFinishedSubagents([paseo("failed")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
parent,
getManagedSubagent: (child) => current.get(child.agentId),
archiveManagedSubagent: async () => {
current.set("failed", managed("failed", "initializing"));
throw new Error("archive failed");
@@ -271,9 +283,9 @@ describe("createArchiveFinishedSubagents", () => {
expect(archive.getState()).toEqual({ eligibleCount: 0, status: { kind: "idle" } });
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: [],
archivedPaseoAgents: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
skippedPaseoAgents: [],
failures: [],
});
});
@@ -288,7 +300,7 @@ describe("createArchiveFinishedSubagents", () => {
[
"belongs to a different parent",
(current: Map<string, ManagedSubagentSnapshot>) =>
current.set("failed", managed("failed", "idle", { parentAgentId: "other-parent" })),
current.set("failed", managed("failed", "idle", { parent: address("other-parent") })),
],
])(
"clears retry state when the authoritative failed child %s",
@@ -296,8 +308,8 @@ describe("createArchiveFinishedSubagents", () => {
const error = new Error("archive failed");
const current = new Map([["failed", managed("failed")]]);
const archive = createArchiveFinishedSubagents([paseo("failed")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
parent,
getManagedSubagent: (child) => current.get(child.agentId),
archiveManagedSubagent: async () => {
updateSnapshot(current);
throw error;
@@ -306,10 +318,10 @@ describe("createArchiveFinishedSubagents", () => {
});
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: [],
archivedPaseoAgents: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
failures: [{ id: "failed", error }],
skippedPaseoAgents: [],
failures: [{ address: address("failed"), error }],
});
expect(archive.getState()).toEqual({ eligibleCount: 1, status: { kind: "idle" } });
},
@@ -324,9 +336,9 @@ describe("createArchiveFinishedSubagents", () => {
]);
const calls: string[] = [];
const archive = createArchiveFinishedSubagents([paseo("first"), paseo("second")], {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async (id) => {
parent,
getManagedSubagent: (child) => current.get(child.agentId),
archiveManagedSubagent: async ({ agentId: id }) => {
calls.push(id);
if (calls.length === 1) throw firstError;
if (calls.length === 2) {
@@ -340,12 +352,12 @@ describe("createArchiveFinishedSubagents", () => {
const operation = archive.archiveFinished();
archive.setRows([]);
await expect(operation).resolves.toEqual({
archivedPaseoIds: [],
archivedPaseoAgents: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
skippedPaseoAgents: [],
failures: [
{ id: "first", error: firstError },
{ id: "second", error: secondError },
{ address: address("first"), error: firstError },
{ address: address("second"), error: secondError },
],
});
archive.setRows([paseo("first"), paseo("second", "running")]);
@@ -355,9 +367,9 @@ describe("createArchiveFinishedSubagents", () => {
status: { kind: "failed", failedCount: 1, totalCount: 1 },
});
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: ["first"],
archivedPaseoAgents: [address("first")],
dismissedProviderIds: [],
skippedPaseoIds: [],
skippedPaseoAgents: [],
failures: [],
});
expect(calls).toEqual(["first", "second", "first"]);
@@ -377,15 +389,15 @@ describe("createArchiveFinishedSubagents", () => {
["first", managed("first")],
["resumed", managed("resumed")],
["archived", managed("archived", "idle", { archivedAt: new Date() })],
["reparented", managed("reparented", "idle", { parentAgentId: "other-parent" })],
["reparented", managed("reparented", "idle", { parent: address("other-parent") })],
["running", managed("running", "running")],
["initializing", managed("initializing", "initializing")],
]);
const archived: string[] = [];
const archive = createArchiveFinishedSubagents(rows, {
parentAgentId: "parent",
getManagedSubagent: (id) => current.get(id),
archiveManagedSubagent: async (id) => {
parent,
getManagedSubagent: (child) => current.get(child.agentId),
archiveManagedSubagent: async ({ agentId: id }) => {
archived.push(id);
current.set("resumed", managed("resumed", "running"));
},
@@ -393,9 +405,16 @@ describe("createArchiveFinishedSubagents", () => {
});
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: ["first"],
archivedPaseoAgents: [address("first")],
dismissedProviderIds: [],
skippedPaseoIds: ["resumed", "missing", "archived", "reparented", "running", "initializing"],
skippedPaseoAgents: [
address("resumed"),
address("missing"),
address("archived"),
address("reparented"),
address("running"),
address("initializing"),
],
failures: [],
});
@@ -412,16 +431,16 @@ describe("createArchiveFinishedSubagents", () => {
]);
const dismissed: string[][] = [];
const archive = createArchiveFinishedSubagents([...descriptors.values()], {
parentAgentId: "parent",
parent,
getManagedSubagent: () => undefined,
archiveManagedSubagent: async () => undefined,
dismissProviderSubagents: (ids) => dismissed.push(ids),
});
await expect(archive.archiveFinished()).resolves.toEqual({
archivedPaseoIds: [],
archivedPaseoAgents: [],
dismissedProviderIds: ["finished"],
skippedPaseoIds: [],
skippedPaseoAgents: [],
failures: [],
});
@@ -430,3 +449,17 @@ describe("createArchiveFinishedSubagents", () => {
expect(descriptors.get("running")).toBe(running);
});
});
it("does not treat unavailable cached idle or unresolved lifecycle as current completion", () => {
const stale = { ...paseo("cached-idle"), freshness: "unavailable" as const };
const pending = { ...paseo("pending"), phase: "detach_pending" as const };
const archive = createArchiveFinishedSubagents([stale, pending], {
parent,
getManagedSubagent: () => undefined,
archiveManagedSubagent: async () => {
throw new Error("Must not dispatch");
},
dismissProviderSubagents: () => undefined,
});
expect(archive.getState().eligibleCount).toBe(0);
});
+41 -24
View File
@@ -1,4 +1,6 @@
import type { Agent } from "@/stores/session-store";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import type { SubagentRelationship } from "@getpaseo/protocol/messages";
import type { SubagentRow } from "./select";
export type ArchiveFinishedStatus =
@@ -12,18 +14,25 @@ export interface ArchiveFinishedState {
}
export interface ArchiveFinishedOutcome {
archivedPaseoIds: string[];
archivedPaseoAgents: AgentAddress[];
dismissedProviderIds: string[];
skippedPaseoIds: string[];
failures: Array<{ id: string; error: unknown }>;
skippedPaseoAgents: AgentAddress[];
failures: Array<{ address: AgentAddress; error: unknown }>;
}
export type ManagedSubagentSnapshot = Pick<Agent, "id" | "status" | "parentAgentId" | "archivedAt">;
export interface ManagedSubagentSnapshot {
address: AgentAddress;
parent: AgentAddress;
status: Agent["status"] | null;
freshness: SubagentRelationship["freshness"];
phase: SubagentRelationship["phase"];
archivedAt: Agent["archivedAt"];
}
export interface ArchiveFinishedSubagentsDeps {
parentAgentId: string;
getManagedSubagent: (id: string) => ManagedSubagentSnapshot | undefined;
archiveManagedSubagent: (id: string) => Promise<void>;
parent: AgentAddress;
getManagedSubagent: (address: AgentAddress) => ManagedSubagentSnapshot | undefined;
archiveManagedSubagent: (address: AgentAddress) => Promise<void>;
dismissProviderSubagents: (ids: string[]) => void;
}
@@ -35,18 +44,26 @@ export interface ArchiveFinishedSubagents {
}
export function isFinishedSubagent(row: SubagentRow): boolean {
if (row.kind === "paseo") return row.status === "idle" || row.status === "error";
if (row.kind === "paseo")
return (
row.freshness === "current" &&
row.phase === "attached" &&
(row.status === "idle" || row.status === "error")
);
return row.status === "completed" || row.status === "failed" || row.status === "canceled";
}
function canArchiveManagedSubagent(
agent: ManagedSubagentSnapshot | undefined,
parentAgentId: string,
parent: AgentAddress,
): boolean {
return Boolean(
agent &&
agent.freshness === "current" &&
agent.phase === "attached" &&
!agent.archivedAt &&
agent.parentAgentId === parentAgentId &&
agent.parent.serverId === parent.serverId &&
agent.parent.agentId === parent.agentId &&
(agent.status === "idle" || agent.status === "error"),
);
}
@@ -55,12 +72,12 @@ function eligibleSignature(rows: readonly SubagentRow[]): string {
return identitySignature(rowIdentities(rows));
}
function managedRowIdentity(id: string): string {
return `paseo:${id}`;
function managedRowIdentity(address: AgentAddress): string {
return JSON.stringify(["paseo", address.serverId, address.agentId]);
}
function rowIdentity(row: SubagentRow): string {
return row.kind === "paseo" ? managedRowIdentity(row.id) : `provider:${row.id}`;
return row.kind === "paseo" ? managedRowIdentity(row.address) : `provider:${row.id}`;
}
function rowIdentities(rows: readonly SubagentRow[]): Set<string> {
@@ -73,9 +90,9 @@ function identitySignature(ids: ReadonlySet<string>): string {
function emptyOutcome(): ArchiveFinishedOutcome {
return {
archivedPaseoIds: [],
archivedPaseoAgents: [],
dismissedProviderIds: [],
skippedPaseoIds: [],
skippedPaseoAgents: [],
failures: [],
};
}
@@ -169,7 +186,7 @@ async function runArchiveFinished(
reportProgress: (completedCount: number) => void,
): Promise<{ outcome: ArchiveFinishedOutcome; retryableFailureIds: Set<string> }> {
const providerIds = rows.filter((row) => row.kind === "provider").map((row) => row.id);
const paseoIds = rows.filter((row) => row.kind === "paseo").map((row) => row.id);
const paseoAddresses = rows.filter((row) => row.kind === "paseo").map((row) => row.address);
let completedCount = 0;
const outcome = emptyOutcome();
const retryableFailureIds = new Set<string>();
@@ -181,19 +198,19 @@ async function runArchiveFinished(
reportProgress(completedCount);
}
for (const id of paseoIds) {
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
for (const address of paseoAddresses) {
if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parent)) {
try {
await deps.archiveManagedSubagent(id);
outcome.archivedPaseoIds.push(id);
await deps.archiveManagedSubagent(address);
outcome.archivedPaseoAgents.push(address);
} catch (error) {
outcome.failures.push({ id, error });
if (canArchiveManagedSubagent(deps.getManagedSubagent(id), deps.parentAgentId)) {
retryableFailureIds.add(managedRowIdentity(id));
outcome.failures.push({ address, error });
if (canArchiveManagedSubagent(deps.getManagedSubagent(address), deps.parent)) {
retryableFailureIds.add(managedRowIdentity(address));
}
}
} else {
outcome.skippedPaseoIds.push(id);
outcome.skippedPaseoAgents.push(address);
}
completedCount += 1;
reportProgress(completedCount);
@@ -42,7 +42,7 @@ function createFakeEnv(
subagents.set(id, snapshot);
},
deps: {
getSubagent: (id) => subagents.get(id),
getSubagent: (child) => subagents.get(child.agentId),
confirm: async (dialog) => {
recordedConfirmInputs.push(dialog);
return options.confirmResult ?? false;
@@ -132,7 +132,10 @@ describe("requestArchiveSubagent", () => {
],
});
await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
await requestArchiveSubagent(
{ child: { serverId: "server-1", agentId: "child-agent" } },
env.deps,
);
expect(env.recordedArchives).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
});
@@ -148,7 +151,10 @@ describe("requestArchiveSubagent", () => {
],
});
await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
await requestArchiveSubagent(
{ child: { serverId: "server-1", agentId: "child-agent" } },
env.deps,
);
expect(env.recordedArchives).toEqual([]);
});
@@ -164,7 +170,10 @@ describe("requestArchiveSubagent", () => {
],
});
await requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
await requestArchiveSubagent(
{ child: { serverId: "server-1", agentId: "child-agent" } },
env.deps,
);
expect(env.recordedConfirmInputs).toEqual([
resolveArchiveSubagentDialog({ title: "Review branch", status: "running" }),
@@ -174,7 +183,7 @@ describe("requestArchiveSubagent", () => {
it("asks with the missing-subagent dialog when the snapshot is unknown", async () => {
const env = createFakeEnv({ confirmResult: false });
await requestArchiveSubagent({ serverId: "server-1", subagentId: "missing" }, env.deps);
await requestArchiveSubagent({ child: { serverId: "server-1", agentId: "missing" } }, env.deps);
expect(env.recordedConfirmInputs).toEqual([
resolveArchiveSubagentDialog({ title: undefined, status: undefined }),
@@ -198,7 +207,7 @@ describe("requestArchiveSubagent", () => {
};
await expect(
requestArchiveSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
requestArchiveSubagent({ child: { serverId: "server-1", agentId: "child-agent" } }, env.deps),
).resolves.toBeUndefined();
expect(env.recordedErrors).toEqual([error]);
});
@@ -1,3 +1,4 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import type { Agent } from "@/stores/session-store";
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
@@ -38,22 +39,21 @@ export function resolveArchiveSubagentDialog(
}
export interface ArchiveSubagentDeps {
getSubagent: (subagentId: string) => ResolveArchiveSubagentDialogInput | undefined;
getSubagent: (child: AgentAddress) => ResolveArchiveSubagentDialogInput | undefined;
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
archiveAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
archiveAgent: (input: AgentAddress) => Promise<void>;
reportError: (error: unknown) => void;
}
export interface RequestArchiveSubagentInput {
serverId: string;
subagentId: string;
child: AgentAddress;
}
export async function requestArchiveSubagent(
input: RequestArchiveSubagentInput,
deps: ArchiveSubagentDeps,
): Promise<void> {
const subagent = deps.getSubagent(input.subagentId);
const subagent = deps.getSubagent(input.child);
const confirmed = await deps.confirm(
resolveArchiveSubagentDialog({
title: subagent?.title,
@@ -64,7 +64,7 @@ export async function requestArchiveSubagent(
return;
}
try {
await deps.archiveAgent({ serverId: input.serverId, agentId: input.subagentId });
await deps.archiveAgent(input.child);
} catch (error) {
deps.reportError(error);
}
@@ -1,15 +1,20 @@
import { managedAgentFields } from "@/subagents/test-fixtures";
import { describe, expect, it } from "vitest";
import { resolveCloseAgentTabPolicy } from "./close-tab-policy";
describe("resolveCloseAgentTabPolicy", () => {
it("archives root agents when their tab closes", () => {
expect(resolveCloseAgentTabPolicy({ parentAgentId: null })).toEqual({
expect(resolveCloseAgentTabPolicy({ managedRelationship: null })).toEqual({
kind: "archive-on-close",
});
});
it("keeps subagent tab close layout-only", () => {
expect(resolveCloseAgentTabPolicy({ parentAgentId: "parent-agent" })).toEqual({
expect(
resolveCloseAgentTabPolicy(
managedAgentFields({ serverId: "server-1", agentId: "parent-agent" }),
),
).toEqual({
kind: "layout-only",
});
});
@@ -3,9 +3,9 @@ import type { Agent } from "@/stores/session-store";
export type CloseAgentTabPolicy = { kind: "archive-on-close" } | { kind: "layout-only" };
export function resolveCloseAgentTabPolicy(
agent: Pick<Agent, "parentAgentId"> | null | undefined,
agent: Pick<Agent, "managedRelationship"> | null | undefined,
): CloseAgentTabPolicy {
if (agent?.parentAgentId) {
if (agent?.managedRelationship) {
return { kind: "layout-only" };
}
@@ -46,14 +46,16 @@ function createFakeEnv(
recordedConfirmInputs,
recordedErrors,
deps: {
getSubagent: (id) => subagents.get(id),
getSubagent: (child) => subagents.get(child.agentId),
confirm: async (dialog) => {
recordedConfirmInputs.push(dialog);
return options.confirmResult ?? false;
},
detachAgent: async (input) => {
recordedDetaches.push(input);
return { phase: "detached" };
},
reportPending: () => undefined,
openDetachedAgent: (input) => {
recordedOpens.push(input);
},
@@ -93,7 +95,10 @@ describe("requestDetachSubagent", () => {
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
});
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
await requestDetachSubagent(
{ child: { serverId: "server-1", agentId: "child-agent" } },
env.deps,
);
expect(env.recordedDetaches).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
});
@@ -104,7 +109,10 @@ describe("requestDetachSubagent", () => {
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
});
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
await requestDetachSubagent(
{ child: { serverId: "server-1", agentId: "child-agent" } },
env.deps,
);
expect(env.recordedOpens).toEqual([{ serverId: "server-1", agentId: "child-agent" }]);
});
@@ -115,7 +123,10 @@ describe("requestDetachSubagent", () => {
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
});
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
await requestDetachSubagent(
{ child: { serverId: "server-1", agentId: "child-agent" } },
env.deps,
);
expect(env.recordedDetaches).toEqual([]);
expect(env.recordedOpens).toEqual([]);
@@ -127,7 +138,10 @@ describe("requestDetachSubagent", () => {
initialSubagents: [{ id: "child-agent", snapshot: { title: "Review branch" } }],
});
await requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps);
await requestDetachSubagent(
{ child: { serverId: "server-1", agentId: "child-agent" } },
env.deps,
);
expect(env.recordedConfirmInputs).toEqual([
resolveDetachSubagentDialog({ title: "Review branch" }),
@@ -145,9 +159,25 @@ describe("requestDetachSubagent", () => {
};
await expect(
requestDetachSubagent({ serverId: "server-1", subagentId: "child-agent" }, env.deps),
requestDetachSubagent({ child: { serverId: "server-1", agentId: "child-agent" } }, env.deps),
).resolves.toBeUndefined();
expect(env.recordedErrors).toEqual([error]);
expect(env.recordedOpens).toEqual([]);
});
});
it("retains pending detach without opening a standalone conversation", async () => {
const env = createFakeEnv({ confirmResult: true });
let pending = 0;
env.deps.detachAgent = async () => ({ phase: "detach-pending" });
env.deps.reportPending = () => {
pending += 1;
};
await requestDetachSubagent(
{ child: { serverId: "server-2", agentId: "child-agent" } },
env.deps,
);
expect(pending).toBe(1);
expect(env.recordedOpens).toEqual([]);
expect(env.recordedErrors).toEqual([]);
});
+10 -8
View File
@@ -1,3 +1,4 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import type { Agent } from "@/stores/session-store";
import type { ConfirmDialogInput } from "@/utils/confirm-dialog";
@@ -34,23 +35,23 @@ export function resolveDetachSubagentDialog(
}
export interface DetachSubagentDeps {
getSubagent: (subagentId: string) => ResolveDetachSubagentDialogInput | undefined;
getSubagent: (child: AgentAddress) => ResolveDetachSubagentDialogInput | undefined;
confirm: (input: ConfirmDialogInput) => Promise<boolean>;
detachAgent: (input: { serverId: string; agentId: string }) => Promise<void>;
openDetachedAgent: (input: { serverId: string; agentId: string }) => void;
detachAgent: (input: AgentAddress) => Promise<{ phase: string }>;
reportPending: () => void;
openDetachedAgent: (input: AgentAddress) => void;
reportError: (error: unknown) => void;
}
export interface RequestDetachSubagentInput {
serverId: string;
subagentId: string;
child: AgentAddress;
}
export async function requestDetachSubagent(
input: RequestDetachSubagentInput,
deps: DetachSubagentDeps,
): Promise<void> {
const subagent = deps.getSubagent(input.subagentId);
const subagent = deps.getSubagent(input.child);
const confirmed = await deps.confirm(
resolveDetachSubagentDialog({
title: subagent?.title,
@@ -60,8 +61,9 @@ export async function requestDetachSubagent(
return;
}
try {
await deps.detachAgent({ serverId: input.serverId, agentId: input.subagentId });
deps.openDetachedAgent({ serverId: input.serverId, agentId: input.subagentId });
const result = await deps.detachAgent(input.child);
if (result.phase === "detached") deps.openDetachedAgent(input.child);
else deps.reportPending();
} catch (error) {
deps.reportError(error);
}
+203
View File
@@ -0,0 +1,203 @@
import { createPaseoApi, type PaseoApi, type PaseoAgentHandle } from "@getpaseo/client";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import type { SubagentDirectory, SubagentRelationship } from "@getpaseo/protocol/messages";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
export interface ManagedSubagentState {
readonly parent: AgentAddress;
readonly rows: readonly SubagentRelationship[];
readonly status: "loading" | "current" | "unavailable";
readonly error: string | null;
readonly archiving: ReadonlySet<string>;
}
export interface ManagedSubagentDirectory {
getSnapshot(): ManagedSubagentState;
subscribe(changed: () => void): () => void;
archive(child: AgentAddress): Promise<void>;
detach(child: AgentAddress): Promise<SubagentRelationship>;
forget(child: AgentAddress): Promise<void>;
}
interface MembershipRuntime {
getClient(serverId: string): DaemonClient | null;
getSnapshot(serverId: string): { connectionStatus: string } | null;
subscribe(serverId: string, changed: () => void): () => void;
withAgent<T>(
address: AgentAddress,
operation: (agent: PaseoAgentHandle) => Promise<T>,
): Promise<T>;
}
/** Retains authoritative membership for the runtime, independently of view mounting. */
export class ManagedSubagents {
private readonly parents = new Map<string, ParentMembership>();
constructor(private readonly runtime: MembershipRuntime) {}
directory(parent: AgentAddress): ManagedSubagentDirectory {
const key = JSON.stringify([parent.serverId, parent.agentId]);
let directory = this.parents.get(key);
if (!directory) {
directory = new ParentMembership(parent, this.runtime);
this.parents.set(key, directory);
}
return directory;
}
async releaseServer(serverId: string): Promise<void> {
const releasing: Promise<void>[] = [];
for (const [key, parent] of this.parents) {
if (parent.getSnapshot().parent.serverId !== serverId) continue;
this.parents.delete(key);
releasing.push(parent.close());
}
await Promise.allSettled(releasing);
}
}
class ParentMembership implements ManagedSubagentDirectory {
private state: ManagedSubagentState;
private readonly listeners = new Set<() => void>();
private stopConnection: (() => void) | null = null;
private client: DaemonClient | null = null;
private api: PaseoApi | null = null;
private generation = 0;
private closed = false;
private readonly releasing = new Set<Promise<unknown>>();
constructor(
private readonly parent: AgentAddress,
private readonly runtime: MembershipRuntime,
) {
this.state = { parent, rows: [], status: "loading", error: null, archiving: new Set() };
}
getSnapshot = (): ManagedSubagentState => this.state;
subscribe = (changed: () => void): (() => void) => {
this.listeners.add(changed);
if (!this.stopConnection && !this.closed) {
this.stopConnection = this.runtime.subscribe(this.parent.serverId, this.syncConnection);
this.syncConnection();
}
// The runtime owns observation/recovery; unmounting a view only removes its listener.
return () => {
this.listeners.delete(changed);
};
};
private publish(state: ManagedSubagentState): void {
this.state = state;
for (const changed of this.listeners) changed();
}
private unavailable(error: string): void {
this.publish({
...this.state,
status: "unavailable",
error,
rows: this.state.rows.map((row) => ({ ...row, freshness: "unavailable" })),
});
}
private retireApi(): void {
if (!this.api) return;
const releasing = this.api.dispose();
this.api = null;
this.releasing.add(releasing);
void releasing.finally(() => this.releasing.delete(releasing)).catch(() => {});
}
private syncConnection = (): void => {
if (this.closed) return;
const client = this.runtime.getClient(this.parent.serverId);
if (client !== this.client) {
this.generation++;
this.retireApi();
this.client = client;
}
if (!client || this.runtime.getSnapshot(this.parent.serverId)?.connectionStatus !== "online") {
this.unavailable("Parent authority is unavailable");
return;
}
if (this.api) return;
const generation = this.generation;
const accept = (directory: SubagentDirectory) => {
if (this.closed || generation !== this.generation) return;
if (
directory.parent.serverId !== this.parent.serverId ||
directory.parent.agentId !== this.parent.agentId
)
throw new Error("Subagent directory does not match its owning parent");
this.publish({
...this.state,
parent: this.parent,
rows: directory.rows,
status: "current",
error: null,
});
};
try {
this.api = createPaseoApi(client);
const observation = this.api.agents.ref(this.parent.agentId).subagents.observe();
observation.subscribe({
snapshot: accept,
update: (message) => {
if (message.type === "agent.subagents.changed") accept(message.payload.directory);
},
error: (error) => {
if (!this.closed && generation === this.generation)
this.unavailable(
error instanceof Error ? error.message : "Subagent membership is unavailable",
);
},
});
void observation.ready.catch((error) => {
if (!this.closed && generation === this.generation)
this.unavailable(
error instanceof Error ? error.message : "Subagent membership is unavailable",
);
});
} catch (error) {
this.retireApi();
this.unavailable(
error instanceof Error ? error.message : "Subagent membership is unavailable",
);
}
};
private requireMembership(child: AgentAddress): SubagentRelationship {
if (this.closed || !this.api || this.state.status !== "current")
throw new Error("Parent authority is unavailable");
const row = this.state.rows.find(
(candidate) =>
candidate.child.serverId === child.serverId && candidate.child.agentId === child.agentId,
);
if (!row || ["forgotten", "archived", "detached", "canceled"].includes(row.phase))
throw new Error("Subagent membership is no longer attached");
return row;
}
archive = async (child: AgentAddress): Promise<void> => {
this.requireMembership(child);
const key = JSON.stringify([child.serverId, child.agentId]);
if (this.state.archiving.has(key)) return;
this.publish({ ...this.state, archiving: new Set([...this.state.archiving, key]) });
try {
await this.runtime.withAgent(child, (agent) => agent.archive());
} finally {
const archiving = new Set(this.state.archiving);
archiving.delete(key);
this.publish({ ...this.state, archiving });
}
};
detach = async (child: AgentAddress): Promise<SubagentRelationship> => {
this.requireMembership(child);
return this.api!.agents.ref(this.parent.agentId).subagents.detach(child);
};
forget = async (child: AgentAddress): Promise<void> => {
this.requireMembership(child);
await this.api!.agents.ref(this.parent.agentId).subagents.forget(child);
};
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
this.generation++;
this.stopConnection?.();
this.retireApi();
this.listeners.clear();
await Promise.allSettled(this.releasing);
}
}
@@ -1,3 +1,4 @@
import { managedAgentFields } from "@/subagents/test-fixtures";
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
@@ -12,7 +13,9 @@ function tab(agentId: string, createdAt: number): WorkspaceTab {
function agent(input: { id: string; parentAgentId?: string | null; open?: boolean }): Agent {
return {
id: input.id,
parentAgentId: input.parentAgentId ?? null,
...managedAgentFields(
input.parentAgentId ? { serverId: "server-1", agentId: input.parentAgentId } : null,
),
labels: input.open ? { [label]: "true" } : {},
} as Agent;
}
@@ -14,7 +14,7 @@ export function getAgentTabsNeedingOpenLabel(input: {
}
const agent = input.getAgent(tab.target.agentId);
if (
agent?.parentAgentId &&
agent?.managedRelationship &&
agent.labels[input.label] !== "true" &&
!input.pendingAgentIds.has(agent.id)
) {
@@ -9,6 +9,7 @@ import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
import { useSubagentOpensInSheet } from "./opens-in-sheet";
import { selectSubagentsForParent, type SubagentRow } from "./select";
import { managedAgentFields, membershipFixture, relationshipFixture } from "./test-fixtures";
vi.mock("@react-native-async-storage/async-storage", () => {
const storage = new Map<string, string>();
@@ -64,7 +65,8 @@ const AGENT_DEFAULTS: Agent = {
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
projectPlacement: null,
};
@@ -77,7 +79,12 @@ if (!workspaceKey) throw new Error("workspace key");
function seedParentWithChild(): SubagentRow {
const parent: Agent = { ...AGENT_DEFAULTS, id: "parent", title: "Parent" };
const child: Agent = { ...AGENT_DEFAULTS, id: "child", title: "Child", parentAgentId: "parent" };
const child: Agent = {
...AGENT_DEFAULTS,
id: "child",
title: "Child",
...managedAgentFields({ serverId: SERVER_ID, agentId: "parent" }),
};
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(
SERVER_ID,
@@ -87,8 +94,10 @@ function seedParentWithChild(): SubagentRow {
]),
);
const [row] = selectSubagentsForParent(
useSessionStore.getState(),
{ serverId: SERVER_ID, parentAgentId: "parent" },
membershipFixture({ serverId: SERVER_ID, agentId: "parent" }, [
relationshipFixture({ serverId: SERVER_ID, agentId: "parent" }, child),
]),
{ originServerId: SERVER_ID, serverId: SERVER_ID },
new Set(),
);
if (!row) throw new Error("child row");
@@ -114,7 +123,11 @@ describe("useSubagentOpensInSheet", () => {
const target = { kind: "agent", agentId: "child" } as const;
// The row renders its answer, as the track does, so a stale render is a stale answer.
const { result } = renderHook(() => useSubagentOpensInSheet(workspaceKey)(row));
const { result } = renderHook(() =>
useSubagentOpensInSheet(workspaceKey, { serverId: SERVER_ID, workspaceId: WORKSPACE_ID })(
row,
),
);
expect(result.current).toBe(true);
let tabId: string | null = null;
+22 -3
View File
@@ -1,18 +1,36 @@
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import {
useWorkspaceLayoutStore,
useWorkspaceLayoutStoreApi,
} from "@/stores/workspace-layout-store";
import type { WorkspaceTabTarget } from "@/workspace-tabs/model";
import type { SubagentRow } from "./select";
import type { WorkspaceAddress } from "@getpaseo/protocol/addresses";
export function subagentRowBelongsToWorkspace(
row: SubagentRow,
workspace: WorkspaceAddress,
): boolean {
return (
row.kind === "provider" ||
(row.workspace !== null &&
row.workspace.serverId === workspace.serverId &&
row.workspace.workspaceId === workspace.workspaceId)
);
}
/** The workspace target a subagent row opens. */
export function subagentRowTarget(row: SubagentRow): WorkspaceTabTarget {
return row.kind === "provider"
? { kind: "provider_subagent", parentAgentId: row.parentAgentId, subagentId: row.id }
: { kind: "agent", agentId: row.id };
: { kind: "agent", agentId: row.address.agentId };
}
/** Answers, per subagent row, whether opening it in this workspace shows a sheet rather than a tab. */
export function useSubagentOpensInSheet(
workspaceKey: string | null,
workspace: WorkspaceAddress,
): (row: SubagentRow) => boolean {
const layout = useWorkspaceLayoutStoreApi();
// The rows render the answer, so this subscribes to what the answer reads: the layout mode and
// the workspace's tabs. A subagent already open as a tab reveals that tab instead of a sheet,
// and a row rendered while that tab existed must re-render once it closes, or choosing the row
@@ -22,8 +40,9 @@ export function useSubagentOpensInSheet(
workspaceKey ? state.layoutByWorkspace[workspaceKey] : undefined,
);
return (row) =>
subagentRowBelongsToWorkspace(row, workspace) &&
workspaceKey !== null &&
useWorkspaceLayoutStore
layout
.getState()
.opensInSheet({ workspaceKey, target: subagentRowTarget(row), intent: "reveal" });
}
@@ -2,6 +2,7 @@ import { DaemonConnectionError } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, test } from "vitest";
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
import {
createProviderSubagentStore,
observeProviderSubagentTimeline,
providerSubagentKey,
refreshProviderSubagents,
@@ -824,3 +825,59 @@ describe("resyncing after the update feed resubscribes", () => {
expect(daemon.requested).toEqual([PARENT_ID]);
});
});
test("isolates equal provider identities and in-flight list ownership between authority bindings", async () => {
const direct = createProviderSubagentStore();
const routed = createProviderSubagentStore();
const pending: Array<
(value: {
requestId: string;
parentAgentId: string;
error: null;
subagents: ProviderSubagentDescriptorPayload[];
}) => void
> = [];
const client = {
listProviderSubagents: () =>
new Promise<{
requestId: string;
parentAgentId: string;
error: null;
subagents: ProviderSubagentDescriptorPayload[];
}>((resolve) => pending.push(resolve)),
};
const descriptor: ProviderSubagentDescriptorPayload = {
id: SUBAGENT_ID,
parentAgentId: PARENT_ID,
provider: "codex",
title: "Scoped",
description: null,
status: "completed",
createdAt: "2026-10-20T10:00:00Z",
updatedAt: "2026-10-20T10:00:00Z",
toolCallId: null,
};
const directRequest = refreshProviderSubagents(client, SERVER_ID, PARENT_ID, direct);
const routedRequest = refreshProviderSubagents(client, SERVER_ID, PARENT_ID, routed);
expect(pending).toHaveLength(2);
pending[0]!({
requestId: "direct",
parentAgentId: PARENT_ID,
error: null,
subagents: [{ ...descriptor, title: "Direct private title" }],
});
await directRequest;
expect(routed.getState().descriptors.size).toBe(0);
pending[1]!({
requestId: "routed",
parentAgentId: PARENT_ID,
error: null,
subagents: [descriptor],
});
await routedRequest;
const key = providerSubagentKey(SERVER_ID, PARENT_ID, SUBAGENT_ID);
expect(routed.getState().descriptors.get(key)?.title).toBe("Scoped");
routed.getState().hideFromTrack(SERVER_ID, PARENT_ID, [SUBAGENT_ID]);
expect(direct.getState().hiddenFromTrack.size).toBe(0);
expect(direct.getState().descriptors.get(key)?.title).toBe("Direct private title");
});
+218 -190
View File
@@ -4,7 +4,8 @@ import type {
SessionOutboundMessage,
} from "@getpaseo/protocol/messages";
import { DaemonConnectionError, type DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { create } from "zustand";
import { create, useStore } from "zustand";
import { createContext, useContext } from "react";
import { applyStreamEvent } from "@/types/stream";
import {
processTimelineResponse,
@@ -71,27 +72,36 @@ export function providerSubagentLifecycleStatus(
type ProviderSubagentListClient = Pick<DaemonClient, "listProviderSubagents">;
const pendingListRequests = new WeakMap<ProviderSubagentListClient, Map<string, Promise<void>>>();
const pendingListRequests = new WeakMap<
ProviderSubagentStoreApi,
WeakMap<ProviderSubagentListClient, Map<string, Promise<void>>>
>();
export function refreshProviderSubagents(
client: ProviderSubagentListClient,
serverId: string,
parentAgentId: string,
store: ProviderSubagentStoreApi = defaultProviderSubagentStore,
): Promise<void> {
const requestKey = `${serverId}\0${parentAgentId}`;
let clientRequests = pendingListRequests.get(client);
let requests = pendingListRequests.get(store);
if (!requests) {
requests = new WeakMap();
pendingListRequests.set(store, requests);
}
let clientRequests = requests.get(client);
if (!clientRequests) {
clientRequests = new Map();
pendingListRequests.set(client, clientRequests);
requests.set(client, clientRequests);
}
const pending = clientRequests.get(requestKey);
if (pending) return pending;
useProviderSubagentStore.getState().trackParent(serverId, parentAgentId);
store.getState().trackParent(serverId, parentAgentId);
const request = client
.listProviderSubagents(parentAgentId)
.then((payload) => {
useProviderSubagentStore.getState().replaceList(serverId, parentAgentId, payload.subagents);
store.getState().replaceList(serverId, parentAgentId, payload.subagents);
return undefined;
})
.finally(() => {
@@ -113,15 +123,16 @@ function parentPrefix(serverId: string, parentAgentId: string): string {
export async function resyncProviderSubagents(
client: ProviderSubagentListClient,
serverId: string,
store: ProviderSubagentStoreApi = defaultProviderSubagentStore,
): Promise<void> {
const serverPrefix = `${serverId}\0`;
const parentAgentIds = [...useProviderSubagentStore.getState().trackedParents]
const parentAgentIds = [...store.getState().trackedParents]
.filter((key) => key.startsWith(serverPrefix))
.map((key) => key.slice(serverPrefix.length, -1));
await Promise.all(
parentAgentIds.map((parentAgentId) =>
// A parent that was archived meanwhile has nothing left to catch up.
refreshProviderSubagents(client, serverId, parentAgentId).catch(() => undefined),
refreshProviderSubagents(client, serverId, parentAgentId, store).catch(() => undefined),
),
);
}
@@ -168,195 +179,215 @@ function settleTimeline(
};
}
export const useProviderSubagentStore = create<ProviderSubagentState>((set) => ({
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
trackedParents: new Set(),
trackParent(serverId, parentAgentId) {
set((state) => {
const prefix = parentPrefix(serverId, parentAgentId);
if (state.trackedParents.has(prefix)) return state;
return { trackedParents: new Set(state.trackedParents).add(prefix) };
});
},
hideFromTrack(serverId, parentAgentId, subagentIds) {
set((state) => {
const hiddenFromTrack = new Set(state.hiddenFromTrack);
for (const subagentId of subagentIds) {
const key = providerSubagentKey(serverId, parentAgentId, subagentId);
if (state.descriptors.get(key)?.status !== "running") hiddenFromTrack.add(key);
}
return { hiddenFromTrack };
});
},
replaceList(serverId, parentAgentId, subagents) {
set((state) => {
const prefix = parentPrefix(serverId, parentAgentId);
const descriptors = new Map(
[...state.descriptors].filter(([key]) => !key.startsWith(prefix)),
);
const hiddenFromTrack = new Set(state.hiddenFromTrack);
for (const subagent of subagents) {
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
descriptors.set(key, subagent);
if (subagent.status === "running") {
hiddenFromTrack.delete(key);
}
}
const retainedKeys = new Set(descriptors.keys());
const timelines = new Map(
[...state.timelines].filter(([key]) => !key.startsWith(prefix) || retainedKeys.has(key)),
);
for (const subagent of subagents) {
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
const current = timelines.get(key);
const previous = state.descriptors.get(key);
if (current && previous?.status !== subagent.status) {
timelines.set(key, settleTimeline(current, subagent));
}
}
return { descriptors, timelines, hiddenFromTrack };
});
},
applyUpdate(serverId, payload) {
set((state) => {
if (payload.kind === "upsert") {
const key = providerSubagentKey(
serverId,
payload.subagent.parentAgentId,
payload.subagent.id,
);
const descriptors = new Map(state.descriptors);
export const createProviderSubagentStore = () =>
create<ProviderSubagentState>((set) => ({
descriptors: new Map(),
timelines: new Map(),
hiddenFromTrack: new Set(),
trackedParents: new Set(),
trackParent(serverId, parentAgentId) {
set((state) => {
const prefix = parentPrefix(serverId, parentAgentId);
if (state.trackedParents.has(prefix)) return state;
return { trackedParents: new Set(state.trackedParents).add(prefix) };
});
},
hideFromTrack(serverId, parentAgentId, subagentIds) {
set((state) => {
const hiddenFromTrack = new Set(state.hiddenFromTrack);
const previous = descriptors.get(key);
descriptors.set(key, payload.subagent);
if (payload.subagent.status === "running") {
hiddenFromTrack.delete(key);
for (const subagentId of subagentIds) {
const key = providerSubagentKey(serverId, parentAgentId, subagentId);
if (state.descriptors.get(key)?.status !== "running") hiddenFromTrack.add(key);
}
let timelines = state.timelines;
const current = state.timelines.get(key);
if (current && previous?.status !== payload.subagent.status) {
timelines = new Map(state.timelines);
timelines.set(key, settleTimeline(current, payload.subagent));
return { hiddenFromTrack };
});
},
replaceList(serverId, parentAgentId, subagents) {
set((state) => {
const prefix = parentPrefix(serverId, parentAgentId);
const descriptors = new Map(
[...state.descriptors].filter(([key]) => !key.startsWith(prefix)),
);
const hiddenFromTrack = new Set(state.hiddenFromTrack);
for (const subagent of subagents) {
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
descriptors.set(key, subagent);
if (subagent.status === "running") {
hiddenFromTrack.delete(key);
}
}
const retainedKeys = new Set(descriptors.keys());
const timelines = new Map(
[...state.timelines].filter(([key]) => !key.startsWith(prefix) || retainedKeys.has(key)),
);
for (const subagent of subagents) {
const key = providerSubagentKey(serverId, parentAgentId, subagent.id);
const current = timelines.get(key);
const previous = state.descriptors.get(key);
if (current && previous?.status !== subagent.status) {
timelines.set(key, settleTimeline(current, subagent));
}
}
return { descriptors, timelines, hiddenFromTrack };
}
if (payload.kind === "remove") {
});
},
applyUpdate(serverId, payload) {
set((state) => {
if (payload.kind === "upsert") {
const key = providerSubagentKey(
serverId,
payload.subagent.parentAgentId,
payload.subagent.id,
);
const descriptors = new Map(state.descriptors);
const hiddenFromTrack = new Set(state.hiddenFromTrack);
const previous = descriptors.get(key);
descriptors.set(key, payload.subagent);
if (payload.subagent.status === "running") {
hiddenFromTrack.delete(key);
}
let timelines = state.timelines;
const current = state.timelines.get(key);
if (current && previous?.status !== payload.subagent.status) {
timelines = new Map(state.timelines);
timelines.set(key, settleTimeline(current, payload.subagent));
}
return { descriptors, timelines, hiddenFromTrack };
}
if (payload.kind === "remove") {
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
const descriptors = new Map(state.descriptors);
descriptors.delete(key);
const timelines = new Map(state.timelines);
timelines.delete(key);
return { descriptors, timelines };
}
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
const descriptors = new Map(state.descriptors);
descriptors.delete(key);
const existing = state.timelines.get(key);
if (existing?.epoch && existing.epoch !== payload.epoch) {
return state;
}
const current = existing ?? EMPTY_TIMELINE;
if (payload.seq <= current.lastSeq) {
return state;
}
const next = processAgentStreamEvent({
currentTail: current.tail,
currentHead: current.head,
currentCursor: current.cursor,
hasAuthoritativeBaseline: current.cursor !== undefined,
event: { type: "timeline", provider: payload.provider, item: payload.item },
timestamp: new Date(payload.timestamp),
seq: payload.seq,
epoch: payload.epoch,
});
const timelines = new Map(state.timelines);
timelines.delete(key);
return { descriptors, timelines };
}
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
const existing = state.timelines.get(key);
if (existing?.epoch && existing.epoch !== payload.epoch) {
return state;
}
const current = existing ?? EMPTY_TIMELINE;
if (payload.seq <= current.lastSeq) {
return state;
}
const next = processAgentStreamEvent({
currentTail: current.tail,
currentHead: current.head,
currentCursor: current.cursor,
hasAuthoritativeBaseline: current.cursor !== undefined,
event: { type: "timeline", provider: payload.provider, item: payload.item },
timestamp: new Date(payload.timestamp),
seq: payload.seq,
epoch: payload.epoch,
timelines.set(
key,
settleTimeline(
{
tail: next.tail,
head: next.head,
epoch: payload.epoch,
lastSeq: payload.seq,
cursor: next.cursor ?? current.cursor,
hasOlder: current.hasOlder,
needsRefresh:
current.needsRefresh ||
next.sideEffects.some((effect) => effect.type === "catch_up"),
},
state.descriptors.get(key),
),
);
return { timelines };
});
const timelines = new Map(state.timelines);
timelines.set(
key,
settleTimeline(
{
tail: next.tail,
head: next.head,
epoch: payload.epoch,
lastSeq: payload.seq,
cursor: next.cursor ?? current.cursor,
hasOlder: current.hasOlder,
needsRefresh:
current.needsRefresh || next.sideEffects.some((effect) => effect.type === "catch_up"),
},
replaceTimeline(serverId, payload) {
const provider = payload.provider;
if (!provider) {
return;
}
set((state) => {
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
const existing = state.timelines.get(key);
const current = existing ?? EMPTY_TIMELINE;
// Normalize optional wire metadata at the child timeline boundary. Legacy
// notices are complete singleton items; history always comes from a projected host.
const entries = payload.rows.map((row) => ({
...row,
provider,
seqStart: row.seqStart ?? row.seq,
seqEnd: row.seqEnd ?? row.seq,
}));
const result = processTimelineResponse({
payload: {
...payload,
agentId: payload.subagentId,
projection: "projected",
startCursor:
payload.startCursor ??
(entries.length
? { seq: Math.min(...entries.map((entry) => entry.seqStart)) }
: null),
endCursor:
payload.endCursor ??
(entries.length ? { seq: Math.max(...entries.map((entry) => entry.seqEnd)) } : null),
entries,
},
state.descriptors.get(key),
),
);
return { timelines };
});
},
replaceTimeline(serverId, payload) {
const provider = payload.provider;
if (!provider) {
return;
}
set((state) => {
const key = providerSubagentKey(serverId, payload.parentAgentId, payload.subagentId);
const existing = state.timelines.get(key);
const current = existing ?? EMPTY_TIMELINE;
// Normalize optional wire metadata at the child timeline boundary. Legacy
// notices are complete singleton items; history always comes from a projected host.
const entries = payload.rows.map((row) => ({
...row,
provider,
seqStart: row.seqStart ?? row.seq,
seqEnd: row.seqEnd ?? row.seq,
}));
const result = processTimelineResponse({
payload: {
...payload,
agentId: payload.subagentId,
projection: "projected",
startCursor:
payload.startCursor ??
(entries.length ? { seq: Math.min(...entries.map((entry) => entry.seqStart)) } : null),
endCursor:
payload.endCursor ??
(entries.length ? { seq: Math.max(...entries.map((entry) => entry.seqEnd)) } : null),
entries,
},
currentTail: current.tail,
currentHead: current.head,
currentCursor: current.cursor,
isInitializing: current.cursor === undefined,
hasActiveInitDeferred: current.cursor === undefined,
initRequestDirection: "tail",
sendingClientMessageIds: [],
currentTail: current.tail,
currentHead: current.head,
currentCursor: current.cursor,
isInitializing: current.cursor === undefined,
hasActiveInitDeferred: current.cursor === undefined,
initRequestDirection: "tail",
sendingClientMessageIds: [],
});
const timelines = new Map(state.timelines);
timelines.set(
key,
settleTimeline(
{
tail: result.tail,
head: result.head,
epoch: payload.epoch,
lastSeq:
payload.reset || current.epoch !== payload.epoch
? (result.cursor?.endSeq ?? 0)
: Math.max(current.lastSeq, result.cursor?.endSeq ?? 0),
cursor: result.cursor ?? undefined,
hasOlder:
result.older === "unchanged" ? current.hasOlder : result.older === "available",
needsRefresh:
result.sideEffects.some((effect) => effect.type === "catch_up") &&
(payload.hasNewer || current.lastSeq > (result.cursor?.endSeq ?? 0)),
},
state.descriptors.get(key),
),
);
return { timelines };
});
const timelines = new Map(state.timelines);
timelines.set(
key,
settleTimeline(
{
tail: result.tail,
head: result.head,
epoch: payload.epoch,
lastSeq:
payload.reset || current.epoch !== payload.epoch
? (result.cursor?.endSeq ?? 0)
: Math.max(current.lastSeq, result.cursor?.endSeq ?? 0),
cursor: result.cursor ?? undefined,
hasOlder:
result.older === "unchanged" ? current.hasOlder : result.older === "available",
needsRefresh:
result.sideEffects.some((effect) => effect.type === "catch_up") &&
(payload.hasNewer || current.lastSeq > (result.cursor?.endSeq ?? 0)),
},
state.descriptors.get(key),
),
);
return { timelines };
});
},
}));
export type ProviderSubagentStoreApi = ReturnType<typeof createProviderSubagentStore>;
// COMPAT(unboundProviderSubagents): added in v0.12, remove after 2027-04-20.
// Existing shell callers retain their store; bound consumers receive one captured owner.
export const defaultProviderSubagentStore = createProviderSubagentStore();
export const ProviderSubagentStoreContext = createContext<ProviderSubagentStoreApi | null>(null);
export function useProviderSubagentStoreApi(): ProviderSubagentStoreApi {
return useContext(ProviderSubagentStoreContext) ?? defaultProviderSubagentStore;
}
export const useProviderSubagentStore: ProviderSubagentStoreApi = Object.assign(
function useProviderSubagentStore<T>(selector: (state: ProviderSubagentState) => T): T {
return useStore(useProviderSubagentStoreApi(), selector);
},
}));
defaultProviderSubagentStore,
);
/** Owns child history bootstrap and recovery while a pane observes the child. */
export function observeProviderSubagentTimeline({
client,
store = defaultProviderSubagentStore,
serverId,
parentAgentId,
subagentId,
@@ -364,6 +395,7 @@ export function observeProviderSubagentTimeline({
reportError,
}: {
client: Pick<DaemonClient, "fetchProviderSubagentTimeline">;
store?: ProviderSubagentStoreApi;
serverId: string;
parentAgentId: string;
subagentId: string;
@@ -382,25 +414,21 @@ export function observeProviderSubagentTimeline({
direction: "tail",
limit,
});
if (active) useProviderSubagentStore.getState().replaceTimeline(serverId, payload);
if (active) store.getState().replaceTimeline(serverId, payload);
succeeded = true;
} catch (error) {
// A later stream update or reopening the pane retries a disconnected read.
if (!(error instanceof DaemonConnectionError)) throw error;
} finally {
fetching = false;
if (
succeeded &&
active &&
useProviderSubagentStore.getState().timelines.get(key)?.needsRefresh
)
if (succeeded && active && store.getState().timelines.get(key)?.needsRefresh)
requestRefresh();
}
};
const requestRefresh = () => {
void refresh().catch(reportError);
};
const unsubscribe = useProviderSubagentStore.subscribe((state) => {
const unsubscribe = store.subscribe((state) => {
if (state.timelines.get(key)?.needsRefresh) requestRefresh();
});
requestRefresh();
+383 -395
View File
@@ -1,3 +1,4 @@
import { managedAgentFields } from "@/subagents/test-fixtures";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest";
import {
@@ -5,14 +6,125 @@ import {
selectProviderSubagentsForParent,
selectSubagentsForParent,
} from "./select";
import { createWorkspaceAgentVisibilitySelector } from "@/workspace-tabs/agent-visibility";
import { membershipFixture, relationshipFixture } from "./test-fixtures";
import { useProviderSubagentStore } from "./provider-store";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { ManagedSubagents } from "./managed";
const SERVER_ID = "server-1";
const AGENT_TIMESTAMP = new Date("2026-03-08T10:00:00.000Z");
const EMPTY_PENDING_ARCHIVE_IDS = new Set<string>();
it("observes authoritative qualified membership with colliding IDs and retains it through authority withdrawal", async () => {
const harnessModule = new URL(
"../../../server/src/server/test-utils/paseo-daemon.ts",
import.meta.url,
).pathname;
const { createTestPaseoDaemon } = await import(harnessModule);
const { createHostClient } = await import("@getpaseo/client/node");
const { createPaseoApi } = await import("@getpaseo/client");
const a = await createTestPaseoDaemon();
const b = await createTestPaseoDaemon();
const origin = createHostClient(`127.0.0.1:${a.port}`, {
clientId: crypto.randomUUID(),
reconnect: { enabled: false },
});
const direct = createHostClient(`127.0.0.1:${b.port}`, {
clientId: crypto.randomUUID(),
reconnect: { enabled: false },
});
await Promise.all([origin.connect(), direct.connect()]);
const paseo = createPaseoApi(origin);
const destination = createPaseoApi(direct);
const changed = new Set<() => void>();
let available = true;
const originRuntime = paseo.hosts.runtime();
const model = new ManagedSubagents({
getClient: (serverId) => (available && serverId === a.daemon.getServerId() ? origin : null),
getSnapshot: () => ({ connectionStatus: available ? "online" : "offline" }),
withAgent: async (address, operation) => operation(await originRuntime.agent(address)),
subscribe: (_serverId, listener) => {
changed.add(listener);
return () => {
changed.delete(listener);
};
},
});
try {
const registration = await paseo.hosts.add({ connection: `127.0.0.1:${b.port}` });
await expect.poll(async () => (await paseo.hosts.list()).hosts[0]?.status).toBe("online");
const parent = await paseo.agents.create({
cwd: a.staticDir,
config: { provider: "codex/test" },
});
const workspace = await destination.workspaces.create({
source: { kind: "directory", path: b.staticDir },
});
const collision = crypto.randomUUID();
const local = await parent.subagents.create({
agentId: collision,
config: { provider: "codex/test" },
idempotencyKey: "local",
});
const routed = await parent.subagents.create({
agentId: collision,
serverId: b.daemon.getServerId(),
workspaceId: workspace.id,
config: { provider: "codex/test" },
idempotencyKey: "routed",
});
const directory = model.directory(parent.address);
const stopView = directory.subscribe(() => {});
await expect.poll(() => directory.getSnapshot().status).toBe("current");
expect(directory.getSnapshot().rows.map((row) => row.child)).toEqual(
expect.arrayContaining([local.address, routed.address]),
);
expect(directory.getSnapshot().rows).toHaveLength(2);
stopView();
await parent.subagents.create({
config: { provider: "codex/test" },
idempotencyKey: "without-view",
});
await expect.poll(() => directory.getSnapshot().rows.length).toBe(3);
available = false;
for (const listener of changed) listener();
expect(directory.getSnapshot()).toMatchObject({ status: "unavailable" });
expect(directory.getSnapshot().rows).toHaveLength(3);
expect(directory.getSnapshot().rows.every((row) => row.freshness === "unavailable")).toBe(true);
expect(direct.getConnectionState().status).toBe("connected");
await parent.subagents.create({
config: { provider: "codex/test" },
idempotencyKey: "while-withdrawn",
});
expect(directory.getSnapshot().rows).toHaveLength(3);
available = true;
for (const listener of changed) listener();
await expect.poll(() => directory.getSnapshot().rows.length).toBe(4);
expect(directory.getSnapshot().status).toBe("current");
// A managed action resolves its complete address through the parent capability.
await directory.archive(local.address);
await expect.poll(async () => (await local.refresh())?.agent.archivedAt).not.toBeNull();
expect((await routed.refresh())?.agent.archivedAt).toBeNull();
await paseo.hosts.update(registration.registrationId, { enabled: false });
await expect(directory.archive(routed.address)).rejects.toBeDefined();
// The separately connected B remains usable but must never satisfy the A-owned action.
expect((await destination.agents.ref(collision).refresh())?.agent.archivedAt).toBeNull();
expect(directory.getSnapshot().archiving.size).toBe(0);
await model.releaseServer(parent.serverId);
const released = directory.getSnapshot();
await parent.subagents.create({
config: { provider: "codex/test" },
idempotencyKey: "after-release",
});
expect(directory.getSnapshot()).toBe(released);
} finally {
await model.releaseServer(a.daemon.getServerId());
await Promise.allSettled([originRuntime.dispose(), paseo.dispose(), destination.dispose()]);
await Promise.allSettled([origin.close(), direct.close()]);
await Promise.allSettled([a.close(), b.close()]);
}
});
const AGENT_DEFAULTS: Agent = {
serverId: SERVER_ID,
id: "agent",
@@ -47,7 +159,8 @@ const AGENT_DEFAULTS: Agent = {
attentionReason: null,
attentionTimestamp: null,
archivedAt: null,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
projectPlacement: null,
};
@@ -180,420 +293,295 @@ describe("selectSubagentsForParent", () => {
).map((row) => row.id),
).toEqual(["nested"]);
});
});
it("returns only non-archived children for the requested parent", () => {
const fixtureParent = { serverId: SERVER_ID, agentId: "parent" };
const authority = { originServerId: SERVER_ID, serverId: SERVER_ID };
function membership(agents: Agent[]) {
return membershipFixture(
fixtureParent,
agents.map((agent) => relationshipFixture(fixtureParent, agent)),
);
}
describe("authoritative managed row projection", () => {
it("projects only supplied membership, independently of labels, siblings and grandchildren", () => {
setAgents([
makeAgent({ id: "parent-a" }),
makeAgent({ id: "child-a", parentAgentId: "parent-a" }),
makeAgent({
id: "archived-child",
parentAgentId: "parent-a",
archivedAt: new Date("2026-03-08T12:00:00.000Z"),
}),
]);
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-a",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
it("excludes siblings, unrelated agents, and grandchildren", () => {
setAgents([
makeAgent({ id: "parent-a" }),
makeAgent({ id: "parent-b" }),
makeAgent({ id: "child-a", parentAgentId: "parent-a" }),
makeAgent({ id: "sibling-b", parentAgentId: "parent-b" }),
makeAgent({ id: "grandchild-a", parentAgentId: "child-a" }),
makeAgent({ id: "unrelated" }),
]);
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-a",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
it("shows only direct children for each parent", () => {
setAgents([
makeAgent({ id: "parent" }),
makeAgent({ id: "child", parentAgentId: "parent" }),
makeAgent({ id: "grandchild", parentAgentId: "child" }),
]);
const parentRows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
const childRows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "child",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(parentRows.map((row) => row.id)).toEqual(["child"]);
expect(childRows.map((row) => row.id)).toEqual(["grandchild"]);
});
it("sorts by createdAt ascending", () => {
setAgents([
makeAgent({ id: "parent" }),
makeAgent({
id: "third",
parentAgentId: "parent",
createdAt: new Date("2026-03-08T10:03:00.000Z"),
id: "unrelated",
...managedAgentFields({ serverId: SERVER_ID, agentId: "parent" }),
}),
makeAgent({
id: "first",
parentAgentId: "parent",
createdAt: new Date("2026-03-08T10:01:00.000Z"),
}),
makeAgent({
id: "second",
parentAgentId: "parent",
createdAt: new Date("2026-03-08T10:02:00.000Z"),
id: "grandchild",
...managedAgentFields({ serverId: SERVER_ID, agentId: "child" }),
}),
]);
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent",
},
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(rows.map((row) => row.id)).toEqual(["first", "second", "third"]);
});
it("maps only row-rendered fields and does not expose onOpen", () => {
const createdAt = new Date("2026-03-08T10:01:00.000Z");
setAgents([
makeAgent({ id: "parent" }),
const state = membership([
makeAgent({
id: "child",
parentAgentId: "parent",
provider: "claude",
title: "Review child",
status: "running",
requiresAttention: true,
createdAt,
model: "should-not-leak",
cwd: "/private/project",
...managedAgentFields({ serverId: SERVER_ID, agentId: "untrusted-other-fixtureParent" }),
}),
]);
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent",
},
EMPTY_PENDING_ARCHIVE_IDS,
const rows = selectSubagentsForParent(state, authority, EMPTY_PENDING_ARCHIVE_IDS);
expect(rows.map((row) => row.address)).toEqual([{ serverId: SERVER_ID, agentId: "child" }]);
expect(selectSubagentsForParent(membership([]), authority, EMPTY_PENDING_ARCHIVE_IDS)).toEqual(
[],
);
expect(rows).toEqual([
{
kind: "paseo",
id: "child",
provider: "claude",
title: "Review child",
description: null,
subtitle: null,
status: "running",
turn: { phase: "idle", cancellationRequestId: null },
requiresAttention: true,
createdAt,
},
]);
expect(Object.keys(rows[0] ?? {}).sort()).toEqual([
"createdAt",
"description",
"id",
"kind",
"provider",
"requiresAttention",
"status",
"subtitle",
"title",
"turn",
]);
expect(rows[0]).not.toHaveProperty("onOpen");
expect(rows[0]).not.toHaveProperty("model");
expect(rows[0]).not.toHaveProperty("cwd");
});
it("moves a child when parentAgentId changes", () => {
const child = makeAgent({ id: "child", parentAgentId: "parent-a" });
setAgents([makeAgent({ id: "parent-a" }), makeAgent({ id: "parent-b" }), child]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-a",
},
EMPTY_PENDING_ARCHIVE_IDS,
).map((row) => row.id),
).toEqual(["child"]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-b",
},
EMPTY_PENDING_ARCHIVE_IDS,
).map((row) => row.id),
).toEqual([]);
setAgents([
makeAgent({ id: "parent-a" }),
makeAgent({ id: "parent-b" }),
{ ...child, parentAgentId: "parent-b" },
]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-a",
},
EMPTY_PENDING_ARCHIVE_IDS,
).map((row) => row.id),
).toEqual([]);
expect(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent-b",
},
EMPTY_PENDING_ARCHIVE_IDS,
).map((row) => row.id),
).toEqual(["child"]);
});
it("excludes children whose archive is pending", () => {
setAgents([
makeAgent({ id: "parent" }),
makeAgent({ id: "child-a", parentAgentId: "parent" }),
makeAgent({ id: "child-b", parentAgentId: "parent" }),
]);
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent",
},
new Set(["child-b"]),
);
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
it("returns the shared empty array when pending archive hides the last child", () => {
setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child", parentAgentId: "parent" })]);
const rows = selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "parent",
},
new Set(["child"]),
);
expect(rows).toEqual([]);
expect(rows).toBe(
selectSubagentsForParent(
useSessionStore.getState(),
{
serverId: SERVER_ID,
parentAgentId: "missing-parent",
},
EMPTY_PENDING_ARCHIVE_IDS,
it("removes confirmed terminal membership but retains creating and pending rows", () => {
const phases = [
"attached",
"creating",
"detach_pending",
"lifecycle_pending",
"detached",
"archived",
"forgotten",
"canceled",
] as const;
const state = membershipFixture(
fixtureParent,
phases.map((phase) =>
relationshipFixture(fixtureParent, makeAgent({ id: phase }), { phase }),
),
);
expect(
selectSubagentsForParent(state, authority, EMPTY_PENDING_ARCHIVE_IDS).map((row) => row.phase),
).toEqual(["attached", "creating", "detach_pending", "lifecycle_pending"]);
});
it("sorts by creation time and preserves ordinary presentation fields without exposing raw agent data", () => {
const earlier = makeAgent({
id: "first",
title: "Review child",
provider: "claude",
status: "running",
requiresAttention: true,
createdAt: new Date("2026-03-08T09:00:00Z"),
});
const rows = selectSubagentsForParent(
membership([makeAgent({ id: "second" }), earlier]),
authority,
EMPTY_PENDING_ARCHIVE_IDS,
);
expect(rows.map((row) => row.address.agentId)).toEqual(["first", "second"]);
expect(rows[0]).toMatchObject({
title: "Review child",
provider: "claude",
status: "running",
requiresAttention: true,
createdAt: earlier.createdAt,
authority,
});
expect(rows[0]).not.toHaveProperty("onOpen");
expect(rows[0]).not.toHaveProperty("cwd");
expect(rows[0]).not.toHaveProperty("model");
});
it("keeps colliding IDs distinct and hides only the qualified pending archive", () => {
const rows = selectSubagentsForParent(
membership([makeAgent({ id: "same" }), makeAgent({ serverId: "server-2", id: "same" })]),
authority,
new Set([JSON.stringify([SERVER_ID, "same"])]),
);
expect(rows.map((row) => row.address)).toEqual([{ serverId: "server-2", agentId: "same" }]);
expect(rows[0]?.authority).toEqual({ originServerId: SERVER_ID, serverId: "server-2" });
});
it("retains unavailable membership with its last snapshot and unresolved membership without a fabricated status", () => {
const state = membershipFixture(fixtureParent, [
relationshipFixture(fixtureParent, makeAgent({ id: "stale", status: "running" }), {
freshness: "unavailable",
error: { code: "host_offline", message: "Host unavailable" },
}),
relationshipFixture(fixtureParent, makeAgent({ id: "creating" }), {
phase: "creating",
snapshot: undefined,
freshness: "unavailable",
}),
]);
const rows = selectSubagentsForParent(state, authority, EMPTY_PENDING_ARCHIVE_IDS);
expect(rows.find((row) => row.address.agentId === "stale")).toMatchObject({
status: "running",
freshness: "unavailable",
error: { code: "host_offline" },
});
expect(rows.find((row) => row.address.agentId === "creating")).toMatchObject({
status: null,
turn: null,
createdAt: null,
phase: "creating",
});
});
});
describe("createSubagentsForParentSelector", () => {
class IterationCountingMap<K, V> extends Map<K, V> {
iterations = 0;
override values() {
this.iterations += 1;
return super.values();
}
override entries() {
this.iterations += 1;
return super.entries();
}
override [Symbol.iterator]() {
this.iterations += 1;
return super[Symbol.iterator]();
}
}
const params = { serverId: SERVER_ID, parentAgentId: "parent" };
function streamTick(text: string): void {
useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent", {
head: [
{
kind: "assistant_message",
id: "message-1",
text,
timestamp: new Date("2026-03-08T10:05:00.000Z"),
},
],
});
}
it("returns the cached rows without walking agents when a stream tick leaves them unchanged", () => {
const agents = new IterationCountingMap<string, Agent>(
[
makeAgent({ id: "parent" }),
makeAgent({ id: "child", parentAgentId: "parent" }),
makeAgent({ id: "unrelated" }),
].map((agent) => [agent.id, agent]),
);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, agents);
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
const before = select(useSessionStore.getState());
const iterationsAfterFirstSelect = agents.iterations;
for (let tick = 0; tick < 100; tick += 1) {
streamTick(`token ${tick}`);
expect(select(useSessionStore.getState())).toBe(before);
}
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
expect(agents.iterations).toBe(iterationsAfterFirstSelect);
expect(before.map((row) => row.id)).toEqual(["child"]);
});
it("recomputes when the agents map changes", () => {
setAgents([makeAgent({ id: "parent" }), makeAgent({ id: "child-a", parentAgentId: "parent" })]);
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
const before = select(useSessionStore.getState());
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
const child = makeAgent({
id: "child-b",
parentAgentId: "parent",
createdAt: new Date("2026-03-08T10:01:00.000Z"),
it("retains row identity across 100 unrelated transcript commits without reading the session agent map", () => {
setAgents([makeAgent({ id: "parent" })]);
const state = membership([makeAgent({ id: "child" })]);
const select = createSubagentsForParentSelector(authority, EMPTY_PENDING_ARCHIVE_IDS);
const before = select(state);
for (let tick = 0; tick < 100; tick++) {
useSessionStore.getState().setAgentStreamState(SERVER_ID, "parent", {
head: [
{
kind: "assistant_message",
id: "message",
text: `token ${tick}`,
timestamp: AGENT_TIMESTAMP,
},
],
});
return new Map(current).set(child.id, child);
});
const after = select(useSessionStore.getState());
expect(after).not.toBe(before);
expect(after.map((row) => row.id)).toEqual(["child-a", "child-b"]);
});
it("keeps the previous rows when a changed agents map yields the same rows", () => {
setAgents([
makeAgent({ id: "parent" }),
makeAgent({ id: "child", parentAgentId: "parent" }),
makeAgent({ id: "unrelated" }),
]);
const select = createSubagentsForParentSelector(params, EMPTY_PENDING_ARCHIVE_IDS);
const before = select(useSessionStore.getState());
useSessionStore.getState().setAgents(SERVER_ID, (current) => {
return new Map(current).set("unrelated", makeAgent({ id: "unrelated", status: "running" }));
});
expect(select(useSessionStore.getState())).toBe(before);
});
it("applies the pending archive ids it was created with", () => {
setAgents([
makeAgent({ id: "parent" }),
makeAgent({ id: "child-a", parentAgentId: "parent" }),
makeAgent({ id: "child-b", parentAgentId: "parent" }),
]);
const rows = createSubagentsForParentSelector(
params,
new Set(["child-b"]),
)(useSessionStore.getState());
expect(rows.map((row) => row.id)).toEqual(["child-a"]);
});
it("does no agent map walks for 100 stream commits with unchanged source maps", () => {
const agents = new IterationCountingMap<string, Agent>(
Array.from({ length: 200 }, (_, i) => {
const agent = makeAgent({
id: `agent-${i}`,
workspaceId: "workspace",
parentAgentId: i < 3 ? null : `agent-${i % 3}`,
});
return [agent.id, agent] as const;
}),
);
useSessionStore.getState().initializeSession(SERVER_ID, null as unknown as DaemonClient);
useSessionStore.getState().setAgents(SERVER_ID, agents);
const selectVisibility = createWorkspaceAgentVisibilitySelector({
serverId: SERVER_ID,
workspaceId: "workspace",
});
const selectChildren = Array.from({ length: 3 }, (_, i) =>
createSubagentsForParentSelector(
{ serverId: SERVER_ID, parentAgentId: `agent-${i}` },
EMPTY_PENDING_ARCHIVE_IDS,
),
);
const select = (state: ReturnType<typeof useSessionStore.getState>) => {
selectVisibility(state);
selectChildren.forEach((selector) => selector(state));
};
select(useSessionStore.getState());
const before = agents.iterations;
const unsubscribe = useSessionStore.subscribe(select);
try {
for (let tick = 0; tick < 100; tick++) {
useSessionStore.getState().setAgentStreamState(SERVER_ID, "agent-0", {
head: [
{
kind: "assistant_message",
id: "message",
text: `token ${tick}`,
timestamp: AGENT_TIMESTAMP,
},
],
});
}
expect(useSessionStore.getState().sessions[SERVER_ID]?.agents).toBe(agents);
expect(agents.iterations - before).toBe(0);
} finally {
unsubscribe();
useSessionStore.getState().clearSession(SERVER_ID);
expect(select(state)).toBe(before);
}
expect(before.map((row) => row.address.agentId)).toEqual(["child"]);
});
it("recomputes changed membership and preserves equal projections after a refreshed snapshot", () => {
const select = createSubagentsForParentSelector(authority, EMPTY_PENDING_ARCHIVE_IDS);
const state = membership([makeAgent({ id: "first" })]);
const before = select(state);
expect(select({ ...state })).toBe(before);
const after = select(membership([makeAgent({ id: "first" }), makeAgent({ id: "second" })]));
expect(after).not.toBe(before);
expect(after.map((row) => row.address.agentId)).toEqual(["first", "second"]);
});
it("applies captured qualified archive keys, including hiding the last child", () => {
const select = createSubagentsForParentSelector(
authority,
new Set([JSON.stringify([SERVER_ID, "child"])]),
);
expect(select(membership([makeAgent({ id: "child" })]))).toEqual([]);
});
});
it("resolves child actions through the owning app runtime after initial failure and blocks disposed callers", async () => {
const harnessModule = new URL(
"../../../server/src/server/test-utils/paseo-daemon.ts",
import.meta.url,
).pathname;
const { createTestPaseoDaemon } = await import(harnessModule);
const { createHostClient } = await import("@getpaseo/client/node");
const { createPaseoApi } = await import("@getpaseo/client");
const { acquireHostConnection } = await import("@getpaseo/client/internal/host-connection");
const { HostRuntimeStore } = await import("@/runtime/host-runtime");
const { createSessionStore } = await import("@/stores/session-store");
const { indexedDB, IDBKeyRange } = await import("fake-indexeddb");
globalThis.indexedDB = indexedDB;
globalThis.IDBKeyRange = IDBKeyRange;
const daemons = await Promise.all([
createTestPaseoDaemon(),
createTestPaseoDaemon(),
createTestPaseoDaemon(),
]);
const [a, b, c] = daemons;
const clients = daemons.map((daemon) =>
createHostClient(`127.0.0.1:${daemon.port}`, {
clientId: crypto.randomUUID(),
reconnect: { enabled: false },
}),
);
await Promise.all(clients.map((client) => client.connect()));
const [origin, parentApi, destination] = clients.map((client) => createPaseoApi(client));
const storage = new Map<string, string>();
let failC = true;
let acquisitions = 0;
const registry = new HostRuntimeStore({
sessionStore: createSessionStore(),
storage: {
getItem: async (key) => storage.get(key) ?? null,
setItem: async (key, value) => {
storage.set(key, value);
},
removeItem: async (key) => {
storage.delete(key);
},
},
resolveHostConnection: async (source, serverId, signal) => {
acquisitions++;
expect(source).toBe(clients[0]);
if (serverId === c.daemon.getServerId() && failC)
throw new Error("Initial C acquisition unavailable");
return acquireHostConnection(source, serverId, signal);
},
});
const leases: Array<import("@/runtime/host-runtime").OwnedHostAuthorityBinding> = [];
try {
const [, cRegistration] = await Promise.all([
origin!.hosts.add({ connection: `127.0.0.1:${b.port}` }),
origin!.hosts.add({ connection: `127.0.0.1:${c.port}` }),
parentApi!.hosts.add({ connection: `127.0.0.1:${c.port}` }),
]);
await expect
.poll(async () =>
(await origin!.hosts.list()).hosts.every((host) => host.status === "online"),
)
.toBe(true);
await expect.poll(async () => (await parentApi!.hosts.list()).hosts[0]?.status).toBe("online");
const parent = await parentApi!.agents.create({
cwd: b.staticDir,
config: { provider: "codex/test" },
});
const workspace = await destination!.workspaces.create({
source: { kind: "directory", path: c.staticDir },
});
const child = await parent.subagents.create({
serverId: c.daemon.getServerId(),
workspaceId: workspace.id,
config: { provider: "codex/test" },
idempotencyKey: "bound-parent-child",
});
await registry.upsertDirectConnection({
serverId: a.daemon.getServerId(),
endpoint: `127.0.0.1:${a.port}`,
existingClient: clients[0],
});
await expect
.poll(() => registry.getSnapshot(a.daemon.getServerId())?.connectionStatus)
.toBe("online");
const retainedC = registry.acquireBinding({
originServerId: a.daemon.getServerId(),
serverId: c.daemon.getServerId(),
});
leases.push(retainedC);
await expect(retainedC.binding.ready).rejects.toThrow("Initial C acquisition unavailable");
failC = false;
await origin!.hosts.update(cRegistration!.registrationId, {
metadata: { revision: "recovered" },
});
await expect
.poll(() => retainedC.binding.runtime.getSnapshot(c.daemon.getServerId())?.connectionStatus)
.toBe("online");
const boundParent = registry.acquireBinding({
originServerId: a.daemon.getServerId(),
serverId: b.daemon.getServerId(),
});
leases.push(boundParent);
await boundParent.binding.ready;
const directory = boundParent.binding.runtime.subagents.directory(parent.address);
directory.subscribe(() => {});
await expect.poll(() => directory.getSnapshot().rows.length).toBe(1);
await directory.archive(child.address);
expect((await child.refresh())?.agent.archivedAt).not.toBeNull();
const captured = boundParent.binding.runtime.withAgent.bind(boundParent.binding.runtime);
await boundParent.release();
const beforeDisposedCall = acquisitions;
await expect(captured(child.address, (agent) => agent.refresh())).rejects.toThrow("disposed");
expect(acquisitions).toBe(beforeDisposedCall);
const direct = registry.acquireBinding({
originServerId: a.daemon.getServerId(),
serverId: a.daemon.getServerId(),
});
leases.push(direct);
await direct.binding.ready;
expect(
await direct.binding.runtime.withAgent(child.address, async (agent) => agent.address),
).toEqual(child.address);
} finally {
await Promise.allSettled(leases.map((lease) => lease.release()));
registry.syncHosts([]);
await Promise.allSettled([origin, parentApi, destination].map((api) => api!.dispose()));
await Promise.allSettled(clients.map((client) => client.close()));
await Promise.allSettled(daemons.map((daemon) => daemon.close()));
}
}, 60000);
+104 -79
View File
@@ -1,23 +1,43 @@
import { useEffect, useMemo } from "react";
import { usePendingArchiveAgentIds } from "@/hooks/use-archive-agent";
import { useEffect, useMemo, useSyncExternalStore } from "react";
import equal from "fast-deep-equal";
import { useStoreWithEqualityFn } from "zustand/traditional";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { refreshProviderSubagents, useProviderSubagentStore } from "./provider-store";
import {
refreshProviderSubagents,
useProviderSubagentStore,
useProviderSubagentStoreApi,
} from "./provider-store";
import { useHostRuntimeStore, type HostAuthorityReference } from "@/runtime/host-runtime";
import type { AgentAddress, WorkspaceAddress } from "@getpaseo/protocol/addresses";
import type { ManagedSubagentState } from "./managed";
import type { SubagentRelationship } from "@getpaseo/protocol/messages";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import {
buildAgentFactsLine,
agentFactsSnapshot,
type AgentFactsSnapshot,
} from "@/panels/agent-facts-line";
import type { ProviderSubagentDescriptorPayload } from "@getpaseo/protocol/messages";
export interface PaseoSubagentRow {
kind: "paseo";
id: Agent["id"];
address: AgentAddress;
authority: HostAuthorityReference;
relationshipId: string;
workspace: WorkspaceAddress | null;
freshness: SubagentRelationship["freshness"];
phase: SubagentRelationship["phase"];
error: SubagentRelationship["error"];
provider: Agent["provider"];
title: Agent["title"];
/** Managed agents have a real title, so the union's task line is always absent for them. */
description: null;
subtitle: null;
status: Agent["status"];
turn: Agent["turn"];
subtitle: string | null;
facts: AgentFactsSnapshot | null;
status: Agent["status"] | null;
turn: Agent["turn"] | null;
requiresAttention: Agent["requiresAttention"];
createdAt: Agent["createdAt"];
createdAt: Agent["createdAt"] | null;
}
export interface ProviderSubagentRow {
@@ -39,7 +59,6 @@ export interface ProviderSubagentRow {
export type SubagentRow = PaseoSubagentRow | ProviderSubagentRow;
type SessionStoreSnapshot = ReturnType<typeof useSessionStore.getState>;
type ProviderSubagentStoreSnapshot = ReturnType<typeof useProviderSubagentStore.getState>;
interface SelectSubagentsParams {
@@ -49,79 +68,75 @@ interface SelectSubagentsParams {
providerParentSubagentId?: string;
}
const EMPTY_SUBAGENT_ROWS: SubagentRow[] = [];
const EMPTY_PROVIDER_SUBAGENT_ROWS: ProviderSubagentRow[] = [];
function toSubagentRow(agent: Agent): SubagentRow {
return {
kind: "paseo",
id: agent.id,
provider: agent.provider,
title: agent.title,
description: null,
subtitle: null,
status: agent.status,
turn: agent.turn,
requiresAttention: agent.requiresAttention,
createdAt: agent.createdAt,
};
export function subagentRowIdentity(row: SubagentRow): string {
return row.kind === "paseo"
? JSON.stringify(["paseo", row.address.serverId, row.address.agentId])
: JSON.stringify(["provider", row.parentAgentId, row.id]);
}
export function selectSubagentsForParent(
state: SessionStoreSnapshot,
params: SelectSubagentsParams,
pendingArchiveIds: ReadonlySet<string>,
): SubagentRow[] {
const agents = state.sessions[params.serverId]?.agents;
if (!agents || agents.size === 0) {
return EMPTY_SUBAGENT_ROWS;
state: ManagedSubagentState,
authority: HostAuthorityReference,
pendingArchiveKeys: ReadonlySet<string>,
): PaseoSubagentRow[] {
const rows: PaseoSubagentRow[] = [];
for (const relationship of state.rows) {
if (["detached", "archived", "forgotten", "canceled"].includes(relationship.phase)) continue;
const address = relationship.child;
if (pendingArchiveKeys.has(JSON.stringify([address.serverId, address.agentId]))) continue;
const snapshot = relationship.snapshot
? normalizeAgentSnapshot(relationship.snapshot, address.serverId)
: null;
rows.push({
kind: "paseo",
address,
authority: { originServerId: authority.originServerId, serverId: address.serverId },
relationshipId: relationship.id,
workspace: relationship.workspace ?? null,
phase: relationship.phase,
freshness: relationship.freshness,
error: relationship.error,
provider: snapshot?.provider ?? "",
title: snapshot?.title ?? null,
description: null,
subtitle: snapshot
? buildAgentFactsLine({ ...agentFactsSnapshot(snapshot), models: null })
: null,
facts: snapshot ? agentFactsSnapshot(snapshot) : null,
status: snapshot?.status ?? null,
turn: snapshot?.turn ?? null,
requiresAttention: snapshot?.requiresAttention ?? false,
createdAt: snapshot?.createdAt ?? null,
});
}
const rows: SubagentRow[] = [];
for (const agent of agents.values()) {
if (
agent.archivedAt ||
pendingArchiveIds.has(agent.id) ||
agent.parentAgentId !== params.parentAgentId
) {
continue;
}
rows.push(toSubagentRow(agent));
}
if (rows.length === 0) {
return EMPTY_SUBAGENT_ROWS;
}
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
rows.sort(compareSubagentRows);
return rows;
}
// Session-store selectors run on every store update, including every stream tick, which
// leaves the agent map untouched. Reuse the previous rows until the map changes, and keep
// them when a recompute yields equal rows so the hook's equality check stays O(1).
export function createSubagentsForParentSelector(
params: SelectSubagentsParams,
pendingArchiveIds: ReadonlySet<string>,
): (state: SessionStoreSnapshot) => SubagentRow[] {
let previousAgents: Map<string, Agent> | undefined;
let previousRows: SubagentRow[] | null = null;
authority: HostAuthorityReference,
pendingArchiveKeys: ReadonlySet<string>,
): (state: ManagedSubagentState) => PaseoSubagentRow[] {
let previous: ManagedSubagentState | null = null;
let previousRows: PaseoSubagentRow[] = [];
return (state) => {
const agents = state.sessions[params.serverId]?.agents;
if (previousRows && agents === previousAgents) {
return previousRows;
}
const rows = selectSubagentsForParent(state, params, pendingArchiveIds);
previousAgents = agents;
if (!previousRows || !equal(previousRows, rows)) {
previousRows = rows;
}
if (previous === state) return previousRows;
const rows = selectSubagentsForParent(state, authority, pendingArchiveKeys);
previous = state;
if (!equal(previousRows, rows)) previousRows = rows;
return previousRows;
};
}
function compareSubagentRows(left: SubagentRow, right: SubagentRow): number {
return (
(left.createdAt?.getTime() ?? 0) - (right.createdAt?.getTime() ?? 0) ||
subagentRowIdentity(left).localeCompare(subagentRowIdentity(right))
);
}
export function selectProviderSubagentsForParent(
state: ProviderSubagentStoreSnapshot,
params: SelectSubagentsParams,
@@ -153,18 +168,25 @@ export function selectProviderSubagentsForParent(
createdAt: new Date(subagent.createdAt),
});
}
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
rows.sort(compareSubagentRows);
return rows;
}
export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRow[] {
const { serverId, parentAgentId } = params;
const pendingArchiveIds = usePendingArchiveAgentIds(serverId);
const selectPaseoRows = useMemo(
() => createSubagentsForParentSelector({ serverId, parentAgentId }, pendingArchiveIds),
[serverId, parentAgentId, pendingArchiveIds],
const providerStore = useProviderSubagentStoreApi();
const runtime = useHostRuntimeStore();
const directory = runtime.subagents.directory({ serverId, agentId: parentAgentId });
const membership = useSyncExternalStore(
directory.subscribe,
directory.getSnapshot,
directory.getSnapshot,
);
const paseoRows = useMemo(
() =>
selectSubagentsForParent(membership, runtime.authorityFor(serverId), membership.archiving),
[membership, runtime, serverId],
);
const paseoRows = useStoreWithEqualityFn(useSessionStore, selectPaseoRows, equal);
const supported = useSessionStore(
(state) => state.sessions[params.serverId]?.serverInfo?.features?.providerSubagents === true,
);
@@ -173,7 +195,7 @@ export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRo
state.sessions[params.serverId]?.serverInfo?.features?.providerSubagentNesting === true,
);
const providerRows = useStoreWithEqualityFn(
useProviderSubagentStore,
providerStore,
(state) => selectProviderSubagentsForParent(state, params, supported, nestingSupported),
equal,
);
@@ -181,16 +203,19 @@ export function useSubagentsForParent(params: SelectSubagentsParams): SubagentRo
useEffect(() => {
if (!client || !supported) return;
void refreshProviderSubagents(client, params.serverId, params.parentAgentId).catch(
() => undefined,
);
}, [client, params.parentAgentId, params.serverId, supported]);
void refreshProviderSubagents(
client,
params.serverId,
params.parentAgentId,
providerStore,
).catch(() => undefined);
}, [client, params.parentAgentId, params.serverId, providerStore, supported]);
return useMemo(() => {
if (params.providerParentSubagentId) return providerRows;
if (providerRows.length === 0) return paseoRows;
const rows = [...paseoRows, ...providerRows];
rows.sort((left, right) => left.createdAt.getTime() - right.createdAt.getTime());
rows.sort(compareSubagentRows);
return rows;
}, [params.providerParentSubagentId, paseoRows, providerRows]);
}
@@ -0,0 +1,43 @@
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import type { SubagentRelationship } from "@getpaseo/protocol/messages";
import type { Agent } from "@/stores/session-store";
import { projectAgentSnapshot } from "@/utils/agent-snapshots";
import type { ManagedSubagentState } from "./managed";
export function relationshipFixture(
parent: AgentAddress,
agent: Agent,
overrides: Partial<SubagentRelationship> = {},
): SubagentRelationship {
return {
id: JSON.stringify([parent, agent.serverId, agent.id]),
parent,
child: { serverId: agent.serverId, agentId: agent.id },
...(agent.workspaceId
? { workspace: { serverId: agent.serverId, workspaceId: agent.workspaceId } }
: {}),
snapshot: projectAgentSnapshot(agent),
phase: "attached",
observedAt: agent.updatedAt.toISOString(),
freshness: "current",
error: null,
...overrides,
};
}
export function membershipFixture(
parent: AgentAddress,
rows: SubagentRelationship[],
): ManagedSubagentState {
return { parent, rows, status: "current", error: null, archiving: new Set() };
}
export function managedAgentFields(
parent: AgentAddress | null,
parentWorkspace: import("@getpaseo/protocol/addresses").WorkspaceAddress | null = null,
): Pick<Agent, "parent" | "managedRelationship"> {
return {
parent,
managedRelationship: parent ? { id: JSON.stringify(parent), parent, parentWorkspace } : null,
};
}
@@ -8,12 +8,17 @@ import {
resolveRowLabel,
} from "./track-presentation";
function row(
overrides: Partial<PaseoSubagentRow> & Pick<PaseoSubagentRow, "id">,
): PaseoSubagentRow {
function row(overrides: Partial<PaseoSubagentRow> & { id: string }): PaseoSubagentRow {
return {
kind: "paseo",
id: overrides.id,
facts: null,
address: { serverId: "server-1", agentId: overrides.id },
authority: { originServerId: "server-1", serverId: "server-1" },
relationshipId: overrides.id,
workspace: null,
freshness: "current",
phase: "attached",
error: null,
provider: overrides.provider ?? "codex",
title: overrides.title ?? `Agent ${overrides.id}`,
description: null,
@@ -170,7 +175,7 @@ describe("resolveRowLabel", () => {
describe("buildSubagentRowPresentationData", () => {
it("namespaces the key with a subagent prefix", () => {
expect(buildSubagentRowPresentationData(row({ id: "child-a" })).key).toBe(
"paseo_subagent_child-a",
JSON.stringify(["paseo", "server-1", "child-a"]),
);
});
@@ -2,13 +2,13 @@ import type { TFunction } from "i18next";
import type { ComposerTrackPillSegment } from "@/composer/tracks";
import type { SidebarStateBucket } from "@/utils/sidebar-agent-state";
import { deriveSidebarStateBucket, STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state";
import type { SubagentRow } from "./select";
import { subagentRowIdentity, type SubagentRow } from "./select";
import { isFinishedSubagent } from "./archive-finished";
import { providerSubagentLifecycleStatus } from "./provider-store";
function presentationStatus(row: SubagentRow) {
if (row.kind === "paseo") {
if (row.turn.phase === "open") return "running";
if (row.turn?.phase === "open") return "running";
return row.status === "running" ? "idle" : row.status;
}
return providerSubagentLifecycleStatus(row.status);
@@ -33,15 +33,18 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP
const subtitle = providerSubtitle ?? (description ? title : null);
const status = presentationStatus(row);
return {
key: `${row.kind}_subagent_${row.id}`,
key: subagentRowIdentity(row),
kind: "agent",
label: label ?? "",
subtitle: subtitle ?? "",
titleState: label ? "ready" : "loading",
statusBucket: deriveSidebarStateBucket({
status,
requiresAttention: false,
}),
statusBucket:
status === null
? null
: deriveSidebarStateBucket({
status,
requiresAttention: false,
}),
};
}
+52 -26
View File
@@ -1,3 +1,5 @@
import { HostAuthorityRoute } from "@/runtime/host-authority-route";
import { useAgentSnapshotFactsLine } from "@/panels/agent-facts-line";
import { useCallback, useMemo, type ReactElement, type ReactNode } from "react";
import { Text, View } from "react-native";
import { useTranslation } from "react-i18next";
@@ -14,9 +16,8 @@ import {
type WorkspaceTabPresentation,
} from "@/screens/workspace/workspace-tab-presentation";
import type { Theme } from "@/styles/theme";
import { useAgentFactsLine } from "@/panels/agent-facts-line";
import { getPanelManifest } from "@/panels/panel-manifest";
import type { SubagentRow } from "./select";
import { subagentRowIdentity, type SubagentRow, type PaseoSubagentRow } from "./select";
import type { ArchiveFinishedStatus } from "./use-archive-finished";
import {
buildSubagentPillPresentation,
@@ -35,17 +36,17 @@ const foregroundMutedColorMapping = (theme: Theme) => ({
export interface SubagentsTrackProps {
serverId: string;
rows: SubagentRow[];
onOpenSubagent: (id: string) => void;
onOpenSubagent: (row: PaseoSubagentRow) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
/**
* Whether opening this row shows it in a sheet. A sheet stacks over the list and uncovers it on
* close, so the list stays open; a tab replaces the list's pane, so choosing it ends the list.
*/
opensInSheet: (row: SubagentRow) => boolean;
onArchiveSubagent: (id: string) => void;
onArchiveSubagent: (row: PaseoSubagentRow) => void;
onArchiveFinished?: () => void;
archiveFinishedStatus?: ArchiveFinishedStatus;
onDetachSubagent?: (id: string) => void;
onDetachSubagent?: (row: PaseoSubagentRow) => void;
}
const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
@@ -53,7 +54,10 @@ const IDLE_ARCHIVE_FINISHED_STATUS: ArchiveFinishedStatus = { kind: "idle" };
const ROW_ICON_SIZE = 14;
function useRowPresentation(row: SubagentRow, serverId: string): WorkspaceTabPresentation {
const icon = useProviderIcon(row.provider, serverId);
const icon = useProviderIcon(
row.provider,
row.kind === "paseo" ? row.address.serverId : serverId,
);
const data = buildSubagentRowPresentationData(row);
return {
...data,
@@ -105,7 +109,7 @@ export function SubagentsTrack({
) : null}
{rows.map((row) => (
<SubagentsTrackRow
key={row.id}
key={subagentRowIdentity(row)}
row={row}
serverId={serverId}
onOpenSubagent={onOpenSubagent}
@@ -180,11 +184,11 @@ function ArchiveFinishedRow({
interface SubagentsTrackRowProps {
serverId: string;
row: SubagentRow;
onOpenSubagent: (id: string) => void;
onOpenSubagent: (row: PaseoSubagentRow) => void;
onOpenProviderSubagent: (parentAgentId: string, subagentId: string) => void;
opensInSheet: boolean;
onArchiveSubagent: (id: string) => void;
onDetachSubagent?: (id: string) => void;
onArchiveSubagent: (row: PaseoSubagentRow) => void;
onDetachSubagent?: (row: PaseoSubagentRow) => void;
}
function SubagentsTrackRow({
@@ -205,17 +209,22 @@ function SubagentsTrackRow({
if (row.kind === "provider") {
onOpenProviderSubagent(row.parentAgentId, row.id);
} else {
onOpenSubagent(row.id);
onOpenSubagent(row);
}
}, [onOpenProviderSubagent, onOpenSubagent, row]);
const handleArchivePress = useCallback(() => {
onArchiveSubagent(row.id);
}, [onArchiveSubagent, row.id]);
if (row.kind === "paseo") onArchiveSubagent(row);
}, [onArchiveSubagent, row]);
const handleDetachPress = useCallback(() => {
onDetachSubagent?.(row.id);
}, [onDetachSubagent, row.id]);
if (row.kind === "paseo") onDetachSubagent?.(row);
}, [onDetachSubagent, row]);
const actionsAlwaysVisible = isNative || isCompact;
const rowId = row.kind === "paseo" ? row.address.agentId : row.id;
const pendingFacts = useMemo(
() => <SubagentFacts rowId={rowId} text={row.subtitle} />,
[rowId, row.subtitle],
);
const renderRow = useCallback(
({ active }: { active: boolean }) => (
<>
@@ -225,14 +234,31 @@ function SubagentsTrackRow({
{displayLabel}
</Text>
{row.kind === "paseo" ? (
<PaseoSubagentFacts serverId={serverId} agentId={row.id} />
<>
<HostAuthorityRoute reference={row.authority} pending={pendingFacts}>
<ManagedSubagentFacts row={row} />
</HostAuthorityRoute>
{row.freshness !== "current" ||
row.phase === "detach_pending" ||
row.phase === "lifecycle_pending" ? (
<SubagentFacts
rowId={`${rowId}-availability`}
text={
row.error?.message ??
(row.phase.endsWith("pending")
? t("agentList.badges.pending", { count: 1 })
: t("common.errors.daemonUnavailable"))
}
/>
) : null}
</>
) : (
<SubagentFacts rowId={row.id} text={presentation.subtitle} />
<SubagentFacts rowId={rowId} text={presentation.subtitle} />
)}
</View>
{row.kind === "paseo" ? (
<SubagentRowActions
rowId={row.id}
rowId={rowId}
displayLabel={displayLabel}
compact={isCompact}
visible={actionsAlwaysVisible || active}
@@ -250,16 +276,17 @@ function SubagentsTrackRow({
isCompact,
onDetachSubagent,
presentation,
row.kind,
row.id,
serverId,
pendingFacts,
row,
rowId,
t,
],
);
return (
<ComposerTrackRow
accessibilityLabel={displayLabel}
testID={`subagents-track-row-${row.id}`}
testID={`subagents-track-row-${rowId}`}
closeOnSelect={!opensInSheet}
onPress={handlePress}
>
@@ -268,10 +295,9 @@ function SubagentsTrackRow({
);
}
/** The same facts line the agent's sheet shows when it opens without its composer. */
function PaseoSubagentFacts({ serverId, agentId }: { serverId: string; agentId: string }) {
const text = useAgentFactsLine(serverId, agentId);
return <SubagentFacts rowId={agentId} text={text} />;
function ManagedSubagentFacts({ row }: { row: PaseoSubagentRow }) {
const text = useAgentSnapshotFactsLine(row.address.serverId, row.facts);
return <SubagentFacts rowId={row.address.agentId} text={text} />;
}
function SubagentFacts({ rowId, text }: { rowId: string; text: string | null }) {
@@ -1,13 +1,12 @@
import { useEffect, useMemo, useSyncExternalStore } from "react";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeStore } from "@/runtime/host-runtime";
import {
createArchiveFinishedSubagents,
type ArchiveFinishedOutcome,
type ArchiveFinishedState,
type ArchiveFinishedSubagents,
} from "./archive-finished";
import { useProviderSubagentStore } from "./provider-store";
import { useProviderSubagentStoreApi } from "./provider-store";
import type { SubagentRow } from "./select";
export type { ArchiveFinishedStatus } from "./archive-finished";
@@ -27,18 +26,38 @@ export function useArchiveFinishedSubagents({
parentAgentId,
rows,
}: UseArchiveFinishedSubagentsInput): ArchiveFinishedSubagentsCapability {
const { archiveAgent } = useArchiveAgent();
const runtime = useHostRuntimeStore();
const providerStore = useProviderSubagentStoreApi();
const directory = runtime.subagents.directory({ serverId, agentId: parentAgentId });
const archiveFinished = useMemo<ArchiveFinishedSubagents>(
() =>
createArchiveFinishedSubagents([], {
parentAgentId,
getManagedSubagent: (id) => useSessionStore.getState().sessions[serverId]?.agents.get(id),
archiveManagedSubagent: (id) => archiveAgent({ serverId, agentId: id }),
parent: { serverId, agentId: parentAgentId },
getManagedSubagent: (address) => {
const row = directory
.getSnapshot()
.rows.find(
(candidate) =>
candidate.child.serverId === address.serverId &&
candidate.child.agentId === address.agentId,
);
return row && row.phase === "attached"
? {
address: row.child,
parent: row.parent,
status: row.snapshot?.status ?? null,
freshness: row.freshness,
phase: row.phase,
archivedAt: row.snapshot?.archivedAt ? new Date(row.snapshot.archivedAt) : null,
}
: undefined;
},
archiveManagedSubagent: directory.archive,
dismissProviderSubagents: (ids) => {
useProviderSubagentStore.getState().hideFromTrack(serverId, parentAgentId, ids);
providerStore.getState().hideFromTrack(serverId, parentAgentId, ids);
},
}),
[archiveAgent, parentAgentId, serverId],
[directory, parentAgentId, providerStore, serverId],
);
const state = useSyncExternalStore(
archiveFinished.subscribe,
@@ -1,7 +1,8 @@
import { useCallback } from "react";
import { useToast } from "@/contexts/toast-context";
import { useArchiveAgent } from "@/hooks/use-archive-agent";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeStore } from "@/runtime/host-runtime";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import type { PaseoSubagentRow } from "./select";
import { confirmDialog } from "@/utils/confirm-dialog";
import { toErrorMessage } from "@/utils/error-messages";
import { requestArchiveSubagent, type ResolveArchiveSubagentDialogInput } from "./archive-subagent";
@@ -14,29 +15,37 @@ export type {
} from "./archive-subagent";
export interface UseArchiveSubagentInput {
serverId: string;
parent: AgentAddress;
}
export function useArchiveSubagent(input: UseArchiveSubagentInput): (subagentId: string) => void {
const { archiveAgent } = useArchiveAgent();
const { serverId } = input;
export function useArchiveSubagent(
input: UseArchiveSubagentInput,
): (row: PaseoSubagentRow) => void {
const runtime = useHostRuntimeStore();
const directory = runtime.subagents.directory(input.parent);
const toast = useToast();
return useCallback(
(subagentId: string) => {
(row: PaseoSubagentRow) => {
void requestArchiveSubagent(
{ serverId, subagentId },
{ child: row.address },
{
getSubagent: (id): ResolveArchiveSubagentDialogInput | undefined =>
useSessionStore.getState().sessions[serverId]?.agents?.get(id),
getSubagent: (child): ResolveArchiveSubagentDialogInput | undefined =>
directory
.getSnapshot()
.rows.find(
(current) =>
current.child.serverId === child.serverId &&
current.child.agentId === child.agentId,
)?.snapshot,
confirm: confirmDialog,
archiveAgent,
archiveAgent: directory.archive,
reportError: (error) => {
toast.error(toErrorMessage(error));
},
},
);
},
[archiveAgent, serverId, toast],
[directory, toast],
);
}
@@ -1,7 +1,9 @@
import { useCallback } from "react";
import { useToast } from "@/contexts/toast-context";
import { i18n } from "@/i18n/i18next";
import { useSessionStore } from "@/stores/session-store";
import { useHostRuntimeStore } from "@/runtime/host-runtime";
import type { AgentAddress } from "@getpaseo/protocol/addresses";
import type { PaseoSubagentRow } from "./select";
import { confirmDialog } from "@/utils/confirm-dialog";
import { toErrorMessage } from "@/utils/error-messages";
import { navigateToAgent } from "@/utils/navigate-to-agent";
@@ -15,30 +17,32 @@ export type {
} from "./detach-subagent";
export interface UseDetachSubagentInput {
serverId: string;
parent: AgentAddress;
}
export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: string) => void {
const { serverId } = input;
export function useDetachSubagent(input: UseDetachSubagentInput): (row: PaseoSubagentRow) => void {
const runtime = useHostRuntimeStore();
const directory = runtime.subagents.directory(input.parent);
const toast = useToast();
return useCallback(
(subagentId: string) => {
(row: PaseoSubagentRow) => {
void requestDetachSubagent(
{ serverId, subagentId },
{ child: row.address },
{
getSubagent: (id): ResolveDetachSubagentDialogInput | undefined =>
useSessionStore.getState().sessions[serverId]?.agents?.get(id),
getSubagent: (child): ResolveDetachSubagentDialogInput | undefined =>
directory
.getSnapshot()
.rows.find(
(current) =>
current.child.serverId === child.serverId &&
current.child.agentId === child.agentId,
)?.snapshot,
confirm: confirmDialog,
detachAgent: async ({ serverId: targetServerId, agentId }) => {
const client = useSessionStore.getState().sessions[targetServerId]?.client;
if (!client) {
throw new Error(i18n.t("workspaceSetup.errors.hostDisconnected"));
}
await client.detachAgent(agentId);
},
detachAgent: directory.detach,
reportPending: () => toast.show(i18n.t("agentList.badges.pending", { count: 1 })),
openDetachedAgent: ({ serverId: targetServerId, agentId }) => {
navigateToAgent({ serverId: targetServerId, agentId });
navigateToAgent({ serverId: targetServerId, agentId, authority: row.authority });
},
reportError: (error) => {
toast.error(toErrorMessage(error));
@@ -46,6 +50,6 @@ export function useDetachSubagent(input: UseDetachSubagentInput): (subagentId: s
},
);
},
[serverId, toast],
[directory, toast],
);
}
@@ -1,17 +1,25 @@
import type { Agent } from "@/stores/session-store";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
type WorkspaceAgent = Pick<Agent, "parentAgentId" | "workspaceId">;
type WorkspaceAgent = Pick<Agent, "serverId" | "workspaceId" | "managedRelationship">;
export function isWorkspaceRootAgent(
agent: WorkspaceAgent,
parentAgent: Pick<Agent, "workspaceId"> | undefined,
parentAgent?: Pick<Agent, "id" | "serverId" | "workspaceId">,
): boolean {
if (!agent.parentAgentId) {
return true;
}
const membership = agent.managedRelationship;
if (!membership) return true;
const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId);
const parentWorkspaceId = normalizeWorkspaceOpaqueId(parentAgent?.workspaceId);
return Boolean(workspaceId && parentWorkspaceId && workspaceId !== parentWorkspaceId);
const parentWorkspace =
membership.parentWorkspace ??
(parentAgent?.serverId === membership.parent.serverId &&
parentAgent.id === membership.parent.agentId &&
parentAgent.workspaceId
? { serverId: parentAgent.serverId, workspaceId: parentAgent.workspaceId }
: null);
return Boolean(
workspaceId &&
parentWorkspace &&
(agent.serverId !== parentWorkspace.serverId || workspaceId !== parentWorkspace.workspaceId),
);
}
@@ -1,3 +1,4 @@
import { managedAgentFields } from "@/subagents/test-fixtures";
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { pickAttentionAgent, shouldClearAgentAttention } from "@/utils/agent-attention";
@@ -28,7 +29,8 @@ function createAgent(input: Partial<Agent> & Pick<Agent, "id">): Agent {
title: null,
cwd: "/repo/worktree",
model: null,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
requiresAttention: false,
attentionReason: null,
@@ -219,7 +221,7 @@ describe("pickAttentionAgent", () => {
pickAttentionAgent([
createAgent({
id: "subagent",
parentAgentId: "parent-agent",
...managedAgentFields({ serverId: "server-1", agentId: "parent-agent" }),
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
@@ -239,7 +241,7 @@ describe("pickAttentionAgent", () => {
pickAttentionAgent([
createAgent({
id: "subagent",
parentAgentId: "parent-agent",
...managedAgentFields({ serverId: "server-1", agentId: "parent-agent" }),
requiresAttention: true,
attentionReason: "permission",
attentionTimestamp: new Date("2026-01-01T00:00:00.000Z"),
+1 -1
View File
@@ -38,7 +38,7 @@ export function pickAttentionAgent(agents: Agent[]): string | null {
continue;
}
if (agent.parentAgentId) {
if (agent.managedRelationship) {
continue;
}
@@ -1,7 +1,6 @@
import { describe, expect, it } from "vitest";
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { useSessionStore } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -18,6 +17,8 @@ function createAgentPayload(
): AgentSnapshotPayload {
return {
id: input.id,
parent: input.parent,
managedRelationship: input.managedRelationship,
provider: input.provider ?? "codex",
cwd: input.cwd ?? "/repo",
model: input.model ?? null,
@@ -156,7 +157,7 @@ describe("replaceFetchedAgentDirectory", () => {
store.clearSession(serverId);
});
it("re-derives parentAgentId every time an agent snapshot is ingested", () => {
it("replaces owner membership every time an agent snapshot is ingested", () => {
const serverId = "server-1";
const store = useSessionStore.getState();
store.initializeSession(serverId, null as unknown as DaemonClient);
@@ -166,7 +167,11 @@ describe("replaceFetchedAgentDirectory", () => {
createEntry(
createAgentPayload({
id: "child-1",
labels: { [PARENT_AGENT_ID_LABEL]: "parent-a" },
managedRelationship: {
id: "relationship",
parent: { serverId, agentId: "parent-a" },
parentWorkspace: null,
},
}),
),
]);
@@ -175,14 +180,19 @@ describe("replaceFetchedAgentDirectory", () => {
createEntry(
createAgentPayload({
id: "child-1",
labels: { [PARENT_AGENT_ID_LABEL]: "parent-b" },
managedRelationship: {
id: "relationship",
parent: { serverId, agentId: "parent-b" },
parentWorkspace: null,
},
}),
),
]);
expect(
useSessionStore.getState().sessions[serverId]?.agents.get("child-1")?.parentAgentId,
).toBe("parent-b");
useSessionStore.getState().sessions[serverId]?.agents.get("child-1")?.managedRelationship
?.parent,
).toEqual({ serverId, agentId: "parent-b" });
store.clearSession(serverId);
});
@@ -2,7 +2,7 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { Agent } from "@/stores/session-store";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { resolveProjectPlacement } from "@/utils/project-placement";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import type { ServerInfoStatusPayload, SessionOutboundMessage } from "@getpaseo/protocol/messages";
type AgentDirectoryFetchEntry = FetchAgentsEntry;
export type AgentDirectoryDelta = Extract<
@@ -19,6 +19,7 @@ interface PendingPermissionEntry {
export function buildAgentDirectoryState(input: {
serverId: string;
entries: AgentDirectoryFetchEntry[];
serverInfo?: Pick<ServerInfoStatusPayload, "features"> | null;
}): {
agents: Map<string, Agent>;
pendingPermissions: Map<string, PendingPermissionEntry>;
@@ -27,7 +28,7 @@ export function buildAgentDirectoryState(input: {
const pendingPermissions = new Map<string, PendingPermissionEntry>();
for (const entry of input.entries) {
const normalized = normalizeAgentSnapshot(entry.agent, input.serverId);
const normalized = normalizeAgentSnapshot(entry.agent, input.serverId, input.serverInfo);
const projectPlacement = resolveProjectPlacement({
projectPlacement: entry.project,
cwd: normalized.cwd,
+43 -8
View File
@@ -10,6 +10,8 @@ function createSnapshot(
): AgentSnapshotPayload {
return {
id: input.id ?? "agent-1",
parent: input.parent,
managedRelationship: input.managedRelationship,
provider: input.provider ?? "codex",
cwd: input.cwd ?? "/repo",
model: input.model ?? null,
@@ -78,15 +80,15 @@ describe("normalizeAgentSnapshot", () => {
});
});
it("derives parentAgentId from the parent label while preserving labels", () => {
it("normalizes legacy parent identity from the parent label while preserving labels", () => {
const labels = {
[PARENT_AGENT_ID_LABEL]: "parent-1",
"custom.label": "still-here",
};
const agent = normalizeAgentSnapshot(createSnapshot({ labels }), "server-1");
const agent = normalizeAgentSnapshot(createSnapshot({ labels }), "server-1", { features: {} });
expect(agent.parentAgentId).toBe("parent-1");
expect(agent.parent).toEqual({ serverId: "server-1", agentId: "parent-1" });
expect(agent.labels).toEqual(labels);
});
@@ -94,24 +96,57 @@ describe("normalizeAgentSnapshot", () => {
const agent = normalizeAgentSnapshot(
createSnapshot({ labels: { [PARENT_AGENT_ID_LABEL]: " parent-1 \n" } }),
"server-1",
{ features: {} },
);
expect(agent.parentAgentId).toBe("parent-1");
expect(agent.parent).toEqual({ serverId: "server-1", agentId: "parent-1" });
});
it("maps missing, empty, and non-string parent labels to null", () => {
const missing = normalizeAgentSnapshot(createSnapshot(), "server-1");
const missing = normalizeAgentSnapshot(createSnapshot(), "server-1", { features: {} });
const empty = normalizeAgentSnapshot(
createSnapshot({ labels: { [PARENT_AGENT_ID_LABEL]: " " } }),
"server-1",
{ features: {} },
);
const nonString = normalizeAgentSnapshot(
createSnapshot({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } }),
"server-1",
{ features: {} },
);
expect(missing.parentAgentId).toBeNull();
expect(empty.parentAgentId).toBeNull();
expect(nonString.parentAgentId).toBeNull();
expect(missing.parent).toBeNull();
expect(empty.parent).toBeNull();
expect(nonString.parent).toBeNull();
});
});
it("capable snapshots never acquire membership from a parent pointer or injected label", () => {
for (const parent of [
{ serverId: "server-1", agentId: "parent" },
{ serverId: "other", agentId: "parent" },
]) {
const normalized = normalizeAgentSnapshot(
createSnapshot({ parent, labels: { [PARENT_AGENT_ID_LABEL]: "parent" } }),
"server-1",
{ features: { fleetSubagents: true } },
);
expect(normalized.parent).toEqual(parent);
expect(normalized.managedRelationship).toBeNull();
expect(projectAgentSnapshot(normalized)).toMatchObject({ parent, managedRelationship: null });
}
});
it("round trips owner membership and explicit clearing without consulting labels", () => {
const managedRelationship = {
id: "relationship",
parent: { serverId: "A", agentId: "parent" },
parentWorkspace: { serverId: "A", workspaceId: "same-workspace" },
};
const input = createSnapshot({ parent: managedRelationship.parent, managedRelationship });
expect(projectAgentSnapshot(normalizeAgentSnapshot(input, "A"))).toMatchObject({
managedRelationship,
});
expect(
projectAgentSnapshot(normalizeAgentSnapshot({ ...input, managedRelationship: null }, "A")),
).toMatchObject({ managedRelationship: null });
});
+43 -4
View File
@@ -1,4 +1,4 @@
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import type { AgentSnapshotPayload, ServerInfoStatusPayload } from "@getpaseo/protocol/messages";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import {
@@ -60,6 +60,8 @@ export function derivePendingPermissionKey(
export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
return {
id: agent.id,
parent: agent.parent,
managedRelationship: agent.managedRelationship,
provider: agent.provider,
cwd: agent.cwd,
...(agent.workspaceId ? { workspaceId: agent.workspaceId } : {}),
@@ -88,7 +90,39 @@ export function projectAgentSnapshot(agent: Agent): AgentSnapshotPayload {
};
}
export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId: string) {
function normalizeManagedMembership(
snapshot: AgentSnapshotPayload,
serverId: string,
serverInfo?: Pick<ServerInfoStatusPayload, "features"> | null,
) {
// COMPAT(legacyManagedMembership): added in v0.12.0, remove after 2027-04-20.
// A capable daemon's absent projection never grants membership from labels or a pointer.
const legacyParentId =
serverInfo &&
serverInfo.features?.fleetSubagents !== true &&
snapshot.parent === undefined &&
snapshot.managedRelationship === undefined
? getParentAgentIdFromLabels(snapshot.labels)
: null;
const legacyParent = legacyParentId ? { serverId, agentId: legacyParentId } : null;
const parent = snapshot.parent === undefined ? legacyParent : snapshot.parent;
const managedRelationship =
snapshot.managedRelationship ??
(legacyParentId
? {
id: `legacy:${serverId}:${snapshot.id}`,
parent: { serverId, agentId: legacyParentId },
parentWorkspace: null,
}
: null);
return { parent, managedRelationship };
}
export function normalizeAgentSnapshot(
snapshot: AgentSnapshotPayload,
serverId: string,
serverInfo?: Pick<ServerInfoStatusPayload, "features"> | null,
) {
const createdAt = new Date(snapshot.createdAt);
const updatedAt = new Date(snapshot.updatedAt);
const lastUserMessageAt = snapshot.lastUserMessageAt
@@ -98,7 +132,11 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
? new Date(snapshot.attentionTimestamp)
: null;
const archivedAt = snapshot.archivedAt ? new Date(snapshot.archivedAt) : null;
const parentAgentId = getParentAgentIdFromLabels(snapshot.labels);
const { parent, managedRelationship } = normalizeManagedMembership(
snapshot,
serverId,
serverInfo,
);
// COMPAT(agentTurnIdentity): added in v0.2.6, remove after 2027-01-31 once daemon floor >= v0.2.6.
// Old daemons expose only status. Normalize that legacy signal once so the rest
// of the app consumes one activity shape.
@@ -132,7 +170,8 @@ export function normalizeAgentSnapshot(snapshot: AgentSnapshotPayload, serverId:
attentionReason: snapshot.attentionReason ?? null,
attentionTimestamp,
archivedAt,
parentAgentId,
parent,
managedRelationship,
labels: snapshot.labels,
};
}
+18 -2
View File
@@ -289,7 +289,7 @@ export function parseHostAgentRouteFromPathname(
export function parseHostWorkspaceRouteFromPathname(
pathname: string,
): { serverId: string; workspaceId: string } | null {
): { serverId: string; workspaceId: string; authority?: { originServerId: string } } | null {
const pathOnly = stripSearchAndHash(pathname);
const match = pathOnly.match(/^\/h\/([^/]+)\/workspace\/([^/]+)\/?$/);
if (!match) {
@@ -306,7 +306,8 @@ export function parseHostWorkspaceRouteFromPathname(
if (!workspaceId) {
return null;
}
return { serverId, workspaceId };
const authority = getHostRouteAuthority(pathname);
return { serverId, workspaceId, ...(authority ? { authority } : {}) };
}
export function stripHostWorkspaceRouteEchoSearch(route: string): string {
@@ -359,6 +360,16 @@ export function buildHostWorkspaceRoute(serverId: string, workspaceId: string) {
return `/h/${encodeSegment(normalizedServerId)}/workspace/${encodeSegment(encodedWorkspaceId)}` as const;
}
/** Route metadata identifies the intended authority owner; restoration reacquires its grants. */
export function withHostRouteAuthority(
route: string,
authority: { originServerId: string },
): string {
const params = new URLSearchParams(extractSearch(route));
params.set("origin", authority.originServerId);
return `${stripSearchAndHash(route)}?${params.toString()}${extractHash(route)}`;
}
export function buildHostWorkspaceOpenRoute(
serverId: string,
workspaceId: string,
@@ -590,3 +601,8 @@ export function normalizeProjectSettingsRouteId(value: string | string[] | undef
const id = Array.isArray(value) ? value[0] : value;
return typeof id === "string" ? id : "";
}
export function getHostRouteAuthority(route: string): { originServerId: string } | null {
const originServerId = new URLSearchParams(extractSearch(route)).get("origin")?.trim();
return originServerId ? { originServerId } : null;
}
@@ -7,7 +7,11 @@ export type { NavigateToAgentInput } from "./resolve";
export function navigateToAgent(input: NavigateToAgentInput): string {
return resolveNavigateToAgent(input, {
readAgentNavTarget: ({ serverId, agentId }) => {
readAgentNavTarget: ({ serverId, agentId, authority }) => {
// COMPAT(unboundAgentNavigation): added in v0.12, remove after 2027-04-20.
// Older callers use the app registry. Bound navigation resolves unknown workspace
// metadata through its named route instead of consulting another authority's cache.
if (authority) return { agentWorkspaceId: null };
const session = useSessionStore.getState().sessions[serverId];
const agent = session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
return {
@@ -1,4 +1,4 @@
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { buildHostAgentDetailRoute, withHostRouteAuthority } from "@/utils/host-routes";
import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity";
import type { NavigateToWorkspaceInput } from "@/stores/navigation-active-workspace-store";
@@ -9,6 +9,7 @@ export interface NavigateToAgentInput {
// (cold deep-links). Otherwise the workspace is read from the store.
workspaceId?: string | null;
pin?: boolean;
authority?: { originServerId: string };
}
export interface AgentNavTarget {
@@ -16,7 +17,11 @@ export interface AgentNavTarget {
}
export interface NavigateToAgentDeps {
readAgentNavTarget: (input: { serverId: string; agentId: string }) => AgentNavTarget;
readAgentNavTarget: (input: {
serverId: string;
agentId: string;
authority?: { originServerId: string };
}) => AgentNavTarget;
navigateToHostAgent: (route: string) => void;
navigateToWorkspace: (input: NavigateToWorkspaceInput) => string;
}
@@ -27,11 +32,16 @@ export function resolveNavigateToAgent(
): string {
const agentWorkspaceId =
input.workspaceId ??
deps.readAgentNavTarget({ serverId: input.serverId, agentId: input.agentId }).agentWorkspaceId;
deps.readAgentNavTarget({
serverId: input.serverId,
agentId: input.agentId,
...(input.authority ? { authority: input.authority } : {}),
}).agentWorkspaceId;
const workspaceId = normalizeWorkspaceOpaqueId(agentWorkspaceId);
if (!workspaceId) {
const route = buildHostAgentDetailRoute(input.serverId, input.agentId);
const base = buildHostAgentDetailRoute(input.serverId, input.agentId);
const route = input.authority ? withHostRouteAuthority(base, input.authority) : base;
deps.navigateToHostAgent(route);
return route;
}
@@ -41,5 +51,6 @@ export function resolveNavigateToAgent(
workspaceId,
target: { kind: "agent", agentId: input.agentId },
pin: input.pin,
...(input.authority ? { authority: input.authority } : {}),
});
}
@@ -1,3 +1,4 @@
import { managedAgentFields } from "@/subagents/test-fixtures";
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import { buildWorkspaceAgentActivityIndex } from "./workspace-agent-activity";
@@ -60,7 +61,9 @@ function agent(input: {
attentionReason: input.attentionReason,
attentionTimestamp: input.attentionTimestamp ? new Date(input.attentionTimestamp) : null,
archivedAt: input.archivedAt ? new Date(input.archivedAt) : null,
parentAgentId: input.parentAgentId ?? null,
...managedAgentFields(
input.parentAgentId ? { serverId: "host-a", agentId: input.parentAgentId } : null,
),
labels: {},
};
}
@@ -21,7 +21,9 @@ export function buildWorkspaceAgentActivityIndex(
const latestActivityAtByWorkspaceId = new Map<string, Date>();
for (const agent of agents.values()) {
const parentAgent = agent.parentAgentId ? agents.get(agent.parentAgentId) : undefined;
const parentAgent = agent.managedRelationship?.parent.agentId
? agents.get(agent.managedRelationship?.parent.agentId)
: undefined;
if (agent.archivedAt || !agent.workspaceId || !isWorkspaceRootAgent(agent, parentAgent)) {
continue;
}
@@ -1,3 +1,4 @@
import { managedAgentFields } from "@/subagents/test-fixtures";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { afterEach, describe, expect, it } from "vitest";
import { useSessionStore, type Agent } from "@/stores/session-store";
@@ -51,7 +52,9 @@ function makeAgent(input: {
workspaceId: input.workspaceId,
model: null,
thinkingOptionId: null,
parentAgentId: input.parentAgentId ?? null,
...managedAgentFields(
input.parentAgentId ? { serverId: "srv", agentId: input.parentAgentId } : null,
),
labels: {},
requiresAttention: false,
attentionReason: null,
@@ -521,3 +524,28 @@ describe("workspace agent visibility", () => {
});
});
});
it("compares qualified workspace identity and ignores an unattested parent pointer", () => {
const child = makeAgent({ id: "child", cwd: "/repo", workspaceId: "same" });
const parent = { serverId: "A", agentId: "parent" };
const attested = {
...child,
...managedAgentFields(parent, { serverId: "A", workspaceId: "same" }),
};
const visibility = (value: Agent) =>
deriveWorkspaceAgentVisibility({
sessionAgents: new Map([[value.id, value]]),
workspaceId: "same",
}).autoOpenAgentIds;
expect(visibility(attested)).toEqual(new Set(["child"]));
expect(
visibility({
...attested,
managedRelationship: {
...attested.managedRelationship!,
parentWorkspace: { serverId: "srv", workspaceId: "same" },
},
}),
).toEqual(new Set());
expect(visibility({ ...child, parent })).toEqual(new Set(["child"]));
});
@@ -34,8 +34,9 @@ export function deriveWorkspaceAgentVisibility(input: {
}
if (!agent.archivedAt) {
activeAgentIds.add(agent.id);
const parentAgent = agent.parentAgentId
? (sessionAgents?.get(agent.parentAgentId) ?? agentDetails?.get(agent.parentAgentId))
const parentAgent = agent.managedRelationship?.parent.agentId
? (sessionAgents?.get(agent.managedRelationship?.parent.agentId) ??
agentDetails?.get(agent.managedRelationship?.parent.agentId))
: undefined;
if (isWorkspaceRootAgent(agent, parentAgent)) {
autoOpenAgentIds.add(agent.id);

Some files were not shown because too many files have changed in this diff Show More