Compare commits

...
Author SHA1 Message Date
Mohamed Boudra d369c9d36b feat(subagents): connect fleet MCP delegation to app and parent completion
Parented creation enters the relationship owner before resource admission. Initial run evidence drives one qualified completion consumer, while ordinary bound conversations own their directory demand and workspace selectors. Preserve manager-owned archive lifecycles and verify managed callbacks through the parent capability.
2026-10-20 17:41:52 +02:00
Mohamed Boudra 12890f8ab0 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.
2026-10-20 15:09:50 +02:00
Mohamed Boudra 7c4aa058ff feat(hosts): bind replica and session lifetime to origin authority 2026-10-20 13:23:04 +02:00
Mohamed Boudra 702bc942b7 fix(agents): retain creation identity and dispatch evidence across hosts 2026-10-20 12:15:51 +02:00
Mohamed Boudra b1b5010bd2 feat(agents): persist qualified subagent lifecycle and run evidence 2026-10-20 10:57:13 +02:00
Mohamed Boudra 888b54bb44 feat(hosts): resolve qualified agent addresses through shared runtimes
Keep agent identity and origin authority stable through disconnects, destination changes, and scoped disposal. Share the resolver with trusted daemon consumers and plugins, using ordinary sessions and per-admission permission ceilings. Expose MCP host discovery through that boundary as the first checkpoint toward durable fleet subagents.
2026-10-20 09:32:17 +02:00
Mohamed Boudra 63c3bb8c42 test(e2e): keep transient developer toasts from stalling checks
Concurrent locator handlers can wait for a dismiss button another handler removed. Dismiss existing toast DOM at document lifetime, including its open shadow root, without intercepting app actions.
2026-10-20 08:06:15 +02:00
Mohamed Boudra 569b34263d refactor(hosts): use the ordinary client contract over channel transports
Keep queuing, permission codes and reconnection independent of transport selection. Track submitted request outcomes generically while retaining connection error codes and structured causes.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 71cc853f7b test(hosts): control physical write backpressure across platforms
Hold real TCP writes until assertions finish instead of assuming a paused receiver exhausts every platform receive buffer. Preserve physical send settlement, byte identity, capacity and sibling checks.
2026-10-20 07:59:01 +02:00
Mohamed Boudra f2dfa83e83 refactor(hosts): remove management settings UI from phase one
Keep connected-host management in the CLI and shared SDK while preserving app saved connections and plugin access.
2026-10-20 07:59:01 +02:00
Mohamed Boudra fb4ca20dbd fix(hosts): keep compact row actions clear of host labels
Give compact connected-host rows separate text and action widths so long names and producer labels cannot compete with primary controls.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 4e2aa97a74 fix(hosts): bound encrypted overload and initial acquisition
Check encrypted wire capacity before the async channel converts a resource limit into a physical connection error. Bound initial routed acquisition with the existing client timeout, then enable reconnect after admission.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 5ab9b8dcd4 fix(hosts): share daemon links across isolated routed sessions
Multiplex logical sessions over the monitored daemon connection and wrap original binary bytes in routing headers. Admit channels under their authenticated parent and current grants; preserve scoped delivery and physical send completion.
2026-10-20 07:59:01 +02:00
Mohamed Boudra 4284209025 fix(cli): preserve validated addresses in daemon command hints 2026-10-20 07:59:01 +02:00
Mohamed Boudra 6ae87ec7e2 test(hosts): include relay coverage in existing CI contracts 2026-10-20 07:59:01 +02:00
Mohamed Boudra 630e8301ad fix(hosts): validate safe targets and verify encrypted relay routes 2026-10-20 07:59:01 +02:00
Mohamed Boudra 74bb3e76e4 feat(hosts): connect to saved hosts through a daemon 2026-10-20 07:59:01 +02:00
239 changed files with 20092 additions and 4985 deletions
+1
View File
@@ -46,6 +46,7 @@ At the start of non-trivial work, list `docs/` and skim anything relevant to the
| [docs/rpc-namespacing.md](docs/rpc-namespacing.md) | WebSocket RPC naming convention — dotted namespaces and `.request`/`.response` pairs |
| [docs/protocol-compatibility.md](docs/protocol-compatibility.md) | Why app/daemon versions drift, protocol vs feature contract, capability gating, COMPAT tagging |
| [docs/protocol-validation.md](docs/protocol-validation.md) | zod-aot generated inbound WebSocket validation, patched compiler regressions, schema-purity rules |
| [docs/connected-hosts.md](docs/connected-hosts.md) | Daemon-owned host registrations, routed SDK scopes, identity and authority boundaries |
| [docs/permissions.md](docs/permissions.md) | Semantic daemon permissions, principals, credentials, pairing invitations, and Hub authority |
| [docs/terminal-performance.md](docs/terminal-performance.md) | Terminal latency pipeline, coalescing/backpressure invariants, benchmark + perf spec usage |
| [docs/agent-stream-performance.md](docs/agent-stream-performance.md) | Assistant text pipeline — coalescing window, paced reveal, why arrival lumps are smoothed at render |
+3
View File
@@ -4,6 +4,9 @@ Paseo is a client-server system for monitoring and controlling local AI coding a
Your code never leaves your machine. Paseo is local-first.
Daemon-owned outbound connections and routed API scope ownership are described in
[Connected hosts](connected-hosts.md).
## System overview
```
+83
View File
@@ -0,0 +1,83 @@
# Connected hosts
A saved host belongs to the daemon that stores it. The app's saved connections remain a separate list.
Connect A to B through `paseo --host A host` or `paseo.hosts`. Consumers select B by its learned `serverId`; management uses the stable
`registrationId` created before B is reachable.
## Ownership and authority
Credentials live in A's `hosts.json`, written atomically with mode `0600` on POSIX systems. Discovery returns a safe
target description. Replacement inputs never come back through a read API. Keep this file private
when collecting diagnostics or backing up daemon state.
The authenticated producer owns its source key. Repeating an unchanged add returns the saved entry,
including a user's disabled state. Changed input requires an explicit update. Display labels and
access can be changed by users with `tunnel.manage`; connection and metadata changes require the
producer or `access.manage`. Removing a producer-managed entry allows that producer to add it again.
Disabling retains the opt-out.
Each enabled registration owns one physical daemon link, shared by status monitoring and every
routed API scope. Each scope owns an isolated virtual session on B. A derives its identity from the
admitted origin session, producer, and logical SDK scope. B admits virtual sessions under the actual
physical link principal, then intersects forwarded caller grants with that link's current authority.
Changing or resuming a parent's grants closes its existing channels; reconnect re-admits demand with
current authority. A routed API cannot select a second hop, and saving a host grants no reverse access.
A producer that registers a host through a routed API is qualified on B as
`route:<B principal>:<A serverId>:<A producer>`, with each component percent-encoded. This namespace belongs to the authenticated link
principal. An ordinary direct B consumer does not become that producer by supplying a source key.
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). 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.
Before accepting new writes, the daemon adopts eligible pre-feature local parent labels into authoritative membership. The upgrade records its bounded candidates before qualifying agent metadata, then resumes those candidates after interruption. It never rescans later label edits. Existing membership, including forgotten entries, takes precedence; explicit null or qualified parent pointers do not establish legacy provenance. Adopted entries preserve the old daemon-owned local lifecycle, not an authenticated creator grant, and contain no creation recipe to replay. Recovery still resolves current owner authority within the retained self-only ceiling.
Provider-owned subagents are descriptors and timelines in `ProviderSubagentStore`, not `AgentStorage` records. They retain their provider observation/cancellation path. Internal managed agents and execution-owned records are separate stored/runtime concepts and are excluded from adoption on both sides of the relationship.
## Connections and recovery
The shared Node connector owns the target grammar and each connection attempt, including a fresh
SSH process. SSH reaches an already running daemon. The shared client connection lifecycle owns
physical reconnect and keepalive; the registry retains configuration, identity acceptance, and recovery
status. Unreachable registrations remain saved. Identity is pinned when discovered. Self and duplicate
registrations remain inspectable; a changed destination requires explicit identity replacement.
Both daemons advertise `connectedHosts`; the shared link additionally requires `hostMultiplexing`.
A completes capability and pinned-identity checks on each physical recovery before admitting channels. There is no local fallback. An older daemon continues to serve existing local
operations; requesting this feature gives update guidance.
Disposing a routed API releases its observations and session. It does not remove the saved host or
affect sibling API scopes. Disposing the origin cascades to its derived scopes. Observations restore
through the usual owned-subscription contract. The ordinary client queues new requests while connecting
within its existing deadline and rejects requests while disconnected. Connection loss clears old
waiters and queued sends. Submitted requests with no response fail with `DaemonConnectionError`
(`DAEMON_CONNECTION_LOST`), optional `outcome: "unknown"` and known `serverId`. Inspect the destination
before retrying a mutation.
Ordinary non-recoverable mutations are not automatically replayed. Existing acknowledged/idempotent
creation recovery retains its request identity and resumes through the ordinary creation lifecycle. Multiplexing changes only the transport: ordinary permission
RPC codes pass through unchanged, and connection/recovery behavior matches direct clients.
Creation cancellation fences a reserved creation identity before admission. An admitted receipt can precede resource readiness and cannot be reported as canceled. Observe its current receipt and later changes through the ordinary SDK instead of replaying an uncertain prompt. Parent archive retains a pending obligation until cancellation or destination lifecycle confirmation; explicit forget removes local membership without claiming the destination changed.
Run journals retain submission and provider-turn identity. Restarted nonterminal evidence is interrupted, and subsequent evidence must match that turn. Idle and reconnect do not prove completion. A storage failure retains observed evidence with pending durability; a later read retries persistence. Notification recovery must preserve checkpoint identity and this durability distinction.
Text session envelopes carry a channel identifier. Binary routing adds an opcode, a bounded UTF-8
channel identifier, and the original terminal or file-transfer bytes. It never converts those bytes to
base64 JSON. The existing direct binary opcodes and 100 MiB message limit remain unchanged; routed
payloads reserve space for the routing header inside that limit. Relay encryption retains its own
existing frame limit.
Host links cap saved registrations, simultaneous channels, handshake lifetime, frames, physical
output, and aggregate queued bytes. Capacity checks use the actual escaped outer JSON or binary
frame size. Awaitable output completes when the physical send completes. An overflowing channel
closes without closing siblings or the saved link. Routed sessions release resources on disconnect;
subscription demand is rebuilt by its owning client on reconnect.
See the [SDK reference](../public-docs/sdk/reference.md#clienthosts),
[CLI reference](../public-docs/cli.md#connected-hosts), and
[plugin reference](../public-docs/plugins/reference.md#connected-hosts).
+4
View File
@@ -31,6 +31,10 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
Connected-host management uses `tunnel.manage`; source-owned connection and metadata replacement
requires the producer or `access.manage`. Routed operations retain both caller and link authority,
see [Connected hosts](connected-hosts.md#ownership-and-authority).
Owner, operator, and viewer are UI presets expanded into explicit permissions. Do not persist them as roles. Adding a permission must not silently widen an existing principal.
Permissions are additive allows. Missing authority denies the operation. Do not add deny precedence.
+1
View File
@@ -38178,6 +38178,7 @@
"dependencies": {
"@getpaseo/protocol": "0.12.0",
"@getpaseo/relay": "0.12.0",
"ws": "^8.20.0",
"zod": "^4.4.3"
},
"devDependencies": {
@@ -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);
}
@@ -0,0 +1,58 @@
import { metroTest as test, expect } from "../support/fixtures";
import { installDeveloperToastDismissal } from "../support/developer-ui.mjs";
import type { Page } from "@playwright/test";
async function showTransientDeveloperToast(page: Page, shadow: boolean) {
await page.evaluate((useShadow) => {
const toast = document.createElement("div");
toast.id = "error-toast";
document.body.append(toast);
const root = useShadow ? toast.attachShadow({ mode: "open" }) : toast;
const button = document.createElement("button");
button.style.cssText = "width:32px;height:32px";
button.addEventListener("click", () => button.remove());
root.append(button);
}, shadow);
}
async function openTransientDeveloperToast(page: Page) {
await page.setContent("<button>Continue</button>");
await showTransientDeveloperToast(page, false);
await installDeveloperToastDismissal(page);
}
async function navigateWithDeveloperToastDismissal(page: Page) {
await installDeveloperToastDismissal(page);
await page.goto("data:text/html,<button>Continue</button>");
await showTransientDeveloperToast(page, true);
}
async function inspectLayoutWhileToastDisappears(page: Page) {
const action = page.getByRole("button", { name: "Continue", exact: true });
const boxes = await Promise.all(Array.from({ length: 4 }, () => action.boundingBox()));
for (const box of boxes) expect(box).not.toBeNull();
await expect(page.locator("#error-toast").getByRole("button")).toHaveCount(0);
await action.click();
}
test("a disappearing developer toast does not stall concurrent layout checks", async ({ page }) => {
test.setTimeout(10000);
await openTransientDeveloperToast(page);
await inspectLayoutWhileToastDisappears(page);
});
test("developer toast dismissal survives navigation and observes Expo shadow-root updates", async ({
page,
}) => {
test.setTimeout(10000);
await navigateWithDeveloperToastDismissal(page);
await inspectLayoutWhileToastDisappears(page);
await page.evaluate(() => {
const root = document.getElementById("error-toast")!.shadowRoot!;
const replacement = document.createElement("button");
replacement.style.cssText = "width:32px;height:32px";
replacement.addEventListener("click", () => replacement.remove());
root.append(replacement);
});
await inspectLayoutWhileToastDisappears(page);
});
@@ -1,3 +1,16 @@
import { buildHostAgentDetailRoute } from "@/utils/host-routes";
import { submitMessage } from "../support/helpers/composer";
import { openSessions, clickSessionRow } from "../support/helpers/archive-tab";
import {
seedFleetSubagents,
delegateFleetThroughMcp,
observeFleetRouteOwnership,
readFleetCompletion,
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";
@@ -6,6 +19,8 @@ import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-
import { seedWorkspace } from "../support/helpers/seed-client";
import { openSubagentsTrack, seedParentWithSubagent } from "../support/helpers/subagents";
test.use({ e2eInjectPaseoTools: true });
const SETTINGS_KEY = "@paseo:app-settings";
async function openParentInRightPane(page: Page, parentId: string, workspaceId: string) {
@@ -147,3 +162,167 @@ 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();
}
});
test("an agent delegates through MCP to self and B, opens both ordinary conversations and receives correlated completion", async ({
page,
}) => {
test.setTimeout(180_000);
await page.setViewportSize({ width: 1400, height: 900 });
const counts = observeFleetRouteOwnership(page);
const fleet = await delegateFleetThroughMcp();
try {
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
console.log("Fleet ordinary parent ownership", await counts());
await openSubagentsTrack(page);
await expect(page.getByTestId(`subagents-track-row-${fleet.local.id}`)).toContainText(
"MCP child on A",
);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.id}`)).toContainText(
"MCP child on B",
);
await expect(page.getByTestId(`subagents-track-row-${fleet.remote.id}`)).toContainText(
"Fleet B",
);
await page.getByTestId(`subagents-track-row-${fleet.local.id}`).click();
await expect(page).toHaveURL((url) =>
url.pathname.includes(`/h/${fleet.local.serverId}/workspace/`),
);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
await fleet.expectCompletion(fleet.local);
await submitMessage(page, "UI_MCP_FLEET_LOCAL");
await expect(page.getByText("UI_MCP_FLEET_LOCAL", { exact: true })).toBeVisible();
expect(
(await fleet.direct.agents.ref(fleet.local.id).refresh())?.agent.lastUserMessageAt,
).toBeNull();
await page.getByTestId(`workspace-tab-agent_${fleet.parent.id}`).click();
await openSubagentsTrack(page);
await page.getByTestId(`subagents-track-row-${fleet.remote.id}`).click();
await expect(page).toHaveURL(
(url) =>
url.pathname.includes(`/h/${fleet.remote.serverId}/workspace/`) &&
url.searchParams.get("origin") === fleet.parent.serverId,
);
await expect(page.getByRole("textbox", { name: "Message agent..." })).toBeVisible();
console.log("Fleet destination conversation ownership", await counts());
await fleet.expectCompletion(fleet.remote);
await submitMessage(page, "UI_MCP_FLEET_REMOTE");
await expect(page.getByText("UI_MCP_FLEET_REMOTE", { exact: true })).toBeVisible();
expect(
(await fleet.origin.agents.ref(fleet.remote.id).refresh())?.agent.lastUserMessageAt,
).toBeNull();
await fleet.expectCompletion(fleet.local);
await fleet.expectCompletion(fleet.remote);
await openAgentRoute(page, {
workspaceId: fleet.workspace.workspaceId,
agentId: fleet.parent.id,
});
await readFleetCompletion(page, fleet.local);
await readFleetCompletion(page, fleet.remote);
} finally {
await fleet.close();
}
});
+24 -7
View File
@@ -1,10 +1,27 @@
/** @param {import('@playwright/test').Page} page */
export async function installDeveloperToastDismissal(page) {
// Developer chrome must not cover app actions or move the test's pointer/focus.
// Console errors remain in the runner's logs.
const dismiss = page.locator("#error-toast").getByRole("button", { name: "", exact: true });
await page.addLocatorHandler(dismiss, () => dismiss.dispatchEvent("click"), {
// A new logged error can replace the toast before the old one disappears.
noWaitAfter: true,
});
// Keep developer chrome out of app actions without pointer/focus changes.
// Locator handlers can deadlock when concurrent checks dismiss the same toast.
const install = () => {
let observedRoot;
const options = { childList: true, subtree: true };
const dismiss = () => {
const toast = document.getElementById("error-toast");
const root = toast?.shadowRoot ?? toast;
if (root && root !== observedRoot) {
observer.disconnect();
observer.observe(document, options);
observer.observe(root, options);
observedRoot = root;
}
// Expo's toast root contains dismiss buttons; its overlay has a different ID.
for (const button of root?.querySelectorAll("button") ?? [])
button.dispatchEvent(new MouseEvent("click", { bubbles: true }));
};
const observer = new MutationObserver(dismiss);
observer.observe(document, options);
dismiss();
};
await page.addInitScript(install);
await page.evaluate(install);
}
@@ -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;
}
}
+382 -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,354 @@ 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",
hydrated: binding?.sessionStore.getState().sessions[destinationId]?.hasHydratedWorkspaces,
workspaceIds: [
...(binding?.sessionStore.getState().sessions[destinationId]?.workspaces.keys() ?? []),
],
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);
}
/** Primary children are created by the agent's real MCP endpoint, never the seed SDK. */
export async function delegateFleetThroughMcp() {
const { Client } = await import("@modelcontextprotocol/sdk/client/index.js");
const { StreamableHTTPClientTransport } =
await import("@modelcontextprotocol/sdk/client/streamableHttp.js");
const { getE2EDaemonPort } = await import("./daemon-port");
const { z } = await import("zod");
const workspace = await seedWorkspace({ repoPrefix: "mcp-fleet-journey-" });
const destination = await startIsolatedHostDaemon(`fleet_${randomUUID()}`);
const createApi = await loadPaseoApiFactory();
const aClient = await connectDaemonClient<DaemonClient>({ clientIdPrefix: "mcp-fleet-origin" });
const bClient = await connectDaemonClient<DaemonClient>({
clientIdPrefix: "mcp-fleet-observer",
port: destination.port,
});
const origin = createApi(aClient);
const direct = createApi(bClient);
const mcp = new Client({ name: "fleet-delegating-agent", version: "1.0.0" });
const close = async () => {
await mcp.close();
await Promise.allSettled([origin.dispose(), direct.dispose()]);
await Promise.allSettled([aClient.close(), bClient.close()]);
await destination.close();
await workspace.cleanup();
};
const call = async (name: string, args: Record<string, unknown> = {}) => {
const result = await mcp.callTool({ name, arguments: args });
expect(result, `${name} result`).not.toMatchObject({ isError: true });
return z.record(z.string(), z.unknown()).parse(result.structuredContent);
};
try {
const config = { provider: "mock/ten-second-stream", modeId: "load-test" };
const parent = await origin.workspaces
.ref(workspace.workspaceId)
.agents.create({ title: "Fleet MCP parent", config });
const registered = await origin.hosts.add({
connection: `127.0.0.1:${destination.port}`,
label: "Fleet B",
});
await expect
.poll(
async () =>
(await origin.hosts.list()).hosts.find(
(host) => host.registrationId === registered.registrationId,
)?.status,
)
.toBe("online");
await mcp.connect(
new StreamableHTTPClientTransport(
new URL(`http://127.0.0.1:${getE2EDaemonPort()}/mcp/agents?callerAgentId=${parent.id}`),
),
);
const hosts = await call("list_hosts");
expect(hosts).toMatchObject({
hosts: expect.arrayContaining([
expect.objectContaining({ serverId: destination.serverId, label: "Fleet B" }),
]),
});
const target = await call("create_workspace", {
serverId: destination.serverId,
isolation: "local",
path: workspace.repoPath,
title: "Fleet B workspace",
});
const targetWorkspaceId = z.string().parse(target.workspaceId);
expect(await call("list_workspaces", { serverId: destination.serverId })).toMatchObject({
workspaces: expect.arrayContaining([
expect.objectContaining({ workspaceId: targetWorkspaceId }),
]),
});
const createChild = async (serverId: string, workspaceId: string, title: string) => {
const result = await call("create_agent", {
serverId,
workspaceId,
title,
provider: config.provider,
settings: { modeId: config.modeId },
initialPrompt: `Complete ${title}`,
notifyOnFinish: true,
});
const agentId = z.string().parse(result.agentId);
const submissionId = z.string().parse(result.submissionId);
expect(await call("get_agent_status", { serverId, agentId })).toMatchObject({ serverId });
expect(await call("get_agent_activity", { serverId, agentId })).toMatchObject({ serverId });
expect(await call("list_agents", { serverId })).toMatchObject({
serverId,
agents: expect.arrayContaining([expect.objectContaining({ id: agentId })]),
});
return { id: agentId, serverId, workspaceId, submissionId, title };
};
const local = await createChild(parent.serverId, workspace.workspaceId, "MCP child on A");
const remote = await createChild(destination.serverId, targetWorkspaceId, "MCP child on B");
// Opposite-host collisions are unrelated fixture agents. Neither primary child is seeded.
await direct.agents.create({
agentId: local.id,
cwd: workspace.repoPath,
title: "Unrelated collision on B",
config,
});
await origin.agents.create({
agentId: remote.id,
cwd: workspace.repoPath,
title: "Unrelated collision on A",
config,
});
expect((await parent.subagents.list()).rows.map((row) => row.child)).toEqual(
expect.arrayContaining([
{ serverId: local.serverId, agentId: local.id },
{ serverId: remote.serverId, agentId: remote.id },
]),
);
return {
workspace,
destination,
parent,
local,
remote,
origin,
direct,
close,
async expectCompletion(child: typeof local) {
await expect
.poll(
async () => {
const timeline = await parent.timeline.refetch();
return timeline.entries
.flatMap(({ item }) =>
item.type === "tool_call" && item.agentMessage ? [item.agentMessage] : [],
)
.filter(
(message) =>
message.event === "finished" &&
message.sender.address?.serverId === child.serverId &&
message.text.includes(child.submissionId),
);
},
{ timeout: 30000 },
)
.toHaveLength(1);
},
};
} catch (error) {
await close();
throw error;
}
}
export async function readFleetCompletion(
page: Page,
child: { title: string; submissionId: string },
) {
await expect(
page.getByRole("button", { name: new RegExp(`Agent finished.*${child.title}`) }),
).toBeVisible();
}
/** Records observed requests and runtime-owned sessions without opening extra scopes. */
export function observeFleetRouteOwnership(page: Page) {
const requests: Record<string, number> = {};
let sockets = 0;
page.on("websocket", (socket) => {
sockets++;
socket.on("framesent", ({ payload }) => {
const request = inspectSessionWireRequest(payload)?.request;
if (!request || typeof request.type !== "string") return;
if (request.subscribe !== undefined || request.type === "hosts.route.open.request")
requests[request.type] = (requests[request.type] ?? 0) + 1;
});
});
return async () => ({
sockets,
cumulativeRequests: { ...requests },
bindings: await page.evaluate(() => {
const registry = Reflect.get(globalThis, "__paseoHostRuntimeStore") as HostRuntimeStore;
return registry.getAuthorityBindings().map((binding) => {
const id = binding.reference.serverId;
const session = binding.sessionStore.getState().sessions[id];
return {
reference: binding.reference,
status: binding.runtime.getSnapshot(id)?.connectionStatus,
session: session?.client !== null && session?.client !== undefined,
workspaceReplica: session?.hasHydratedWorkspaces === true,
timelineReplica: session?.viewedTimelineSync !== undefined,
};
});
}),
});
}
+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);
}
+56 -28
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";
@@ -27,29 +30,25 @@ import {
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import type { AgentPermissionResponse } from "@getpaseo/protocol/agent-types";
import { getHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useHostRuntimeStore, useHostRuntimeIsConnected } from "@/runtime/host-runtime";
import { useVoiceAudioEngineOptional, useVoiceRuntimeOptional } from "@/contexts/voice-context";
import type { AudioPlaybackSource } from "@/audio";
import {
selectAgentTimelineState,
toDaemonServerInfo,
useSessionStore,
useSessionStoreApi,
type SessionState,
} from "@/stores/session-store";
import { sendOsNotification } from "@/utils/os-notifications";
import { getIsAppActivelyVisible, getIsAppVisible } from "@/utils/app-visibility";
import {
getInitKey,
getInitDeferred,
createInitDeferred,
rejectInitDeferred,
} from "@/utils/agent-initialization";
import { getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey } from "@/utils/agent-snapshots";
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";
@@ -201,6 +200,10 @@ 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();
const voiceAudioEngine = useVoiceAudioEngineOptional();
@@ -265,7 +268,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
notification?: AgentAttentionNotificationPayload;
}) => {
const appState = appStateRef.current;
const session = useSessionStore.getState().sessions[serverId];
const session = sessionStore.getState().sessions[serverId];
const attentionFocusedAgentId = session?.focusedAgentId ?? null;
if (params.reason === "error") {
return;
@@ -309,7 +312,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
data: notification.data,
});
},
[serverId],
[serverId, sessionStore],
);
useEffect(() => {
@@ -377,7 +380,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
{ event: streamEvent, seq, epoch, timestamp: parsedTimestamp },
]);
if (turnLiveness.length > 0) {
getHostRuntimeStore().applyAgentTurnLiveness(serverId, agentId, turnLiveness);
runtime.applyAgentTurnLiveness(serverId, agentId, turnLiveness);
}
sync.enqueueStreamEvent(agentId, {
event: streamEvent,
@@ -395,14 +398,14 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
void requestTimelineReplacement(
{
fetchAgentTimeline: (agentId, request) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request),
runtime.fetchAgentTimeline(serverId, agentId, request),
},
message.payload.agentId,
).catch((error: unknown) => {
console.warn("[Session] timeline replacement refresh failed", { serverId, error });
});
};
const sync = getHostRuntimeStore().createViewedTimelineOwner(serverId, {
const sync = runtime.createViewedTimelineOwner(serverId, {
observe: (agentIds) => {
const observation = client.observeTimeline(agentIds);
observation.subscribe({
@@ -416,7 +419,7 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
},
readCursor: (agentId) => {
const timeline = selectAgentTimelineState(
useSessionStore.getState().sessions[serverId],
sessionStore.getState().sessions[serverId],
agentId,
);
return timeline.status === "synced" && timeline.range
@@ -424,15 +427,19 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
: undefined;
},
fetchPage: async (agentId, request) => {
const session = useSessionStore.getState().sessions[serverId];
const session = sessionStore.getState().sessions[serverId];
const initKey = getInitKey(serverId, agentId);
const shouldInitialize = selectAgentTimelineState(session, agentId).status !== "synced";
if (shouldInitialize) {
if (!getInitDeferred(initKey)) {
const deferred = createInitDeferred(initKey, request.direction ?? "tail");
if (!sessionStore.initializations.getInitDeferred(initKey)) {
const deferred = sessionStore.initializations.createInitDeferred(
initKey,
request.direction ?? "tail",
);
void deferred.promise.catch(() => undefined);
}
refreshAgentInitializationTimeout({
store: sessionStore,
key: initKey,
agentId,
setAgentInitializing,
@@ -440,21 +447,29 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setAgentInitializing(agentId, true);
}
try {
const page = await getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, request);
if (shouldInitialize && getInitDeferred(initKey)) {
refreshAgentInitializationTimeout({ key: initKey, agentId, setAgentInitializing });
const page = await runtime.fetchAgentTimeline(serverId, agentId, request);
if (shouldInitialize && sessionStore.initializations.getInitDeferred(initKey)) {
refreshAgentInitializationTimeout({
store: sessionStore,
key: initKey,
agentId,
setAgentInitializing,
});
}
return page;
} catch (error) {
if (shouldInitialize) {
setAgentInitializing(agentId, false);
rejectInitDeferred(initKey, error instanceof Error ? error : new Error(String(error)));
sessionStore.initializations.rejectInitDeferred(
initKey,
error instanceof Error ? error : new Error(String(error)),
);
}
throw error;
}
},
fetchLatestTail: (agentId) =>
getHostRuntimeStore().fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()),
runtime.fetchAgentTimeline(serverId, agentId, planTimelineTailFetch()),
reportError: (error) => {
console.warn("[Session] viewed timeline synchronization failed", { serverId, error });
},
@@ -466,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 () => {
@@ -478,7 +495,17 @@ function SessionProviderInternal({ children, serverId, client }: SessionProvider
setViewedTimelineSync(serverId, null);
sync.dispose();
};
}, [client, serverId, setInitializingAgents, setViewedTimelineSync, voiceRuntime]);
}, [
client,
serverId,
sessionStore,
runtime,
sessionStore.initializations,
layoutStore,
setInitializingAgents,
setViewedTimelineSync,
voiceRuntime,
]);
useEffect(() => {
viewedTimelineSyncRef.current?.setConnected(isConnected);
@@ -519,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);
},
});
@@ -709,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,
};
+12 -9
View File
@@ -1,12 +1,15 @@
import { QueryClient } from "@tanstack/react-query";
export const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
export const createQueryClient = () =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: Infinity,
refetchOnMount: false,
refetchOnReconnect: false,
refetchOnWindowFocus: false,
},
},
},
});
});
export const queryClient = createQueryClient();
@@ -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(),
@@ -1,5 +1,5 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { useSessionStore } from "@/stores/session-store";
import { createSessionStore, useSessionStore } from "@/stores/session-store";
import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy";
import type { HostRuntimeStore } from "@/runtime/host-runtime";
import { getInitDeferred, getInitKey, resolveInitDeferred } from "@/utils/agent-initialization";
@@ -288,3 +288,45 @@ describe("refreshAgent", () => {
]);
});
});
it.each(["complete", "fail"] as const)(
"keeps routed initialization pending when direct initialization for the same address %s",
async (outcome) => {
const direct = createSessionStore();
const routed = createSessionStore();
for (const store of [direct, routed]) store.getState().initializeSession(serverId, null);
const directRuntime = new FakeTimelineRuntime();
const routedRuntime = new FakeTimelineRuntime();
const client = new FakeDaemonClient();
const start = (store: typeof direct, runtime: FakeTimelineRuntime) =>
ensureAgentIsInitialized({
store,
serverId,
agentId,
client: client as never,
runtime,
setAgentInitializing: createSetAgentInitializing(
serverId,
store.getState().setInitializingAgents,
),
});
const directReady = start(direct, directRuntime);
const routedReady = start(routed, routedRuntime);
void directReady.catch(() => {});
void routedReady.catch(() => {});
try {
expect(directReady).not.toBe(routedReady);
const key = getInitKey(serverId, agentId);
if (outcome === "complete") direct.initializations.resolveInitDeferred(key);
else direct.initializations.rejectInitDeferred(key, new Error("Direct history failed"));
await Promise.allSettled([directReady]);
expect(routed.initializations.getInitDeferred(key)?.promise).toBe(routedReady);
routed.initializations.resolveInitDeferred(key);
await routedReady;
} finally {
direct.initializations.resolveInitDeferred(getInitKey(serverId, agentId));
routed.initializations.resolveInitDeferred(getInitKey(serverId, agentId));
resolveInitDeferred(getInitKey(serverId, agentId));
}
},
);
@@ -1,16 +1,14 @@
import { useCallback, useMemo } from "react";
import { useTranslation } from "react-i18next";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { selectAgentTimelineState, useSessionStore } from "@/stores/session-store";
import {
createInitDeferred,
getInitDeferred,
getInitKey,
INIT_TIMEOUT_MS,
rejectInitDeferred,
refreshInitTimeout,
} from "@/utils/agent-initialization";
import { getHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
selectAgentTimelineState,
useSessionStore,
useSessionStoreApi,
type SessionStoreApi,
} from "@/stores/session-store";
import { getInitKey, INIT_TIMEOUT_MS } from "@/utils/agent-initialization";
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { planTimelineResumeFetch, planTimelineTailFetch } from "@/timeline/timeline-sync-plan";
import { i18n } from "@/i18n/i18next";
@@ -22,19 +20,22 @@ export function createHistorySyncTimeoutError(): Error {
export function refreshAgentInitializationTimeout(input: {
key: string;
store?: SessionStoreApi;
agentId: string;
setAgentInitializing: SetAgentInitializing;
}): void {
refreshInitTimeout({
const initializations = (input.store ?? useSessionStore).initializations;
initializations.refreshInitTimeout({
key: input.key,
onTimeout: () => {
input.setAgentInitializing(input.agentId, false);
rejectInitDeferred(input.key, createHistorySyncTimeoutError());
initializations.rejectInitDeferred(input.key, createHistorySyncTimeoutError());
},
});
}
export interface EnsureAgentIsInitializedInput {
store?: SessionStoreApi;
serverId: string;
agentId: string;
client: Pick<DaemonClient, "fetchAgentTimeline"> | null;
@@ -45,25 +46,27 @@ export interface EnsureAgentIsInitializedInput {
export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput): Promise<void> {
const { serverId, agentId, client, setAgentInitializing } = input;
const store = input.store ?? useSessionStore;
const initializations = store.initializations;
const key = getInitKey(serverId, agentId);
const existing = getInitDeferred(key);
const existing = initializations.getInitDeferred(key);
if (existing) {
return existing.promise;
}
const timeline = selectAgentTimelineState(useSessionStore.getState().sessions[serverId], agentId);
const timeline = selectAgentTimelineState(store.getState().sessions[serverId], agentId);
const timelineRequest = planTimelineResumeFetch(
timeline.status === "synced" ? timeline.range : null,
);
const deferred = createInitDeferred(key, timelineRequest.direction);
refreshAgentInitializationTimeout({ key, agentId, setAgentInitializing });
const deferred = initializations.createInitDeferred(key, timelineRequest.direction);
refreshAgentInitializationTimeout({ key, store, agentId, setAgentInitializing });
setAgentInitializing(agentId, true);
if (!client) {
setAgentInitializing(agentId, false);
rejectInitDeferred(
initializations.rejectInitDeferred(
key,
new Error(input.hostDisconnectedMessage ?? i18n.t("workspace.terminal.hostDisconnected")),
);
@@ -72,7 +75,10 @@ export function ensureAgentIsInitialized(input: EnsureAgentIsInitializedInput):
input.runtime.fetchAgentTimeline(serverId, agentId, timelineRequest).catch((error) => {
setAgentInitializing(agentId, false);
rejectInitDeferred(key, error instanceof Error ? error : new Error(String(error)));
initializations.rejectInitDeferred(
key,
error instanceof Error ? error : new Error(String(error)),
);
});
return deferred.promise;
@@ -127,6 +133,8 @@ export function useAgentInitialization({
client: DaemonClient | null;
}) {
const { t } = useTranslation();
const store = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const setInitializingAgents = useSessionStore((state) => state.setInitializingAgents);
const setAgentInitializing = useMemo(
() => createSetAgentInitializing(serverId, setInitializingAgents),
@@ -136,14 +144,15 @@ export function useAgentInitialization({
const ensureAgentIsInitializedCallback = useCallback(
(agentId: string): Promise<void> =>
ensureAgentIsInitialized({
store,
serverId,
agentId,
client,
runtime: getHostRuntimeStore(),
runtime,
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[client, serverId, setAgentInitializing, t],
[client, runtime, serverId, setAgentInitializing, store, t],
);
const refreshAgentCallback = useCallback(
@@ -152,11 +161,11 @@ export function useAgentInitialization({
serverId,
agentId,
client,
runtime: getHostRuntimeStore(),
runtime,
setAgentInitializing,
hostDisconnectedMessage: t("workspace.terminal.hostDisconnected"),
}),
[client, serverId, setAgentInitializing, t],
[client, runtime, serverId, setAgentInitializing, t],
);
return {
@@ -43,7 +43,8 @@ function createAgent(id: string): Agent {
title: "Agent",
cwd: "/repo",
model: null,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
};
}
@@ -1,3 +1,4 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { seedSessionHosts } from "@/test/seed-session";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { QueryClient } from "@tanstack/react-query";
@@ -38,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,
@@ -161,6 +163,7 @@ describe("useArchiveAgent", () => {
});
applyArchivedAgentCloseResults({
runtime: getHostRuntimeStore(),
queryClient,
serverId: "server-a",
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
@@ -233,6 +236,7 @@ describe("useArchiveAgent", () => {
});
applyArchivedAgentCloseResults({
runtime: getHostRuntimeStore(),
queryClient,
serverId: "server-a",
results: [{ agentId: "agent-1", archivedAt: "2026-04-01T04:00:00.000Z" }],
+72 -95
View File
@@ -1,14 +1,8 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { useCallback, useMemo } from "react";
import {
useMutation,
useQuery,
useQueryClient,
type QueryClient,
type QueryKey,
} from "@tanstack/react-query";
import { useQuery, useQueryClient, type QueryClient, type QueryKey } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { useSessionStore } from "@/stores/session-store";
import { useSessionStoreApi, type SessionStoreApi } from "@/stores/session-store";
import { agentHistoryQueryKey, allAgentHistoryQueryRootKey } from "./agent-history-query-key";
export const ARCHIVE_AGENT_PENDING_QUERY_KEY = ["archive-agent-pending"] as const;
@@ -241,14 +235,15 @@ interface ArchiveAgentMutationContext {
lists: ArchivedAgentListCacheSnapshot;
}
function getStoredAgentSnapshot(input: ArchiveAgentInput) {
return useSessionStore.getState().sessions[input.serverId]?.agents.get(input.agentId);
function getStoredAgentSnapshot(store: SessionStoreApi, input: ArchiveAgentInput) {
return store.getState().sessions[input.serverId]?.agents.get(input.agentId);
}
function restoreAgentSnapshot(
runtime: HostRuntimeStore,
input: ArchiveAgentInput & { agent: ReturnType<typeof getStoredAgentSnapshot> },
): void {
getHostRuntimeStore().restoreAgentSnapshot(input.serverId, input.agentId, input.agent);
runtime.restoreAgentSnapshot(input.serverId, input.agentId, input.agent);
}
function getArchivedAgentListCacheSnapshot(
@@ -299,20 +294,20 @@ function restoreArchivedAgentListCacheSnapshot(
}
}
function markAgentArchivedInStore(input: ArchiveAgentInput & { archivedAt: string }): void {
function markAgentArchivedInStore(
runtime: HostRuntimeStore,
input: ArchiveAgentInput & { archivedAt: string },
): void {
const archivedAt = new Date(input.archivedAt);
if (Number.isNaN(archivedAt.getTime())) {
return;
}
getHostRuntimeStore().archiveAgentSnapshot(
input.serverId,
input.agentId,
archivedAt.toISOString(),
);
runtime.archiveAgentSnapshot(input.serverId, input.agentId, archivedAt.toISOString());
}
interface ApplyArchivedAgentCloseResultsInput {
runtime: HostRuntimeStore;
queryClient: QueryClient;
serverId: string;
results: ArchivedAgentCloseResult[];
@@ -325,7 +320,7 @@ export function applyArchivedAgentCloseResults(input: ApplyArchivedAgentCloseRes
}
for (const result of input.results) {
markAgentArchivedInStore({
markAgentArchivedInStore(input.runtime, {
serverId: input.serverId,
agentId: result.agentId,
archivedAt: result.archivedAt,
@@ -375,87 +370,69 @@ export function usePendingArchiveAgentIds(serverId: string): ReadonlySet<string>
);
}
/** The action owns optimistic state and rollback within its captured authority. */
export function createAgentArchiveAction({
runtime,
sessionStore,
queryClient,
unavailableMessage,
}: {
runtime: HostRuntimeStore;
sessionStore: SessionStoreApi;
queryClient: QueryClient;
unavailableMessage: string;
}): (input: ArchiveAgentInput) => Promise<void> {
return async (input) => {
const context: ArchiveAgentMutationContext = {
agent: getStoredAgentSnapshot(sessionStore, input),
lists: getArchivedAgentListCacheSnapshot(queryClient, input.serverId),
};
applyArchivedAgentCloseResults({
runtime,
queryClient,
serverId: input.serverId,
results: [{ agentId: input.agentId, archivedAt: new Date().toISOString() }],
invalidateQueries: false,
});
setAgentArchiving({ ...input, queryClient, isArchiving: true });
try {
const client = runtime.getClient(input.serverId);
if (!client) throw new Error(unavailableMessage);
const result = await client.archiveAgent(input.agentId);
markAgentArchivedInStore(runtime, { ...input, archivedAt: result.archivedAt });
} catch (error) {
restoreAgentSnapshot(runtime, { ...input, agent: context.agent });
restoreArchivedAgentListCacheSnapshot(queryClient, input.serverId, context.lists);
throw error;
} finally {
clearArchiveAgentPending({ ...input, queryClient });
for (const queryKey of [
["sidebarAgentsList", input.serverId],
["allAgents", input.serverId],
agentHistoryQueryKey(input.serverId),
allAgentHistoryQueryRootKey(),
])
void queryClient.invalidateQueries({ queryKey });
}
};
}
export function useArchiveAgent() {
const queryClient = useQueryClient();
const runtime = useHostRuntimeStore();
const sessionStore = useSessionStoreApi();
const { t } = useTranslation();
const pendingQuery = useArchiveAgentPendingQuery();
const archiveMutation = useMutation({
mutationFn: async (input: ArchiveAgentInput): Promise<{ archivedAt: string }> => {
const client = useSessionStore.getState().sessions[input.serverId]?.client ?? null;
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
return await client.archiveAgent(input.agentId);
},
onMutate: (input) => {
const context: ArchiveAgentMutationContext = {
agent: getStoredAgentSnapshot(input),
lists: getArchivedAgentListCacheSnapshot(queryClient, input.serverId),
};
const archivedAt = new Date().toISOString();
applyArchivedAgentCloseResults({
const unavailableMessage = t("common.errors.daemonClientUnavailable");
const archiveAgent = useMemo(
() =>
createAgentArchiveAction({
runtime,
sessionStore,
queryClient,
serverId: input.serverId,
results: [{ agentId: input.agentId, archivedAt }],
invalidateQueries: false,
});
setAgentArchiving({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
isArchiving: true,
});
return context;
},
onSuccess: (result, input) => {
markAgentArchivedInStore({
serverId: input.serverId,
agentId: input.agentId,
archivedAt: result.archivedAt,
});
},
onError: (_error, input, context) => {
if (!context) {
return;
}
restoreAgentSnapshot({
serverId: input.serverId,
agentId: input.agentId,
agent: context.agent,
});
restoreArchivedAgentListCacheSnapshot(queryClient, input.serverId, context.lists);
},
onSettled: (_result, _error, input) => {
clearArchiveAgentPending({
queryClient,
serverId: input.serverId,
agentId: input.agentId,
});
void queryClient.invalidateQueries({
queryKey: ["sidebarAgentsList", input.serverId],
});
void queryClient.invalidateQueries({
queryKey: ["allAgents", input.serverId],
});
void queryClient.invalidateQueries({
queryKey: agentHistoryQueryKey(input.serverId),
});
void queryClient.invalidateQueries({
queryKey: allAgentHistoryQueryRootKey(),
});
},
});
const archiveMutateAsync = archiveMutation.mutateAsync;
const archiveAgent = useCallback(
async (input: ArchiveAgentInput): Promise<void> => {
await archiveMutateAsync(input);
},
[archiveMutateAsync],
unavailableMessage,
}),
[runtime, sessionStore, queryClient, unavailableMessage],
);
const isArchivingAgent = useCallback(
@@ -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. */
+39 -16
View File
@@ -1,4 +1,4 @@
import { getHostRuntimeStore } from "@/runtime/host-runtime";
import { useHostRuntimeStore, type HostRuntimeStore } from "@/runtime/host-runtime";
import { Button } from "@/components/ui/button";
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
@@ -87,6 +87,8 @@ import {
selectAgentTurnPresentation,
type Agent,
useSessionStore,
useSessionStoreApi,
type SessionStoreApi,
} from "@/stores/session-store";
import { useWorkspaceLayoutStore } from "@/stores/workspace-layout-store";
import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model";
@@ -97,7 +99,7 @@ import type { PendingPermission } from "@/types/shared";
import type { StreamItem, TodoEntry } from "@/types/stream";
import type { ViewedTimelineStatus, ViewedTimelineUiBridge } from "@/timeline/viewed-timeline-sync";
import { useArchiveFinishedSubagents, useSubagentsForParent } from "@/subagents";
import { getInitDeferred, getInitKey } from "@/utils/agent-initialization";
import { getInitKey } from "@/utils/agent-initialization";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { applyLegacyDaemonWorkspaceOwnership } from "@/workspace/legacy-daemon-workspaces";
import type { WorkspaceFileOpenRequest } from "@/workspace/file-open";
@@ -288,6 +290,8 @@ function shouldStoreFetchedAgentInActiveDirectory(agent: Agent): boolean {
type FetchAgentResult = Awaited<ReturnType<DaemonClient["fetchAgent"]>>;
function storeFetchedAgentDetail(input: {
runtime: HostRuntimeStore;
sessionStore: SessionStoreApi;
serverId: string;
result: NonNullable<FetchAgentResult>;
}): Agent {
@@ -299,10 +303,10 @@ function storeFetchedAgentDetail(input: {
projectPlacement: input.result.project,
},
});
const store = useSessionStore.getState();
const store = input.sessionStore.getState();
if (shouldStoreFetchedAgentInActiveDirectory(hydrated)) {
getHostRuntimeStore().acceptAgentSnapshot(input.serverId, hydrated);
input.runtime.acceptAgentSnapshot(input.serverId, hydrated);
} else {
store.setAgentDetails(input.serverId, (previous) => {
const next = new Map(previous);
@@ -388,6 +392,7 @@ function AgentPanel() {
}
function DraftPanel() {
const runtime = useHostRuntimeStore();
const {
serverId,
workspaceId,
@@ -404,10 +409,10 @@ function DraftPanel() {
(agentSnapshot: Parameters<typeof normalizeAgentSnapshot>[0]) => {
const normalized = normalizeAgentSnapshot(agentSnapshot, serverId);
const agent = applyLegacyDaemonWorkspaceOwnership({ serverId, agent: normalized });
getHostRuntimeStore().acceptAgentSnapshot(serverId, agent);
runtime.acceptAgentSnapshot(serverId, agent);
retargetCurrentTab({ kind: "agent", agentId: agentSnapshot.id });
},
[retargetCurrentTab, serverId],
[retargetCurrentTab, runtime, serverId],
);
return (
@@ -593,10 +598,12 @@ function AgentPanelBody({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const sessionStore = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const { isArchivingAgent: _isArchivingAgent } = useArchiveAgent();
const hasSession = useSessionStore((state) => Boolean(state.sessions[serverId]));
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => {
if (!agentId) {
return null;
@@ -656,7 +663,7 @@ function AgentPanelBody({
return;
}
storeFetchedAgentDetail({ serverId, result });
storeFetchedAgentDetail({ serverId, result, sessionStore, runtime });
setLookupState({ tag: "idle" });
return;
})
@@ -671,7 +678,17 @@ function AgentPanelBody({
}
setLookupState({ tag: "error", message });
});
}, [agentId, agentState.id, client, hasSession, isConnected, lookupState.tag, serverId]);
}, [
agentId,
agentState.id,
client,
hasSession,
isConnected,
lookupState.tag,
runtime,
serverId,
sessionStore,
]);
if (lookupState.tag === "not_found") {
return (
@@ -748,6 +765,8 @@ function ChatAgentContent({
onOpenWorkspaceFile?: (request: WorkspaceFileOpenRequest) => void;
}) {
const { t } = useTranslation();
const sessionStore = useSessionStoreApi();
const runtime = useHostRuntimeStore();
const isPaneVisible = useRetainedPanelActive();
const { api: toastApi, toast: toastState, dismiss: dismissToast } = useToastHost();
const { isArchivingAgent } = useArchiveAgent();
@@ -763,7 +782,7 @@ function ChatAgentContent({
useShallow((state) => selectChatAgentState(state, serverId, agentId)),
);
const projectPlacement = useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => {
if (!agentId) {
return null;
@@ -863,8 +882,8 @@ function ChatAgentContent({
return false;
}
const initKey = getInitKey(serverId, agentId);
return Boolean(getInitDeferred(initKey));
}, [agentId, isInitializing, serverId]);
return Boolean(sessionStore.initializations.getInitDeferred(initKey));
}, [agentId, isInitializing, serverId, sessionStore]);
const needsAuthoritativeSync = useMemo(() => {
if (!agentId) {
return false;
@@ -1005,7 +1024,7 @@ function ChatAgentContent({
if (attemptToken !== initAttemptTokenRef.current) {
return;
}
const currentSession = useSessionStore.getState().sessions[serverId];
const currentSession = sessionStore.getState().sessions[serverId];
const currentAgent =
currentSession?.agents.get(agentId) ?? currentSession?.agentDetails.get(agentId);
if (!currentAgent) {
@@ -1020,7 +1039,7 @@ function ChatAgentContent({
});
return;
}
storeFetchedAgentDetail({ serverId, result });
storeFetchedAgentDetail({ serverId, result, sessionStore, runtime });
}
if (attemptToken !== initAttemptTokenRef.current) {
return;
@@ -1050,6 +1069,8 @@ function ChatAgentContent({
isPaneVisible,
missingAgentState.kind,
serverId,
sessionStore,
runtime,
viewedTimelineSync,
visibilityCatchUpStatus,
]);
@@ -1440,7 +1461,7 @@ const AgentStreamSection = memo(function AgentStreamSection({
);
const streamItems = streamItemsRaw ?? EMPTY_STREAM_ITEMS;
const pendingPermissionList = useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => {
if (!agentId) {
return EMPTY_PENDING_PERMISSION_LIST;
@@ -1573,6 +1594,7 @@ function ActiveAgentComposer({
{ initialIsBelow: isCompactFormFactor },
);
const paneContext = usePaneContext();
const sessionStore = useSessionStoreApi();
const openInSidePane = useSettings((settings) => settings.openInSidePane);
const { workspaceId, tabId, retargetCurrentTab } = paneContext;
const { archiveAgent } = useArchiveAgent();
@@ -1604,7 +1626,7 @@ function ActiveAgentComposer({
const handleClientSlashCommand = useCallback(
async (command: ClientSlashCommand) => {
const agent = resolveChatAgentFromSession(useSessionStore.getState(), serverId, agentId);
const agent = resolveChatAgentFromSession(sessionStore.getState(), serverId, agentId);
if (!agent) {
throw new Error("Agent not found");
}
@@ -1634,6 +1656,7 @@ function ActiveAgentComposer({
hideWorkspaceAgent,
retargetCurrentTab,
serverId,
sessionStore,
tabId,
unpinWorkspaceAgent,
workspaceId,
+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) {
@@ -2,6 +2,7 @@ import { describe, expect, it } from "vitest";
import type { DaemonClient, FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import {
createSessionStore,
selectAgentTimelineState,
selectAgentTurnPresentation,
useSessionStore,
@@ -391,3 +392,34 @@ describe("AgentDirectoryReplica", () => {
store.clearSession(serverId);
});
});
it("partitions equal destination identities by the owning authority store, including late snapshots", () => {
const direct = createSessionStore();
const routed = createSessionStore();
const serverId = "srv_B";
direct.getState().initializeSession(serverId, null);
routed.getState().initializeSession(serverId, null);
const directReplica = new AgentDirectoryReplica(
serverId,
() => {},
() => {},
direct,
);
const routedReplica = new AgentDirectoryReplica(
serverId,
() => {},
() => {},
routed,
);
directReplica.commitSnapshot([entry(payload("Broader direct data"))], []);
expect(routed.getState().sessions[serverId]?.agents.size).toBe(0);
routedReplica.commitSnapshot([entry(payload("Origin-authorized data"))], []);
directReplica.commitSnapshot([entry(payload("Late direct snapshot"))], []);
expect(routed.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
"Origin-authorized data",
);
expect(direct.getState().sessions[serverId]?.agents.get("agent")?.title).toBe(
"Late direct snapshot",
);
expect(routedReplica.snapshot().get("agent")?.title).toBe("Origin-authorized data");
});
@@ -2,7 +2,7 @@ import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import type { AgentSnapshotPayload } from "@getpaseo/protocol/messages";
import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import type { Agent } from "@/stores/session-store";
import { useSessionStore, type SessionStoreApi, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot, projectAgentSnapshot } from "@/utils/agent-snapshots";
import { type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { reconcileAgentDirectory } from "@/utils/agent-directory-reconciliation";
@@ -38,8 +38,9 @@ export class AgentDirectoryReplica {
private readonly serverId: string,
private readonly onStoppedRunning: (agentId: string) => void,
private readonly persist: (mutations: readonly DirectoryReplicaMutation[]) => void,
private readonly store: SessionStoreApi = useSessionStore,
) {
this.storeProjection = new AgentStoreProjection(serverId);
this.storeProjection = new AgentStoreProjection(serverId, store);
}
captureTimeline(agentId: string): AgentLifecycleToken {
@@ -80,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,
@@ -9,6 +9,7 @@ import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type SessionStoreApi,
type Agent,
type ProjectDescriptor,
type WorkspaceDescriptor,
@@ -172,11 +173,17 @@ export class DirectorySync {
markAgentError: (error: string) => void;
},
private readonly checkpoints?: DirectoryCheckpointStorage,
private readonly store: SessionStoreApi = useSessionStore,
) {
const persist = (mutations: readonly DirectoryReplicaMutation[]) =>
this.checkpoints?.commitDirectoryMutations(this.serverId, mutations);
this.agents = new AgentDirectoryReplica(serverId, callbacks.onAgentStoppedRunning, persist);
this.workspaces = new WorkspaceDirectoryReplica(serverId);
this.agents = new AgentDirectoryReplica(
serverId,
callbacks.onAgentStoppedRunning,
persist,
store,
);
this.workspaces = new WorkspaceDirectoryReplica(serverId, store);
}
connectionChanged(connection: DirectoryConnection): boolean {
@@ -401,18 +408,18 @@ export class DirectorySync {
async loadCachedAgent(agentId: string): Promise<void> {
if (!this.checkpoints) return;
if (useSessionStore.getState().sessions[this.serverId]?.agents.has(agentId)) return;
if (this.store.getState().sessions[this.serverId]?.agents.has(agentId)) return;
const token = this.agents.captureCache(agentId);
const agent = await this.checkpoints.readAgent(this.serverId, agentId);
if (!agent) return;
const session = useSessionStore.getState().sessions[this.serverId];
const session = this.store.getState().sessions[this.serverId];
if (!session || session.agents.has(agentId)) return;
this.agents.commitCachedAgent(token, agent);
}
async prepareAgentRoute(agentId: string): Promise<void> {
await this.loadCachedAgent(agentId);
const agent = useSessionStore.getState().sessions[this.serverId]?.agents.get(agentId);
const agent = this.store.getState().sessions[this.serverId]?.agents.get(agentId);
if (agent?.workspaceId) await this.loadCachedWorkspace(agent.workspaceId);
}
@@ -422,12 +429,12 @@ export class DirectorySync {
private async loadCachedWorkspace(workspaceId: string): Promise<void> {
if (!this.checkpoints) return;
if (useSessionStore.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
if (this.store.getState().sessions[this.serverId]?.workspaces.has(workspaceId)) return;
const revision = this.workspaceRevision;
const cached = await this.checkpoints.readWorkspace(this.serverId, workspaceId);
if (!cached) return;
if (this.workspaceRevision !== revision) return;
const session = useSessionStore.getState().sessions[this.serverId];
const session = this.store.getState().sessions[this.serverId];
if (!session) return;
this.workspaces.commitCachedWorkspace(cached.workspace, cached.project);
}
@@ -439,7 +446,7 @@ export class DirectorySync {
const revision = this.revision;
const cached = await checkpoints.readDirectory(this.serverId);
if (this.cacheAccepted) return;
if (!useSessionStore.getState().sessions[this.serverId]) return;
if (!this.store.getState().sessions[this.serverId]) return;
this.agents.commitCached(cached.agents);
this.workspaces.commitCached(cached);
if (this.revision === revision) this.cursors = cached.checkpoint ?? {};
@@ -495,7 +502,7 @@ export class DirectorySync {
if (!onlineConnection) {
this.callbacks.markAgentReady();
return {
agents: new Map(useSessionStore.getState().sessions[this.serverId]?.agents),
agents: new Map(this.store.getState().sessions[this.serverId]?.agents),
subscriptionId: null,
};
}
@@ -560,7 +567,7 @@ export class DirectorySync {
});
try {
await this.waitForSessionMetadata(client, source);
const serverInfo = useSessionStore.getState().sessions[this.serverId]?.serverInfo;
const serverInfo = this.store.getState().sessions[this.serverId]?.serverInfo;
const supportsProjectList = serverInfo?.features?.projectList === true;
const supportsDirectorySync = serverInfo?.features?.directorySync === true;
const projectsReady = supportsProjectList
@@ -690,7 +697,7 @@ export class DirectorySync {
}
markWorkspacesHydrated(hydrated: boolean): void {
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, hydrated);
this.store.getState().setHasHydratedWorkspaces(this.serverId, hydrated);
}
private async fetchAgents(
@@ -783,8 +790,7 @@ export class DirectorySync {
private supportsDirectorySync(): boolean {
return (
useSessionStore.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync ===
true
this.store.getState().sessions[this.serverId]?.serverInfo?.features?.directorySync === true
);
}
@@ -964,7 +970,7 @@ export class DirectorySync {
source: DirectorySourceToken,
): Promise<void> {
await this.waitForSessionState(client, source, () => {
const session = useSessionStore.getState().sessions[this.serverId];
const session = this.store.getState().sessions[this.serverId];
return this.hasMatchingSession(client, source) && session?.serverInfo !== null;
});
}
@@ -995,13 +1001,13 @@ export class DirectorySync {
}
};
this.abortSessionWaits.add(abort);
unsubscribe = useSessionStore.subscribe(check);
unsubscribe = this.store.subscribe(check);
check();
});
}
private hasMatchingSession(client: DaemonClient, source: DirectorySourceToken): boolean {
const session = useSessionStore.getState().sessions[this.serverId];
const session = this.store.getState().sessions[this.serverId];
return session?.client === client && session.clientGeneration === source.clientGeneration;
}
@@ -1,6 +1,6 @@
import equal from "fast-deep-equal";
import type { FetchAgentsEntry } from "@getpaseo/client/internal/daemon-client";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { useSessionStore, type SessionStoreApi, type Agent } from "@/stores/session-store";
import { acceptAgentDirectoryUpdate } from "@/utils/agent-directory-update-policy";
import { buildAgentDirectoryState, type AgentDirectoryDelta } from "@/utils/agent-directory-sync";
import { derivePendingPermissionKey, normalizeAgentSnapshot } from "@/utils/agent-snapshots";
@@ -9,7 +9,7 @@ import { clearArchiveAgentPending } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import { buildDraftStoreKey } from "@/stores/draft-keys";
import { useDraftStore } from "@/stores/draft-store";
import { getInitDeferred, getInitKey, rejectInitDeferred } from "@/utils/agent-initialization";
import { getInitKey } from "@/utils/agent-initialization";
import { reduceTurnLiveness, type TurnLivenessTransition } from "@/timeline/turn-liveness";
function withoutAgent(agentId: string) {
@@ -32,14 +32,17 @@ function mergeSnapshotTurn(previous: Agent | undefined, incoming: Agent): Agent
}
export class AgentStoreProjection {
constructor(private readonly serverId: string) {}
constructor(
private readonly serverId: string,
private readonly store: SessionStoreApi = useSessionStore,
) {}
snapshot(): Map<string, Agent> {
return useSessionStore.getState().sessions[this.serverId]?.agents ?? new Map();
return this.store.getState().sessions[this.serverId]?.agents ?? new Map();
}
get(agentId: string): Agent | undefined {
const session = useSessionStore.getState().sessions[this.serverId];
const session = this.store.getState().sessions[this.serverId];
return session?.agents.get(agentId) ?? session?.agentDetails.get(agentId);
}
@@ -47,17 +50,17 @@ export class AgentStoreProjection {
const merged = new Map(agents);
for (const [agentId, agent] of this.snapshot()) merged.set(agentId, agent);
for (const agent of merged.values()) this.publishActivity(agent);
useSessionStore.getState().setAgents(this.serverId, merged);
this.store.getState().setAgents(this.serverId, merged);
return merged;
}
publishActivity(agent: Agent): void {
useSessionStore.getState().setAgentLastActivity(agent.id, agent.lastActivityAt);
this.store.getState().setAgentLastActivity(agent.id, agent.lastActivityAt);
}
accept(agent: Agent): Agent {
let accepted = agent;
useSessionStore.getState().setAgents(this.serverId, (current) => {
this.store.getState().setAgents(this.serverId, (current) => {
const previous = current.get(agent.id);
accepted = acceptAgentDirectoryUpdate(previous, mergeSnapshotTurn(previous, agent));
if (accepted === previous) return current;
@@ -77,8 +80,12 @@ export class AgentStoreProjection {
this.removeFromDirectory(delta.agentId);
return { agentId: delta.agentId, stoppedRunning: false };
}
const normalized = normalizeAgentSnapshot(delta.agent, this.serverId);
const session = useSessionStore.getState().sessions[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 =
previous?.workspaceId ??
@@ -98,7 +105,7 @@ export class AgentStoreProjection {
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId: accepted.id });
}
this.replacePendingPermissions(accepted);
useSessionStore.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
this.store.getState().setAgentLastActivity(accepted.id, accepted.lastActivityAt);
return {
agentId: accepted.id,
stoppedRunning: previous?.turn.phase === "open" && accepted.turn.phase === "idle",
@@ -110,8 +117,9 @@ export class AgentStoreProjection {
const { agents: fetched, pendingPermissions } = buildAgentDirectoryState({
serverId: this.serverId,
entries,
serverInfo: this.store.getState().sessions[this.serverId]?.serverInfo,
});
const store = useSessionStore.getState();
const store = this.store.getState();
const current = store.sessions[this.serverId]?.agents ?? new Map<string, Agent>();
const agents = new Map<string, Agent>();
for (const [agentId, fetchedAgent] of fetched) {
@@ -141,17 +149,17 @@ export class AgentStoreProjection {
}
replacePendingPermissions(agent: Agent): void {
const pending = new Map(useSessionStore.getState().sessions[this.serverId]?.pendingPermissions);
const pending = new Map(this.store.getState().sessions[this.serverId]?.pendingPermissions);
for (const [key, entry] of pending) if (entry.agentId === agent.id) pending.delete(key);
for (const request of agent.pendingPermissions) {
const key = derivePendingPermissionKey(agent.id, request);
pending.set(key, { key, agentId: agent.id, request });
}
useSessionStore.getState().setPendingPermissions(this.serverId, pending);
this.store.getState().setPendingPermissions(this.serverId, pending);
}
removeFromDirectory(agentId: string): void {
const store = useSessionStore.getState();
const store = this.store.getState();
const removeKey = withoutAgent(agentId);
clearArchiveAgentPending({ queryClient, serverId: this.serverId, agentId });
store.setAgents(this.serverId, removeKey);
@@ -163,7 +171,7 @@ export class AgentStoreProjection {
for (const [key, pending] of next) if (pending.agentId === agentId) next.delete(key);
return next.size === current.size ? current : next;
});
useSessionStore.setState((state) => {
this.store.setState((state) => {
if (!state.agentLastActivity.has(agentId)) return state;
const agentLastActivity = new Map(state.agentLastActivity);
agentLastActivity.delete(agentId);
@@ -173,8 +181,11 @@ export class AgentStoreProjection {
draftKey: buildDraftStoreKey({ serverId: this.serverId, agentId }),
});
const initKey = getInitKey(this.serverId, agentId);
if (getInitDeferred(initKey)) {
rejectInitDeferred(initKey, new Error("Agent was removed during initialization"));
if (this.store.initializations.getInitDeferred(initKey)) {
this.store.initializations.rejectInitDeferred(
initKey,
new Error("Agent was removed during initialization"),
);
}
}
@@ -182,7 +193,7 @@ export class AgentStoreProjection {
this.removeFromDirectory(agentId);
// Only entity deletion destroys the transcript. A filtered active-list
// response can arrive after a fresh history response for an archived agent.
const store = useSessionStore.getState();
const store = this.store.getState();
const removeKey = withoutAgent(agentId);
store.setAgentTimelineCursor(this.serverId, removeKey);
store.setAgentAuthoritativeHistoryApplied(this.serverId, agentId, false);
@@ -192,7 +203,7 @@ export class AgentStoreProjection {
archive(agentId: string, archivedAt: string): Agent | null {
let archived: Agent | null = null;
useSessionStore.getState().setAgents(this.serverId, (current) => {
this.store.getState().setAgents(this.serverId, (current) => {
const agent = current.get(agentId);
if (!agent) return current;
archived = {
@@ -213,7 +224,7 @@ export class AgentStoreProjection {
): Agent | null {
const transitions = Array.isArray(transition) ? transition : [transition];
let accepted: Agent | null = null;
useSessionStore.getState().setAgents(this.serverId, (current) => {
this.store.getState().setAgents(this.serverId, (current) => {
const agent = current.get(agentId);
if (!agent) return current;
const turn = transitions.reduce(reduceTurnLiveness, agent.turn);
@@ -7,6 +7,7 @@ import {
normalizeProjectDescriptor,
normalizeWorkspaceDescriptor,
useSessionStore,
type SessionStoreApi,
type ProjectDescriptor,
type WorkspaceDescriptor,
} from "@/stores/session-store";
@@ -38,7 +39,10 @@ export class WorkspaceDirectoryReplica {
private projects = new Map<string, ProjectDescriptor>();
private workspaceIdsByProject = new Map<string, Set<string>>();
constructor(private readonly serverId: string) {}
constructor(
private readonly serverId: string,
private readonly store: SessionStoreApi = useSessionStore,
) {}
applyDelta(delta: WorkspaceDirectoryDelta): DirectoryReplicaMutation[] {
if (delta.kind === "script_status") return this.applyScriptStatus(delta.update);
@@ -55,7 +59,7 @@ export class WorkspaceDirectoryReplica {
workspaces: new Map([...input.workspaces, ...this.workspaces]),
projects: new Map([...input.projects, ...this.projects]),
});
useSessionStore.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
this.store.getState().setHasWorkspaceDirectorySnapshot(this.serverId, true);
}
commitCachedWorkspace(
@@ -73,7 +77,7 @@ export class WorkspaceDirectoryReplica {
): DirectoryReplicaMutation[] {
this.replace(snapshot);
const mutations = deltas.flatMap((delta) => this.applyDelta(delta));
useSessionStore.getState().setHasHydratedWorkspaces(this.serverId, true);
this.store.getState().setHasHydratedWorkspaces(this.serverId, true);
return mutations;
}
@@ -106,7 +110,7 @@ export class WorkspaceDirectoryReplica {
this.workspaces = workspaces;
this.projects = new Map(snapshot.projects);
this.rebuildProjectIndex();
const store = useSessionStore.getState();
const store = this.store.getState();
store.setWorkspaces(this.serverId, this.workspaces);
store.setProjects(this.serverId, this.projects.values());
}
@@ -133,7 +137,7 @@ export class WorkspaceDirectoryReplica {
{ kind: "project", type: "delete", id: delta.projectId },
];
this.projects.delete(delta.projectId);
useSessionStore.getState().removeProject(this.serverId, delta.projectId);
this.store.getState().removeProject(this.serverId, delta.projectId);
for (const workspaceId of this.workspaceIdsByProject.get(delta.projectId) ?? []) {
this.deleteWorkspace(workspaceId);
mutations.push({ kind: "workspace", type: "delete", id: workspaceId });
@@ -179,7 +183,7 @@ export class WorkspaceDirectoryReplica {
}
if (delta.removedProjectId) {
this.projects.delete(delta.removedProjectId);
useSessionStore.getState().removeProject(this.serverId, delta.removedProjectId);
this.store.getState().removeProject(this.serverId, delta.removedProjectId);
mutations.push({ kind: "project", type: "delete", id: delta.removedProjectId });
}
clearWorkspaceArchivePending({ serverId: this.serverId, workspaceId: delta.id });
@@ -217,19 +221,19 @@ export class WorkspaceDirectoryReplica {
const projectWorkspaces = this.workspaceIdsByProject.get(workspace.projectId) ?? new Set();
projectWorkspaces.add(workspace.id);
this.workspaceIdsByProject.set(workspace.projectId, projectWorkspaces);
useSessionStore.getState().mergeWorkspaces(this.serverId, [workspace]);
this.store.getState().mergeWorkspaces(this.serverId, [workspace]);
}
private deleteWorkspace(workspaceId: string): void {
const workspace = this.workspaces.get(workspaceId);
if (workspace) this.workspaceIdsByProject.get(workspace.projectId)?.delete(workspaceId);
this.workspaces.delete(workspaceId);
useSessionStore.getState().removeWorkspace(this.serverId, workspaceId);
this.store.getState().removeWorkspace(this.serverId, workspaceId);
}
private setProject(project: ProjectDescriptor): void {
this.projects.set(project.projectId, project);
useSessionStore.getState().upsertProject(this.serverId, project);
this.store.getState().upsertProject(this.serverId, project);
}
private rebuildProjectIndex(): void {
@@ -0,0 +1,71 @@
/** @vitest-environment jsdom */
import {
createWorkspaceLayoutStore,
useWorkspaceLayoutStoreApi,
} from "@/stores/workspace-layout-store";
import React, { act } from "react";
import { useHasHydratedWorkspaces } from "@/stores/session-store-hooks";
import {
createProviderSubagentStore,
useProviderSubagentStoreApi,
} from "@/subagents/provider-store";
import { createRoot } from "react-dom/client";
import { expect, it } from "vitest";
import { QueryClient, useQueryClient } from "@tanstack/react-query";
import { createSessionStore, useSessionStoreApi } from "@/stores/session-store";
import { useCreateFlowStoreApi } from "@/stores/create-flow-store";
import { HostRuntimeStore, useHostRuntimeStore, type HostAuthorityBinding } from "./host-runtime";
import { HostAuthorityProvider } from "./host-authority-provider";
it("ordinary consumer hooks retain the runtime, store and creation owner of each authority", async () => {
const binding = (originServerId: string): HostAuthorityBinding => {
const sessionStore = createSessionStore();
return {
reference: { originServerId, serverId: "B" },
namespace: JSON.stringify(["host-authority-v1", originServerId, "B"]),
sessionStore,
queries: new QueryClient(),
providerSubagents: createProviderSubagentStore(),
layout: createWorkspaceLayoutStore(),
runtime: new HostRuntimeStore({ sessionStore }),
ready: Promise.resolve(),
};
};
const direct = binding("B");
const routed = binding("A");
for (const owner of [direct, routed]) {
owner.sessionStore.getState().initializeSession("B", null);
owner.sessionStore.getState().setHasHydratedWorkspaces("B", owner === routed);
}
function OrdinaryConsumer({ expected }: { expected: HostAuthorityBinding }) {
expect(useHasHydratedWorkspaces("B")).toBe(expected === routed);
const runtime = useHostRuntimeStore();
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);
return null;
}
const container = document.createElement("div");
const root = createRoot(container);
try {
await act(async () =>
root.render(
<>
<HostAuthorityProvider binding={direct}>
<OrdinaryConsumer expected={direct} />
</HostAuthorityProvider>
<HostAuthorityProvider binding={routed}>
<OrdinaryConsumer expected={routed} />
</HostAuthorityProvider>
</>,
),
);
} finally {
await act(async () => root.unmount());
}
});
@@ -0,0 +1,32 @@
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";
import { CreateFlowStoreContext } from "@/stores/create-flow-store";
import { HostRuntimeContext, type HostAuthorityBinding } from "./host-runtime";
/** The retained screen owns acquisition/release; descendants receive one bound capability. */
export function HostAuthorityProvider({
binding,
children,
}: {
binding: HostAuthorityBinding;
children: ReactNode;
}) {
return (
<QueryClientProvider client={binding.queries}>
<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>
);
}
+392 -3
View File
@@ -1,4 +1,7 @@
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";
import type { AppStateStatus } from "react-native";
import {
bindHostRuntimeAppState,
@@ -13,12 +16,15 @@ import type {
import { DaemonAuthenticationError } from "@getpaseo/client/internal/daemon-client";
import type { ConnectionOffer } from "@getpaseo/protocol/connection-offer";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import { MutableDaemonConfigSchema } from "@getpaseo/protocol/messages";
import { daemonConfigQueryKey } from "@/data/daemon-config";
import type { AgentPermissionRequest } from "@getpaseo/protocol/agent-types";
import type { HostConnection, HostProfile } from "@/types/host-connection";
import { defaultHostAppearance } from "@/hosts/appearance";
import { useSessionStore, type Agent } from "@/stores/session-store";
import { normalizeAgentSnapshot } from "@/utils/agent-snapshots";
import { isAgentArchiving, setAgentArchiving } from "@/hooks/use-archive-agent";
import { createAgentArchiveAction } from "@/hooks/use-archive-agent";
import { queryClient } from "@/data/query-client";
import {
HostRuntimeController,
@@ -26,6 +32,7 @@ import {
readInitialDaemonConnectionHint,
type HostRuntimeControllerDeps,
type HostRuntimeStorage,
type HostAuthorityBinding,
} from "./host-runtime";
import type { ReplicaRow, ReplicaRowStore } from "./replica-cache/row-store";
@@ -52,6 +59,22 @@ it("requests the managed connection credential through desktop main without a we
});
class FakeDaemonClient {
private readonly eventListeners = new Set<(message: SessionOutboundMessage) => void>();
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 }> {
this.archiveCalls.push(agentId);
return this.archiveResponse ?? { archivedAt: new Date().toISOString() };
}
supportsBackgroundWorkspaces(): boolean {
return false;
}
@@ -96,7 +119,12 @@ class FakeDaemonClient {
}
observeEvents() {
return subscriptionFixture(Promise.resolve({}), () => () => {});
return subscriptionFixture(Promise.resolve({}), (receive) => {
this.eventListeners.add(receive);
return () => {
this.eventListeners.delete(receive);
};
});
}
async waitForAgentUpdates(): Promise<void> {
@@ -161,6 +189,29 @@ class FakeDaemonClient {
return this.state;
}
private readonly hostDirectoryListeners = new Set<(message: SessionOutboundMessage) => void>();
observeHosts(): ReturnType<DaemonClient["observeHosts"]> {
return subscriptionFixture(
Promise.resolve({ self: { serverId: this.serverId, label: "Origin" }, hosts: [] }),
(receive) => {
this.hostDirectoryListeners.add(receive);
return () => {
this.hostDirectoryListeners.delete(receive);
};
},
);
}
hostsChanged(): void {
for (const receive of this.hostDirectoryListeners)
receive({
type: "hosts.changed",
payload: {
subscriptionId: "hosts",
directory: { self: { serverId: this.serverId, label: "Origin" }, hosts: [] },
},
});
}
public serverId = "srv_test";
public ownedSubscriptions = true;
private daemonVersion: string | null = "0.8.0";
@@ -168,7 +219,7 @@ class FakeDaemonClient {
if (this.daemonVersion === null) return null;
return {
status: "server_info",
serverId: "srv_test",
serverId: this.serverId,
hostname: "test",
version: this.daemonVersion,
features: { ownedSubscriptions: this.ownedSubscriptions },
@@ -3391,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]]);
});
@@ -4440,3 +4492,340 @@ describe("HostRuntimeStore initial connection hint bootstrap", () => {
expect(store.getHosts()).toHaveLength(0);
});
});
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;
const prefix = crypto.randomUUID();
const aId = `A-${prefix}`;
const bId = `B-${prefix}`;
const clients = new Map<string, FakeDaemonClient>();
const resolutions: Array<{ origin: DaemonClient; serverId: string }> = [];
const releases: string[] = [];
const routeClient = makeConnectedProbeClient(5);
routeClient.serverId = bId;
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
replicaRowStore: createMemoryReplicaRowStore(),
deps: {
createClient: ({ host }) => {
const client = makeConnectedProbeClient(5);
client.serverId = host.serverId;
clients.set(host.serverId, client);
return client as unknown as DaemonClient;
},
connectToDaemon: async () => {
throw new Error("Probe is not needed");
},
getClientId: async () => "binding-client",
},
resolveHostConnection: async (origin, serverId) => {
resolutions.push({ origin, serverId });
const client =
origin === (clients.get(aId) as unknown as DaemonClient) ? routeClient : origin;
return {
client: client as DaemonClient,
release: async () => {
releases.push(serverId);
},
};
},
});
const hosts = [aId, bId].map((serverId) =>
makeHost({ serverId, connections: [makeHost().connections[0]!] }),
);
const initial = new Map<string, { connectionId: string; existingClient: DaemonClient }>();
for (const host of hosts) {
const client = makeConnectedProbeClient(5);
client.serverId = host.serverId;
clients.set(host.serverId, client);
initial.set(host.serverId, {
connectionId: host.connections[0]!.id,
existingClient: client as unknown as DaemonClient,
});
}
store.syncHosts(hosts, { initialConnectionByServerId: initial });
await onceHostSnapshotMatches(store, aId, (snapshot) => snapshot?.connectionStatus === "online");
await onceHostSnapshotMatches(store, bId, (snapshot) => snapshot?.connectionStatus === "online");
const direct = store.acquireBinding({ originServerId: bId, serverId: bId });
const routed = store.acquireBinding({ originServerId: aId, serverId: bId });
const sibling = store.acquireBinding({ originServerId: aId, serverId: bId });
try {
await Promise.all([direct.binding.ready, routed.binding.ready]);
expect(sibling.binding).toBe(routed.binding);
expect(direct.binding.runtime.getClient(bId)).toBe(clients.get(bId));
expect(routed.binding.runtime.getClient(bId)).toBe(routeClient);
expect(direct.binding.sessionStore).not.toBe(routed.binding.sessionStore);
expect(direct.binding.namespace).not.toBe(routed.binding.namespace);
expect(routed.binding.queries.getDefaultOptions()).toEqual(queryClient.getDefaultOptions());
const key = ["schedules", bId];
let directFetches = 0;
let routedFetches = 0;
direct.binding.queries.setQueryData(key, "Direct cache");
routed.binding.queries.setQueryData(key, "Routed cache");
queryClient.setQueryData(key, "Registry cache");
const directObserver = new QueryObserver(direct.binding.queries, {
queryKey: key,
queryFn: async () => {
directFetches++;
return "Direct recovered";
},
});
const routedObserver = new QueryObserver(routed.binding.queries, {
queryKey: key,
queryFn: async () => {
routedFetches++;
return "Routed recovered";
},
});
const stopDirect = directObserver.subscribe(() => {});
const stopRouted = routedObserver.subscribe(() => {});
try {
expect([directFetches, routedFetches]).toEqual([0, 0]);
routeClient.setConnectionState({ status: "disconnected", reason: "Recover routed cache" });
routeClient.setConnectionState({ status: "connected" });
await expect.poll(() => routed.binding.queries.getQueryData(key)).toBe("Routed recovered");
expect([directFetches, routedFetches]).toEqual([0, 1]);
expect(direct.binding.queries.getQueryData(key)).toBe("Direct cache");
expect(queryClient.getQueryState(key)?.isInvalidated).toBe(false);
clients
.get(bId)!
.setConnectionState({ status: "disconnected", reason: "Recover direct cache" });
clients.get(bId)!.setConnectionState({ status: "connected" });
await expect.poll(() => direct.binding.queries.getQueryData(key)).toBe("Direct recovered");
expect([directFetches, routedFetches]).toEqual([1, 1]);
} finally {
stopDirect();
stopRouted();
queryClient.removeQueries({ queryKey: key });
}
const configKey = daemonConfigQueryKey(bId);
const config = MutableDaemonConfigSchema.parse({
relay: { enabled: false },
mcp: { injectIntoAgents: true },
});
const directConfig = MutableDaemonConfigSchema.parse({
relay: { enabled: true },
mcp: { injectIntoAgents: true },
});
direct.binding.queries.setQueryData(configKey, directConfig);
routeClient.publishEvent({
type: "status",
payload: { status: "daemon_config_changed", config },
});
await expect.poll(() => routed.binding.queries.getQueryData(configKey)).toEqual(config);
expect(direct.binding.queries.getQueryData(configKey)).toEqual(directConfig);
const agent = replicaAgent(
makeFetchAgentsEntry({
id: "same-agent",
cwd: "/repo",
title: "Direct",
updatedAt: new Date().toISOString(),
}).agent,
bId,
);
direct.binding.runtime.acceptAgentSnapshot(bId, agent);
expect(routed.binding.sessionStore.getState().sessions[bId]?.agents.size).toBe(0);
routed.binding.runtime.acceptAgentSnapshot(bId, { ...agent, title: "Routed" });
direct.binding.runtime.acceptAgentSnapshot(bId, { ...agent, title: "Late direct" });
expect(routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.title).toBe(
"Routed",
);
const archive = createAgentArchiveAction({
runtime: routed.binding.runtime,
sessionStore: routed.binding.sessionStore,
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 });
void archiving.catch(() => {});
expect(routeClient.archiveCalls).toEqual([agent.id]);
expect(clients.get(bId)!.archiveCalls).toEqual([]);
expect(
isAgentArchiving({ queryClient: routed.binding.queries, serverId: bId, agentId: agent.id }),
).toBe(true);
expect(
isAgentArchiving({ queryClient: direct.binding.queries, serverId: bId, agentId: agent.id }),
).toBe(false);
expect(
direct.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
).toBeNull();
gate.reject(new Error("Rejected by selected authority"));
await expect(archiving).rejects.toThrow("Rejected by selected authority");
expect(
routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
).toBeNull();
routeClient.setConnectionState({ status: "disconnected", reason: "Route revoked" });
expect(routed.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("error");
expect(direct.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("online");
expect(routed.binding.runtime.getClient(bId)).toBe(routeClient);
expect(resolutions).toHaveLength(2);
// Withdrawal must retire command sources even if the old client stays alive.
routeClient.setConnectionState({ status: "connected" });
store.syncHosts(hosts.filter((host) => host.serverId === bId));
expect(routed.binding.runtime.getClient(bId)).toBeNull();
expect(routed.binding.sessionStore.getState().sessions[bId]?.client).toBeNull();
expect(routeClient.getConnectionState().status).toBe("connected");
routeClient.publishEvent({
type: "status",
payload: { status: "daemon_config_changed", config: directConfig },
});
expect(routed.binding.queries.getQueryData(configKey)).toEqual(config);
await expect(archive({ serverId: bId, agentId: agent.id })).rejects.toThrow(
"Authority unavailable",
);
expect(routeClient.archiveCalls).toEqual([agent.id]);
expect(clients.get(bId)!.archiveCalls).toEqual([]);
expect(
routed.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.archivedAt,
).toBeNull();
expect(direct.binding.runtime.getSnapshot(bId)?.connectionStatus).toBe("online");
expect(direct.binding.sessionStore.getState().sessions[bId]?.client).toBe(clients.get(bId));
await routed.release();
expect(releases).toEqual([bId]);
expect(sibling.binding.sessionStore.getState().sessions[bId]?.agents.get(agent.id)?.title).toBe(
"Routed",
);
} finally {
await Promise.all([direct.release(), routed.release(), sibling.release()]);
store.syncHosts([]);
}
expect(releases).toHaveLength(2);
});
it.each(["after failure", "during acquisition"])(
"recovers a cold offline authority when the same origin enables the destination %s",
async (timing) => {
globalThis.indexedDB = fakeIndexedDb;
globalThis.IDBKeyRange = FakeIDBKeyRange;
const aId = `cold-A-${crypto.randomUUID()}`;
const bId = `cold-B-${crypto.randomUUID()}`;
const origin = makeConnectedProbeClient(5);
origin.serverId = aId;
const target = makeConnectedProbeClient(5);
target.serverId = bId;
const gate = createDeferred<void>();
let attempts = 0;
let enabled = false;
const store = new HostRuntimeStore({
storage: createMemoryHostRuntimeStorage(),
replicaRowStore: createMemoryReplicaRowStore(),
resolveHostConnection: async (source, destination) => {
expect(source).toBe(origin);
expect(destination).toBe(bId);
attempts++;
if (attempts === 1) {
await gate.promise;
throw new Error("Destination disabled");
}
if (!enabled) throw new Error("Destination disabled");
return { client: target as unknown as DaemonClient, release: async () => {} };
},
deps: {
createClient: () => {
throw new Error("Origin must remain the same client");
},
connectToDaemon: async () => {
throw new Error("No new connection");
},
getClientId: async () => "cold-authority",
},
});
const host = makeHost({ serverId: aId, connections: [makeHost().connections[0]!] });
store.syncHosts([host], {
initialConnectionByServerId: new Map([
[
aId,
{
connectionId: host.connections[0]!.id,
existingClient: origin as unknown as DaemonClient,
},
],
]),
});
await onceHostSnapshotMatches(
store,
aId,
(snapshot) => snapshot?.connectionStatus === "online",
);
origin.setConnectionState({ status: "disconnected", reason: "Cold offline origin" });
const retained = store.acquireBinding({ originServerId: aId, serverId: bId });
try {
origin.setConnectionState({ status: "connected" });
await expect.poll(() => attempts).toBe(1);
if (timing === "after failure") {
gate.resolve();
await expect
.poll(() => retained.binding.runtime.getSnapshot(bId)?.connectionStatus)
.toBe("error");
}
enabled = true;
origin.hostsChanged();
gate.resolve();
await expect
.poll(() => retained.binding.runtime.getSnapshot(bId)?.connectionStatus)
.toBe("online");
expect(store.getClient(aId)).toBe(origin);
expect(retained.binding.runtime.getClient(bId)).toBe(target);
expect(attempts).toBeGreaterThanOrEqual(2);
} finally {
gate.resolve();
await retained.release();
store.syncHosts([]);
}
},
);
+555 -88
View File
@@ -1,4 +1,21 @@
import { useSyncExternalStore, useMemo } from "react";
import type { HostDirectory } from "@getpaseo/protocol/hosts";
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 {
@@ -52,6 +69,8 @@ import { CLIENT_CAPS } from "@getpaseo/protocol/client-capabilities";
import { BROWSER_AUTOMATION_COMMAND_NAMES } from "@getpaseo/protocol/browser-automation/rpc-schemas";
import {
useSessionStore,
type SessionStoreApi,
createSessionStore,
toDaemonServerInfo,
type Agent,
type WorkspaceDescriptor,
@@ -60,7 +79,7 @@ import {
import type { TurnLivenessTransition } from "@/timeline/turn-liveness";
import { useWorkspaceSetupStore } from "@/stores/workspace-setup-store";
import { invalidateCheckoutGitQueriesForServer } from "@/git/query-keys";
import { queryClient } from "@/data/query-client";
import { queryClient, createQueryClient } from "@/data/query-client";
import {
invalidateServerDataQueriesAfterReconnect,
mountServerDataPushRouter,
@@ -608,21 +627,8 @@ function createDefaultDeps(): HostRuntimeControllerDeps {
}),
getClientId: () => getOrCreateClientId(),
mountClientHandlers: ({ client, host }) => {
const unmountServerData = mountServerDataPushRouter({
client,
queryClient,
serverId: host.serverId,
});
if (!browserAutomationCapabilities) {
return unmountServerData;
}
const unmountBrowserAutomation = mountBrowserAutomationDaemonClientHandler(client, {
serverId: host.serverId,
});
return () => {
unmountBrowserAutomation();
unmountServerData();
};
if (!browserAutomationCapabilities) return () => {};
return mountBrowserAutomationDaemonClientHandler(client, { serverId: host.serverId });
},
};
}
@@ -1486,8 +1492,64 @@ interface AgentDirectoryRefreshInput {
page?: FetchAgentsOptions["page"];
}
export interface HostAuthorityReference {
originServerId: string;
serverId: string;
}
export interface HostAuthorityBinding {
readonly reference: HostAuthorityReference;
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>;
}
export interface OwnedHostAuthorityBinding {
readonly binding: HostAuthorityBinding;
release(): Promise<void>;
}
function authorityNamespace(reference: HostAuthorityReference): string {
return JSON.stringify(["host-authority-v1", reference.originServerId, reference.serverId]);
}
function initialAuthoritySnapshot(serverId: string): HostRuntimeSnapshot {
return {
serverId,
activeConnectionId: null,
activeConnection: null,
connectionStatus: "connecting",
client: null,
lastError: null,
lastOnlineAt: null,
agentDirectoryStatus: "idle",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: false,
probeByConnectionId: new Map(),
clientGeneration: 0,
connectionEpoch: 0,
};
}
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 fleetDirectory: HostDirectory | null = null;
private serverListeners = new Map<string, Set<() => void>>();
private globalListeners = new Set<() => void>();
private hostListListeners = new Set<() => void>();
@@ -1510,22 +1572,387 @@ export class HostRuntimeStore {
private readonly hostConfirmations = new HostConfirmations();
private storage: HostRuntimeStorage;
private replicaCache: ReplicaCache;
private readonly sessionStore: SessionStoreApi;
private readonly queries: QueryClient;
private readonly serverDataClients = new Map<string, { client: DaemonClient; release(): void }>();
private readonly resolveHostConnection: typeof acquireHostConnection;
private readonly snapshots = new Map<string, HostRuntimeSnapshot>();
private readonly revokePushNotifications: typeof revokePushNotifications;
constructor(input?: {
deps?: HostRuntimeControllerDeps;
storage?: HostRuntimeStorage;
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());
this.revokePushNotifications = input?.revokePushNotifications ?? revokePushNotifications;
}
/** 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);
let entry = this.authorityBindings.get(namespace);
if (!entry) {
const queries = createQueryClient();
const scope = this.createAuthorityScope(normalized, namespace, queries);
entry = {
binding: {
reference: normalized,
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,
},
refs: 0,
close: async () => {
try {
await scope.close();
} finally {
queries.clear();
}
},
};
this.authorityBindings.set(namespace, entry);
this.publishAuthorityBindings();
}
entry.refs++;
const retained = entry;
let released: Promise<void> | null = null;
return {
binding: retained.binding,
release: () => {
if (!released) {
retained.refs--;
if (retained.refs === 0) {
if (this.authorityBindings.get(namespace) === retained) {
this.authorityBindings.delete(namespace);
this.publishAuthorityBindings();
}
released = retained.close();
} else released = Promise.resolve();
}
return released;
},
};
}
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,
queries: QueryClient,
): {
runtime: HostRuntimeStore;
ready: Promise<void>;
close(): Promise<void>;
} {
const scoped = new HostRuntimeStore({
sessionStore: createSessionStore(),
queries,
authority: reference,
bindingOwner: this.bindingOwner,
replicaRowStore: createReplicaRowStore(namespace),
storage: this.storage,
deps: this.deps,
});
const { serverId, originServerId } = reference;
scoped.sessionStore.getState().initializeSession(serverId, null);
scoped.snapshots.set(serverId, initialAuthoritySnapshot(serverId));
const patch = (change: Partial<HostRuntimeSnapshot>) => {
const previous = scoped.snapshots.get(serverId)!;
scoped.snapshots.set(serverId, { ...previous, ...change });
scoped.emit(serverId);
};
scoped.initializeReplica(serverId, {
onAgentStoppedRunning: (agentId) => scoped.drainQueuedAgentMessage(serverId, agentId),
markAgentLoading: () =>
patch({ agentDirectoryStatus: "initial_loading", agentDirectoryError: null }),
markAgentReady: () =>
patch({
agentDirectoryStatus: "ready",
agentDirectoryError: null,
hasEverLoadedAgentDirectory: true,
}),
markAgentError: (failure) =>
patch({
agentDirectoryStatus: scoped.getSnapshot(serverId)?.hasEverLoadedAgentDirectory
? "error_after_ready"
: "error_before_first_success",
agentDirectoryError: failure,
}),
});
// A bound workspace needs its own directory, including destinations absent
// from the global sidebar host list. DirectorySync shares all demand sources.
const releaseDirectoryDemand = scoped.acquireDirectoryDemand(serverId);
let stopped = false;
let origin: DaemonClient | null = null;
let controller = new AbortController();
let lease: HostConnectionLease | null = null;
let stopTarget: (() => void) | null = null;
let directory: ReturnType<DaemonClient["observeHosts"]> | null = null;
let acquiring: Promise<void> | null = null;
let directoryRevision = 0;
let resolveReady!: () => void;
let rejectReady!: (failure: unknown) => void;
const ready = new Promise<void>((resolve, reject) => {
resolveReady = resolve;
rejectReady = reject;
});
void ready.catch(() => {});
let generation = 0;
const publish = (
client: DaemonClient | null,
state: ConnectionState,
failure: string | null = null,
) => {
const previous = scoped.snapshots.get(serverId)!;
const online = state.status === "connected";
let connectionStatus: HostRuntimeConnectionStatus = failure ? "error" : "offline";
if (state.status === "connecting") connectionStatus = "connecting";
if (online) connectionStatus = "online";
const snapshot: HostRuntimeSnapshot = {
...previous,
client,
connectionStatus,
lastError: failure,
clientGeneration: generation,
connectionEpoch:
online && previous.connectionStatus !== "online"
? previous.connectionEpoch + 1
: previous.connectionEpoch,
lastOnlineAt: online ? new Date().toISOString() : previous.lastOnlineAt,
};
scoped.syncSessionReplica(serverId, snapshot);
scoped.syncDirectoryConnection(serverId);
scoped.emit(serverId);
};
const acquire = () => {
if (
stopped ||
acquiring ||
lease ||
!origin ||
origin.getConnectionState().status !== "connected"
)
return;
const source = origin;
const signal = controller.signal;
const attempt = generation;
const observedRevision = directoryRevision;
publish(null, { status: "connecting", attempt: 1 });
acquiring = (async () => {
try {
const acquired = await this.resolveHostConnection(source, serverId, signal);
if (stopped || signal.aborted || generation !== attempt) {
await acquired.release();
return;
}
lease = acquired;
stopTarget = acquired.client.subscribeConnectionStatus((state) => {
if (!stopped && generation === attempt)
publish(acquired.client, state, acquired.client.lastError);
});
resolveReady();
} catch (failure) {
if (!stopped && generation === attempt) {
publish(null, { status: "disconnected" }, toErrorMessage(failure));
rejectReady(failure);
}
} finally {
if (generation === attempt) {
acquiring = null;
if (!lease && directoryRevision !== observedRevision) acquire();
}
}
})();
};
const retired = new Set<Promise<unknown>>();
const retire = (release: Promise<unknown>) => {
retired.add(release);
void release.then(
() => retired.delete(release),
() => retired.delete(release),
);
};
const syncOrigin = () => {
if (stopped) return;
const snapshot = this.getSnapshot(originServerId);
const nextOrigin = snapshot?.client ?? null;
if (origin !== nextOrigin) {
generation++;
controller.abort();
stopTarget?.();
stopTarget = null;
if (lease) retire(lease.release());
if (directory) retire(directory.release());
if (acquiring) retire(acquiring);
lease = null;
directory = null;
acquiring = null;
controller = new AbortController();
origin = nextOrigin;
publish(null, { status: "disconnected" });
}
if (origin && origin.getConnectionState().status === "connected" && !directory) {
try {
directory = origin.observeHosts();
const changed = (value: HostDirectory) => {
scoped.fleetDirectory = value;
scoped.emit(serverId);
directoryRevision++;
acquire();
};
directory.subscribe({
snapshot: changed,
update: (message) => {
if (message.type === "hosts.changed") changed(message.payload.directory);
},
});
void directory.ready.catch(() => {});
} catch (failure) {
publish(null, { status: "disconnected" }, toErrorMessage(failure));
}
}
if (snapshot?.connectionStatus === "online") acquire();
else if (!lease)
publish(
null,
{ status: "disconnected" },
snapshot?.lastError ?? "Origin authority is unavailable",
);
};
const stopOrigin = this.subscribe(originServerId, syncOrigin);
syncOrigin();
return {
runtime: scoped,
ready,
close: async () => {
scoped.disposed = true;
scoped.lifetime.abort();
stopped = true;
generation++;
stopOrigin();
controller.abort();
stopTarget?.();
rejectReady(new Error("Host authority binding is disposed"));
scoped.syncServerDataClient(serverId, null);
releaseDirectoryDemand();
scoped.directorySyncByServer.get(serverId)?.dispose();
scoped.sessionStore.getState().sessions[serverId]?.viewedTimelineSync?.dispose();
for (const agentId of scoped.sessionStore
.getState()
.sessions[serverId]?.initializingAgents.keys() ?? []) {
scoped.sessionStore.initializations.rejectInitDeferred(
scoped.sessionStore.initializations.getInitKey(serverId, agentId),
new Error("Host authority binding is disposed"),
);
}
await Promise.allSettled([
lease?.release(),
directory?.release(),
acquiring,
...retired,
scoped.subagents.releaseServer(serverId),
]);
await scoped.replicaCache.flush();
scoped.sessionStore.getState().clearSession(serverId);
},
};
}
// --- Host registry ---
getHostLabel(serverId: string): string {
const directory = this.fleetDirectory;
if (directory?.self.serverId === serverId) return directory.self.label;
return directory?.hosts.find((host) => host.serverId === serverId)?.label ?? serverId;
}
getHosts(): HostProfile[] {
return this.hosts;
}
@@ -1772,6 +2199,7 @@ export class HostRuntimeStore {
}
rekeyMap(this.controllers, oldServerId, newServerId);
this.snapshots.delete(oldServerId);
rekeyMap(this.lastConnectionStatusByServer, oldServerId, newServerId);
rekeyMap(this.connectionStatusStartedAtByServer, oldServerId, newServerId);
this.replicaCache.reconcileServerId(oldServerId, newServerId);
@@ -1779,25 +2207,12 @@ export class HostRuntimeStore {
this.directorySyncByServer.get(oldServerId)?.dispose();
this.directorySyncByServer.delete(oldServerId);
this.timelineReplicaByServer.delete(oldServerId);
const directory = new DirectorySync(
newServerId,
{
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
},
this.replicaCache,
);
this.directorySyncByServer.set(newServerId, directory);
this.timelineReplicaByServer.set(
newServerId,
createTimelineReplica({
serverId: newServerId,
storage: this.replicaCache,
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
}),
);
const directory = this.initializeReplica(newServerId, {
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(newServerId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
});
controller.adoptReconciledServerId(newServerId);
const snapshot = controller.getSnapshot();
this.clearHostReplica(oldServerId);
@@ -2258,7 +2673,10 @@ 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);
this.connectionStatusStartedAtByServer.delete(serverId);
this.directorySyncByServer.get(serverId)?.dispose();
@@ -2287,35 +2705,24 @@ export class HostRuntimeStore {
onReconcileServerId: (oldId, newId) => this.reconcileServerId(oldId, newId),
});
this.controllers.set(host.serverId, controller);
useSessionStore.getState().initializeSession(host.serverId, null);
const directory = new DirectorySync(
host.serverId,
{
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
},
this.replicaCache,
);
this.directorySyncByServer.set(host.serverId, directory);
this.timelineReplicaByServer.set(
host.serverId,
createTimelineReplica({
serverId: host.serverId,
storage: this.replicaCache,
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
}),
);
this.sessionStore.getState().initializeSession(host.serverId, null);
this.initializeReplica(host.serverId, {
onAgentStoppedRunning: (agentId) => this.drainQueuedAgentMessage(host.serverId, agentId),
markAgentLoading: () => controller.markAgentDirectorySyncLoading(),
markAgentReady: () => controller.markAgentDirectorySyncReady(),
markAgentError: (error) => controller.markAgentDirectorySyncError(error),
});
const initialSnapshot = controller.getSnapshot();
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
@@ -2332,13 +2739,31 @@ export class HostRuntimeStore {
}
}
private initializeReplica(
serverId: string,
callbacks: ConstructorParameters<typeof DirectorySync>[1],
): DirectorySync {
const directory = new DirectorySync(serverId, callbacks, this.replicaCache, this.sessionStore);
this.directorySyncByServer.set(serverId, directory);
this.timelineReplicaByServer.set(
serverId,
createTimelineReplica({
serverId,
store: this.sessionStore,
storage: this.replicaCache,
prepareAgent: (agentId) => directory.prepareAgentRoute(agentId),
}),
);
return directory;
}
private syncSessionReplica(serverId: string, snapshot: HostRuntimeSnapshot): void {
if (!snapshot.client) {
return;
}
const sessionStore = useSessionStore.getState();
this.snapshots.set(serverId, snapshot);
const sessionStore = this.sessionStore.getState();
sessionStore.initializeSession(serverId, snapshot.client, snapshot.clientGeneration);
sessionStore.updateSessionClient(serverId, snapshot.client, snapshot.clientGeneration);
this.syncServerDataClient(serverId, snapshot.client);
if (!snapshot.client) return;
// A reconnect keeps the same client, so the daemon's handshake (a restart or upgrade can
// change its version and features) only reaches the store here. The client clears it while
// disconnected; keep the last known value until the next handshake.
@@ -2348,19 +2773,32 @@ export class HostRuntimeStore {
}
}
private syncServerDataClient(serverId: string, client: DaemonClient | null): void {
const current = this.serverDataClients.get(serverId);
if (current?.client === client) return;
current?.release();
this.serverDataClients.delete(serverId);
if (client)
this.serverDataClients.set(serverId, {
client,
release: mountServerDataPushRouter({ client, serverId, queryClient: this.queries }),
});
}
private clearHostReplica(serverId: string): void {
useSessionStore.getState().clearSession(serverId);
this.syncServerDataClient(serverId, null);
void this.subagents.releaseServer(serverId);
this.sessionStore.getState().clearSession(serverId);
useWorkspaceSetupStore.getState().clearServer(serverId);
}
private syncDirectoryConnection(serverId: string): void {
const controller = this.controllers.get(serverId);
if (!controller) {
const snapshot = this.snapshots.get(serverId);
if (!snapshot) {
this.lastConnectionStatusByServer.delete(serverId);
this.connectionStatusStartedAtByServer.delete(serverId);
return;
}
const snapshot = controller.getSnapshot();
const directory = this.directorySyncByServer.get(serverId);
directory?.connectionChanged({
client: snapshot.client,
@@ -2383,20 +2821,20 @@ export class HostRuntimeStore {
const didTransitionOnline =
snapshot.connectionStatus === "online" && previousStatus !== "online";
if (didTransitionOnline) {
useSessionStore.getState().bumpHistorySyncGeneration(serverId);
this.sessionStore.getState().bumpHistorySyncGeneration(serverId);
// Checkout git data is push-driven; pushes emitted while disconnected are gone for
// good (the daemon dedupes by snapshot fingerprint). Mark the caches stale so active
// queries refetch now and evicted ones on their next mount.
void invalidateCheckoutGitQueriesForServer(queryClient, serverId);
invalidateServerDataQueriesAfterReconnect({ queryClient, serverId });
void queryClient.invalidateQueries({ queryKey: schedulesQueryBaseKey });
void invalidateCheckoutGitQueriesForServer(this.queries, serverId);
invalidateServerDataQueriesAfterReconnect({ queryClient: this.queries, serverId });
void this.queries.invalidateQueries({ queryKey: schedulesQueryBaseKey });
}
}
drainQueuedAgentMessage(serverId: string, agentId: string): void {
const drainKey = `${serverId}:${agentId}`;
if (this.queuedAgentDrainInFlight.has(drainKey)) return;
const store = useSessionStore.getState();
const store = this.sessionStore.getState();
const session = store.sessions[serverId];
const queue = session?.queuedMessages.get(agentId);
const client = session?.client;
@@ -2410,12 +2848,13 @@ export class HostRuntimeStore {
messageId: next.id,
queue: {
read: (queuedAgentId) =>
useSessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
write: (update) => useSessionStore.getState().setQueuedMessages(serverId, update),
this.sessionStore.getState().sessions[serverId]?.queuedMessages.get(queuedAgentId) ?? [],
write: (update) => this.sessionStore.getState().setQueuedMessages(serverId, update),
},
submitMessage: async ({ text, attachments }) => {
const supportsForgeAttachments =
useSessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch === true;
this.sessionStore.getState().sessions[serverId]?.serverInfo?.features?.forgeSearch ===
true;
await dispatchComposerAgentMessage({
client,
agentId,
@@ -2425,7 +2864,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
@@ -2457,13 +2896,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 });
}
@@ -2500,7 +2955,7 @@ export class HostRuntimeStore {
}
getSnapshot(serverId: string): HostRuntimeSnapshot | null {
return this.controllers.get(serverId)?.getSnapshot() ?? null;
return this.snapshots.get(serverId) ?? null;
}
getConnectionStatusSince(serverId: string): number | null {
@@ -2511,8 +2966,12 @@ export class HostRuntimeStore {
return this.version;
}
getServerInfo(serverId: string) {
return this.sessionStore.getState().sessions[serverId]?.serverInfo ?? null;
}
getClient(serverId: string): DaemonClient | null {
return this.controllers.get(serverId)?.getClient() ?? null;
return this.snapshots.get(serverId)?.client ?? null;
}
subscribe(serverId: string, listener: () => void): () => void {
@@ -2639,9 +3098,9 @@ export class HostRuntimeStore {
): Promise<Awaited<ReturnType<DaemonClient["fetchAgentTimeline"]>>> {
const directory = this.directorySyncByServer.get(serverId);
if (!directory) throw new Error(`Unknown host runtime for serverId ${serverId}`);
const owner = useSessionStore.getState().sessions[serverId]?.viewedTimelineSync;
const owner = this.sessionStore.getState().sessions[serverId]?.viewedTimelineSync;
const page = await directory.fetchTimeline(agentId, request);
if (owner && useSessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
if (owner && this.sessionStore.getState().sessions[serverId]?.viewedTimelineSync === owner) {
owner.flushStreamAgent(agentId);
owner.applyTimelineResponse(page);
}
@@ -2657,6 +3116,7 @@ export class HostRuntimeStore {
const replica = this.timelineReplicaByServer.get(serverId);
if (!replica) throw new Error(`Unknown host runtime for serverId ${serverId}`);
return createViewedTimelineOwner({
store: this.sessionStore,
serverId,
replica,
replaceDemandedAgentIds: (agentIds) => directory.setAgentRouteDemand(agentIds),
@@ -2706,8 +3166,15 @@ export function getHostRuntimeConnectionStatusSince(serverId: string): number |
return getHostRuntimeStore().getConnectionStatusSince(serverId);
}
export const HostRuntimeContext = createContext<HostRuntimeStore | null>(null);
/** Ordinary conversation hooks read the capability bound to their retained screen. */
export function useHostRuntimeStore(): HostRuntimeStore {
return useContext(HostRuntimeContext) ?? getHostRuntimeStore();
}
export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot | null {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => store.getSnapshot(serverId),
@@ -2716,7 +3183,7 @@ export function useHostRuntimeSnapshot(serverId: string): HostRuntimeSnapshot |
}
export function useHostRuntimeClient(serverId: string): DaemonClient | null {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => store.getSnapshot(serverId)?.client ?? null,
@@ -2725,7 +3192,7 @@ export function useHostRuntimeClient(serverId: string): DaemonClient | null {
}
export function useHostRuntimeIsConnected(serverId: string): boolean {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => isHostRuntimeConnected(store.getSnapshot(serverId)),
@@ -2734,7 +3201,7 @@ export function useHostRuntimeIsConnected(serverId: string): boolean {
}
export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeConnectionStatus {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => store.getSnapshot(serverId)?.connectionStatus ?? "connecting",
@@ -2745,7 +3212,7 @@ export function useHostRuntimeConnectionStatus(serverId: string): HostRuntimeCon
export function useHostRuntimeConnectionStatuses(
serverIds: readonly string[],
): ReadonlyMap<string, HostRuntimeConnectionStatus> {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
// The snapshot is the statuses themselves, joined into a string so React compares by
// value. A version counter read only for reactivity is dropped by the React Compiler.
const readStatuses = () =>
@@ -2765,7 +3232,7 @@ export function useHostRuntimeConnectionStatuses(
}
export function useHostRuntimeLastError(serverId: string): string | null {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => store.getSnapshot(serverId)?.lastError ?? null,
@@ -2776,7 +3243,7 @@ export function useHostRuntimeLastError(serverId: string): string | null {
export function useHostRuntimeAgentDirectoryStatus(
serverId: string,
): HostRuntimeAgentDirectoryStatus {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => store.getSnapshot(serverId)?.agentDirectoryStatus ?? "idle",
@@ -2785,7 +3252,7 @@ export function useHostRuntimeAgentDirectoryStatus(
}
export function useHostRuntimeIsDirectoryLoading(serverId: string): boolean {
const store = getHostRuntimeStore();
const store = useHostRuntimeStore();
return useSyncExternalStore(
(onStoreChange) => store.subscribe(serverId, onStoreChange),
() => isHostRuntimeDirectoryLoading(store.getSnapshot(serverId)),
@@ -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 } : {}),
@@ -38,12 +38,14 @@ function createConnection(database: SQLiteDatabase): ReplicaSqliteConnection {
};
}
const expoSqliteDriver: ReplicaSqliteDriver = {
async open() {
return createConnection(await openDatabaseAsync(DATABASE_NAME));
},
};
export function createReplicaRowStore(): ReplicaRowStore {
return createSqliteReplicaRowStore(expoSqliteDriver, REPLICA_ROW_STORE_SCHEMA_VERSION);
export function createReplicaRowStore(authorityNamespace?: string): ReplicaRowStore {
const databaseName = authorityNamespace
? `paseo-replica-${encodeURIComponent(authorityNamespace)}.db`
: DATABASE_NAME;
const driver: ReplicaSqliteDriver = {
async open() {
return createConnection(await openDatabaseAsync(databaseName));
},
};
return createSqliteReplicaRowStore(driver, REPLICA_ROW_STORE_SCHEMA_VERSION);
}
@@ -5,7 +5,7 @@ import {
} from "fake-indexeddb";
import { expect, it, vi } from "vitest";
import { runReplicaRowStoreContract } from "./row-store.contract";
import { createIndexedDbReplicaRowStore } from "./row-store.web";
import { createReplicaRowStore, createIndexedDbReplicaRowStore } from "./row-store.web";
globalThis.indexedDB = fakeIndexedDb;
globalThis.IDBKeyRange = FakeIDBKeyRange;
@@ -49,3 +49,27 @@ it("uses exact IndexedDB keys for targeted rows instead of scanning the host", a
get.mockRestore();
getAll.mockRestore();
});
it("restores only rows in the selected authority namespace while retaining the destination server ID", async () => {
const serverId = `B-${crypto.randomUUID()}`;
const origin = `A-${crypto.randomUUID()}`;
const direct = createReplicaRowStore();
const selected = createReplicaRowStore(origin);
await Promise.all([direct.open(), selected.open()]);
const row = {
serverId,
kind: "agent" as const,
id: "colliding-agent",
payload: "broader-direct",
};
await direct.apply({ upserts: [row], deletes: [] });
expect(await selected.read(serverId, ["agent"])).toEqual([]);
await selected.apply({ upserts: [{ ...row, payload: "selected-authority" }], deletes: [] });
const restored = createReplicaRowStore(origin);
await restored.open();
expect(await restored.read(serverId, ["agent"])).toEqual([
{ ...row, payload: "selected-authority" },
]);
expect(await direct.read(serverId, ["agent"])).toEqual([row]);
await Promise.all([selected.clear(), direct.deleteHost(serverId)]);
});
@@ -210,9 +210,11 @@ export function createIndexedDbReplicaRowStore(
return { open, read, readAll, apply, deleteHost, renameHost, clear };
}
export function createReplicaRowStore(): ReplicaRowStore {
export function createReplicaRowStore(authorityNamespace?: string): ReplicaRowStore {
return createIndexedDbReplicaRowStore({
databaseName: DATABASE_NAME,
databaseName: authorityNamespace
? `${DATABASE_NAME}:${encodeURIComponent(authorityNamespace)}`
: DATABASE_NAME,
schemaVersion: REPLICA_ROW_STORE_SCHEMA_VERSION,
});
}
@@ -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(
+101 -84
View File
@@ -1,4 +1,5 @@
import { create } from "zustand";
import { create, useStore } from "zustand";
import { createContext, useContext } from "react";
import type { UserMessageImageAttachment } from "@/types/stream";
import type { AgentAttachment } from "@getpaseo/protocol/messages";
@@ -48,89 +49,105 @@ interface CreateFlowState {
clearAll: () => void;
}
export const useCreateFlowStore = create<CreateFlowState>((set, get) => ({
pendingByDraftId: {},
setPending: (pending) =>
set((state) => ({
pendingByDraftId: {
...state.pendingByDraftId,
[pending.draftId]: {
...pending,
lifecycle: "active",
},
},
})),
trySetPending: (pending) => {
const existing = get().pendingByDraftId[pending.draftId];
if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned") return false;
get().setPending(pending);
return true;
},
updateAgentId: ({ draftId, agentId }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
if (!current || current.agentId === agentId) {
return state;
}
return {
export const createCreateFlowStore = () =>
create<CreateFlowState>((set, get) => ({
pendingByDraftId: {},
setPending: (pending) =>
set((state) => ({
pendingByDraftId: {
...state.pendingByDraftId,
[draftId]: { ...current, agentId },
[pending.draftId]: {
...pending,
lifecycle: "active",
},
},
};
}),
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
if (!current || current.lifecycle === lifecycle) {
return state;
}
return {
pendingByDraftId: {
...state.pendingByDraftId,
[draftId]: { ...current, lifecycle, errorMessage },
},
};
}),
rekeyDraft: ({ fromDraftId, toDraftId }) =>
set((state) => {
const current = state.pendingByDraftId[fromDraftId];
if (!current) {
return state;
}
if (fromDraftId === toDraftId) {
return state;
}
const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
return {
pendingByDraftId: {
...rest,
[toDraftId]: { ...current, draftId: toDraftId },
},
};
}),
clear: ({ draftId }) =>
set((state) => {
if (!state.pendingByDraftId[draftId]) {
return state;
}
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
return { pendingByDraftId: rest };
}),
clearByAgent: ({ serverId, agentId }) =>
set((state) => {
const next = Object.fromEntries(
Object.entries(state.pendingByDraftId).filter(
([, pending]) =>
pending.lifecycle !== "sent" ||
pending.serverId !== serverId ||
pending.agentId !== agentId,
),
);
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {
return state;
}
return { pendingByDraftId: next };
}),
clearAll: () => set({ pendingByDraftId: {} }),
}));
})),
trySetPending: (pending) => {
const existing = get().pendingByDraftId[pending.draftId];
if (existing?.serverId === pending.serverId && existing.lifecycle !== "abandoned")
return false;
get().setPending(pending);
return true;
},
updateAgentId: ({ draftId, agentId }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
if (!current || current.agentId === agentId) {
return state;
}
return {
pendingByDraftId: {
...state.pendingByDraftId,
[draftId]: { ...current, agentId },
},
};
}),
markLifecycle: ({ draftId, lifecycle, errorMessage }) =>
set((state) => {
const current = state.pendingByDraftId[draftId];
if (!current || current.lifecycle === lifecycle) {
return state;
}
return {
pendingByDraftId: {
...state.pendingByDraftId,
[draftId]: { ...current, lifecycle, errorMessage },
},
};
}),
rekeyDraft: ({ fromDraftId, toDraftId }) =>
set((state) => {
const current = state.pendingByDraftId[fromDraftId];
if (!current) {
return state;
}
if (fromDraftId === toDraftId) {
return state;
}
const { [fromDraftId]: _removed, ...rest } = state.pendingByDraftId;
return {
pendingByDraftId: {
...rest,
[toDraftId]: { ...current, draftId: toDraftId },
},
};
}),
clear: ({ draftId }) =>
set((state) => {
if (!state.pendingByDraftId[draftId]) {
return state;
}
const { [draftId]: _removed, ...rest } = state.pendingByDraftId;
return { pendingByDraftId: rest };
}),
clearByAgent: ({ serverId, agentId }) =>
set((state) => {
const next = Object.fromEntries(
Object.entries(state.pendingByDraftId).filter(
([, pending]) =>
pending.lifecycle !== "sent" ||
pending.serverId !== serverId ||
pending.agentId !== agentId,
),
);
if (Object.keys(next).length === Object.keys(state.pendingByDraftId).length) {
return state;
}
return { pendingByDraftId: next };
}),
clearAll: () => set({ pendingByDraftId: {} }),
}));
export type CreateFlowStoreApi = ReturnType<typeof createCreateFlowStore>;
export const defaultCreateFlowStore = createCreateFlowStore();
export const CreateFlowStoreContext = createContext<CreateFlowStoreApi | null>(null);
export function useCreateFlowStoreApi(): CreateFlowStoreApi {
return useContext(CreateFlowStoreContext) ?? defaultCreateFlowStore;
}
export const useCreateFlowStore: CreateFlowStoreApi = Object.assign(function useCreateFlowStore<T>(
selector: (state: CreateFlowState) => T = ((state: CreateFlowState) => state) as (
state: CreateFlowState,
) => T,
): T {
return useStore(useCreateFlowStoreApi(), selector);
}, defaultCreateFlowStore);
@@ -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;
}
@@ -20,7 +20,7 @@ import {
workspaceEqualityFns,
type WorkspaceStructure,
} from "./selectors";
import { useSessionStore, type WorkspaceDescriptor } from "../session-store";
import { useSessionStoreApi, type WorkspaceDescriptor } from "../session-store";
import type { DesktopBadgeWorkspaceStatus } from "@/utils/desktop-badge-state";
// These are the ONLY supported ways to read workspaces from the session store.
@@ -38,7 +38,7 @@ export function useWorkspace(
workspaceId: string | null,
): WorkspaceDescriptor | null {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectWorkspace(state, serverId, workspaceId),
workspaceEqualityFns.identity,
);
@@ -50,7 +50,7 @@ export function useWorkspaceFields<T>(
project: (w: WorkspaceDescriptor) => T,
): T | null {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectWorkspaceFields(state, serverId, workspaceId, project),
workspaceEqualityFns.deep,
);
@@ -58,7 +58,7 @@ export function useWorkspaceFields<T>(
export function useWorkspaceExists(serverId: string | null, workspaceId: string | null): boolean {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectWorkspaceExists(state, serverId, workspaceId),
workspaceEqualityFns.identity,
);
@@ -66,7 +66,7 @@ export function useWorkspaceExists(serverId: string | null, workspaceId: string
export function useHasHydratedWorkspaces(serverId: string | null): boolean {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectHasHydratedWorkspaces(state, serverId),
workspaceEqualityFns.identity,
);
@@ -74,7 +74,7 @@ export function useHasHydratedWorkspaces(serverId: string | null): boolean {
export function useHydratedWorkspaceServerIds(serverIds: string[]): string[] {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectHydratedWorkspaceServerIds(state, serverIds),
workspaceEqualityFns.deep,
);
@@ -82,7 +82,7 @@ export function useHydratedWorkspaceServerIds(serverIds: string[]): string[] {
export function useWorkspaceDirectoryServerIds(serverIds: string[]): string[] {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectWorkspaceDirectoryServerIds(state, serverIds),
workspaceEqualityFns.deep,
);
@@ -93,7 +93,7 @@ export function useWorkspaceDirectory(
workspaceId: string | null,
): string | null {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectWorkspaceDirectory(state, serverId, workspaceId),
workspaceEqualityFns.identity,
);
@@ -105,7 +105,7 @@ export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure {
[serverIds],
);
const projects = useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
selectProjects,
workspaceEqualityFns.deep,
);
@@ -133,7 +133,7 @@ export function useWorkspaceStructure(serverIds: string[]): WorkspaceStructure {
export function useWorkspaceKeys(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectWorkspaceKeys(state, serverId),
workspaceEqualityFns.deep,
);
@@ -141,7 +141,7 @@ export function useWorkspaceKeys(serverId: string | null): string[] {
export function useRecommendedProjectPaths(serverId: string | null): string[] {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectRecommendedProjectPaths(state, serverId),
workspaceEqualityFns.deep,
);
@@ -149,7 +149,7 @@ export function useRecommendedProjectPaths(serverId: string | null): string[] {
export function useHasWorkspaces(serverId: string | null): boolean {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectHasWorkspaces(state, serverId),
workspaceEqualityFns.identity,
);
@@ -157,7 +157,7 @@ export function useHasWorkspaces(serverId: string | null): boolean {
export function useWorkspaceStatusesForBadges(): DesktopBadgeWorkspaceStatus[] {
return useStoreWithEqualityFn(
useSessionStore,
useSessionStoreApi(),
(state) => selectWorkspaceStatusesForBadges(state),
workspaceEqualityFns.deep,
);
@@ -40,7 +40,8 @@ function createTestAgent(agentId: string): Agent {
title: null,
cwd: "/repo",
model: null,
parentAgentId: null,
parent: null,
managedRelationship: null,
labels: {},
};
}
File diff suppressed because it is too large Load Diff
@@ -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"]),
);
});

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